diff --git a/.claude/agents/mosaic-backend.md b/.claude/agents/mosaic-backend.md index 7a2fd093..ccbc66a6 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-opus-5 +model: claude-fable-5 --- You are the Mosaic backend owner. diff --git a/.claude/agents/mosaic-dashboard.md b/.claude/agents/mosaic-dashboard.md index 7149c97d..f31b7a8e 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-opus-5 +model: claude-fable-5 --- You are the Mosaic dashboard owner. diff --git a/.env.example b/.env.example index 61135377..a8ff28bd 100644 --- a/.env.example +++ b/.env.example @@ -284,6 +284,52 @@ MOSAIC_COMMERCE_CONFIGURATION_V2_SCHEMA_PATH= MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH= MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH= +# ============================================================================= +# Mosaic Billing (Phase 9A) — off by default +# ============================================================================= + +# Billing is off at two independent levels and BOTH must be on: this deployment +# switch, and a per-Project switch (PUT /v1/projects/{id}/billing/settings). +# With this false the billing routes are not registered and the billing worker +# families do not start. +MOSAIC_BILLING_ENABLED=false + +# The public origin Apple posts App Store Server Notifications to. Required when +# billing is enabled, and used only to render the one-time notification endpoint +# URL returned on credential create and rotate. +MOSAIC_BILLING_NOTIFICATION_BASE_URL= + +# How long an encrypted Raw Billing Input body is retained, in days (30-400). +# Ninety is the midpoint of Apple's 180-day production and 30-day sandbox +# notification-history windows. Normalized Transaction Facts are kept +# indefinitely; only the sensitive payload behind them expires, after which +# replay runs from facts and is labelled as such. +MOSAIC_BILLING_RAW_RETENTION_DAYS=90 + +# Dedicated poll interval so store-notification latency is not coupled to +# analytics aggregation load in the shared worker loop. +MOSAIC_BILLING_WORKER_POLL_INTERVAL=1s + +# Observation submission limits. The store notification endpoint is deliberately +# NOT rate limited: a 429 to Apple consumes one of five non-renewable delivery +# attempts and can lose a transaction permanently. SDK observations may be shed +# because SDKs hold a durable queue and retry. +MOSAIC_BILLING_OBSERVATIONS_PER_MINUTE=600 +MOSAIC_BILLING_OBSERVATION_BURST=120 +MOSAIC_BILLING_LIMITER_ENTRIES=10000 + +# Provider hosts. Apple decides the store environment purely by which host is +# called, so these are two separate values rather than one with a flag. +MOSAIC_APPLE_STOREKIT_BASE_URL=https://api.storekit.apple.com +MOSAIC_APPLE_STOREKIT_SANDBOX_BASE_URL=https://api.storekit-sandbox.apple.com +MOSAIC_GOOGLE_PLAY_BASE_URL=https://androidpublisher.googleapis.com +MOSAIC_GOOGLE_PUBSUB_BASE_URL=https://pubsub.googleapis.com + +# NOTE: MOSAIC_PROVIDER_CREDENTIAL_KEYRING (above) is REQUIRED when billing is +# enabled. Every Store Server Credential and every retained Raw Billing Input +# body is sealed under it, and `keyring rotate` must be able to reach both +# tables before any key is retired. + # ============================================================================= # Migration command # ============================================================================= diff --git a/README.md b/README.md index 993ba747..dc3fbba0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ apps/api/ Go API, worker binary, and CLI commands (migrate, keyring) apps/dashboard/ dashboard and Studio (TanStack Start) protocol/ canonical JSON Schemas, validators, and fixtures sdk/flutter/ Flutter SDK sdk/ios/ Swift SDK sdk/android/ Kotlin SDK -packages/ design tokens and design system +packages/ design tokens, design system, and cross-SDK test fixtures examples/ example host apps for all three platforms deploy/ scripts/ deployment profile and operational scripts docs/ documentation diff --git a/SECURITY.md b/SECURITY.md index e45019aa..5e8a5346 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,17 @@ Mosaic's security model assumes the operator completes these steps: it up separately from the database — losing it makes stored provider credentials permanently undecryptable. Rotation is documented in [docs/backend/operations/key-rotation.md](docs/backend/operations/key-rotation.md). +- **Mosaic Billing raises what the keyring protects.** With + `MOSAIC_BILLING_ENABLED` set, the same keyring also seals Apple In-App + Purchase keys, Google service-account keys, and retained Raw Billing Input + bodies — which contain Apple signed payloads and full Google purchase tokens + ([ADR 0023](docs/architecture/decisions/0023-persist-store-transaction-evidence-in-an-append-only-billing-ledger.md)). + Those bodies expire after `MOSAIC_BILLING_RAW_RETENTION_DAYS` (90 by default); + normalized Transaction Facts are kept indefinitely and carry no customer + identity, no price, and no currency. The Apple notification endpoint is + authenticated by an unguessable per-credential intake token in the URL plus + JWS verification against a pinned, compiled-in Apple root; treat the endpoint + URL as a secret and rotate the credential to invalidate it. Browser sessions use opaque tokens stored as SHA-256 digests ([ADR 0017](docs/architecture/decisions/0017-use-opaque-browser-sessions.md)); diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index fa2afc36..397a931d 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -15,19 +15,24 @@ import ( "github.com/rs/zerolog" "github.com/Mujhtech/mosaic/apps/api/internal/analytics" + "github.com/Mujhtech/mosaic/apps/api/internal/billing" "github.com/Mujhtech/mosaic/apps/api/internal/browserauth" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" "github.com/Mujhtech/mosaic/apps/api/internal/experiment" "github.com/Mujhtech/mosaic/apps/api/internal/hostedpublishing" "github.com/Mujhtech/mosaic/apps/api/internal/placementdecision" "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/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" "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" "github.com/Mujhtech/mosaic/apps/api/internal/platform/config" "github.com/Mujhtech/mosaic/apps/api/internal/platform/database" "github.com/Mujhtech/mosaic/apps/api/internal/platform/experimentpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" "github.com/Mujhtech/mosaic/apps/api/internal/platform/hostedpublishingpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver" "github.com/Mujhtech/mosaic/apps/api/internal/platform/logging" @@ -249,6 +254,47 @@ func run() (runErr error) { analyticsKeyLimiter := ratelimit.New(cfg.Analytics.KeyBatchesPerMinute, cfg.Analytics.KeyBatchBurst, cfg.Analytics.LimiterEntries) analyticsEventLimiter := ratelimit.New(cfg.Analytics.KeyEventsPerMinute, cfg.Analytics.KeyEventBurst, cfg.Analytics.LimiterEntries) + var billingService *billing.Service + var billingIPLimiter, billingKeyLimiter *ratelimit.Limiter + if cfg.Billing.Enabled { + billingCipher, err := providercredential.NewAESGCMCipher(cfg.Providers.CredentialKeyring, rand.Reader) + if err != nil { + return fmt.Errorf("configure billing credential encryption: %w", err) + } + // The Apple root is compiled in, so a broken embed fails startup rather + // than the first notification. + verifier, err := appstorejws.NewVerifier() + if err != nil { + return fmt.Errorf("configure Apple notification verification: %w", err) + } + appleClient, err := appstoreserver.New(appstoreserver.Config{ + ProductionBaseURL: cfg.Billing.AppleProductionBaseURL, + SandboxBaseURL: cfg.Billing.AppleSandboxBaseURL, + RequestTimeout: cfg.Providers.RequestTimeout, + ConnectTimeout: cfg.Providers.ConnectTimeout, + MaxResponseBytes: cfg.Providers.MaxResponseBytes, + }) + if err != nil { + return fmt.Errorf("configure App Store Server client: %w", err) + } + googleClient, err := googleplay.New(googleplay.Config{ + PlayBaseURL: cfg.Billing.GooglePlayBaseURL, + PubSubBaseURL: cfg.Billing.GooglePubSubBaseURL, + RequestTimeout: cfg.Providers.RequestTimeout, + ConnectTimeout: cfg.Providers.ConnectTimeout, + MaxResponseBytes: cfg.Providers.MaxResponseBytes, + }) + if err != nil { + return fmt.Errorf("configure Google Play client: %w", err) + } + 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) + } + readiness := health.NewReadiness( health.Check{Name: "postgresql", Code: "database_unavailable", Probe: func(ctx context.Context) error { return database.Ping(ctx, databasePool) @@ -286,6 +332,9 @@ func run() (runErr error) { AnalyticsKeyLimiter: analyticsKeyLimiter, AnalyticsEventLimiter: analyticsEventLimiter, Experiment: experimentService, + Billing: billingService, + BillingIPLimiter: billingIPLimiter, + BillingKeyLimiter: billingKeyLimiter, APILimiter: apiLimiter, DecisionLimiter: decisionLimiter, UploadLimiter: uploadLimiter, diff --git a/apps/api/cmd/billingdemo/main.go b/apps/api/cmd/billingdemo/main.go new file mode 100644 index 00000000..526f70fd --- /dev/null +++ b/apps/api/cmd/billingdemo/main.go @@ -0,0 +1,1115 @@ +//go:build billingdemo + +// This command is excluded from every ordinary build. +// +// It constructs the real Mosaic router and the real billing service but injects +// a locally generated trust anchor through appstorejws.WithRoot, which is a +// verification seam that must never exist in a deployed image. cmd/api and +// cmd/worker call NewVerifier() with no options, so the seam is unreachable +// from the deployed path — but a buildable binary in the same module is one +// stray Dockerfile COPY away from being shipped. The tag makes that impossible +// rather than improbable: +// +// DATABASE_URL=postgres://... go run -tags billingdemo ./cmd/billingdemo +// +// Command billingdemo drives the Phase 9A integrated provider demonstration +// against a real PostgreSQL database, the real Mosaic HTTP router, the real +// billing application service, and the real worker job functions. +// +// What is real: the schema and every constraint and append-only trigger in it; +// the chi router with its full middleware stack; every billing HTTP handler; +// the billing service, its encryption envelopes, its idempotency keys, its +// resolver, and its retry classifier; Mosaic's own App Store Server API and +// Google Play/Pub/Sub HTTP clients including the Google RS256 JWT-bearer +// assertion; and the five worker job entry points cmd/worker schedules. +// +// What is synthetic, and cannot be otherwise without a live store: the Apple +// signing chain (generated locally and injected through the verifier's +// documented WithRoot seam), the provider API responses (served by local stubs +// speaking the documented shapes), and the credential material. +// +// Usage: +// +// DATABASE_URL=postgres://... go run -tags billingdemo ./cmd/billingdemo +// +// The command is destructive to the demo tenant it owns (org_demo9a) and +// touches nothing else. +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "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/billingpostgres" + "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" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "demonstration failed:", err) + os.Exit(1) + } +} + +type demo struct { + ctx context.Context + pool *pgxpool.Pool + service *billing.Service + server *httptest.Server + + apple *appleStub + play *playStub + pubsub *pubsubStub + oauth *oauthStub + chain demoChain + + publicKey apiKey + serverKey apiKey + + appleCredentialID string + googleCredentialID string + intakePath string + + stepNumber int + started time.Time +} + +func run() error { + 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) + defer cancel() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return err + } + defer pool.Close() + + d := &demo{ctx: ctx, pool: pool, started: time.Now()} + if err := d.wire(); err != nil { + return err + } + defer d.server.Close() + + for _, stage := range []func() error{ + d.stageSetup, + d.stageApple, + d.stageGoogle, + d.stageQuarantine, + d.stageRetry, + 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, +// each named in the output so no reader can mistake one for production +// behaviour. +func (d *demo) wire() error { + chain, err := newDemoChain() + if err != nil { + return err + } + d.chain = chain + + d.apple = newAppleStub() + d.play = newPlayStub() + d.pubsub = newPubSubStub() + if d.oauth, err = newOAuthStub(); err != nil { + return err + } + // Installed before googleplay.New, which clones http.DefaultTransport. + d.oauth.install() + + keyring, err := newDemoKeyring() + if err != nil { + return err + } + cipher, err := providercredential.NewAESGCMCipher(keyring, rand.Reader) + if err != nil { + return err + } + + // SUBSTITUTION 1: the verifier trusts a locally generated root instead of + // the embedded Apple Root CA - G3. This is appstorejws' documented WithRoot + // option, the same seam its unit tests use. + verifier, err := appstorejws.NewVerifier(appstorejws.WithRoot(chain.root)) + if err != nil { + return err + } + // SUBSTITUTION 2: the provider base URLs point at loopback stubs. These are + // existing configuration knobs (MOSAIC_APPLE_STOREKIT_BASE_URL and friends); + // the clients themselves are unmodified. + appleClient, err := appstoreserver.New(appstoreserver.Config{ + ProductionBaseURL: d.apple.server.URL, SandboxBaseURL: d.apple.server.URL, + RequestTimeout: 5 * time.Second, + }) + if err != nil { + return err + } + googleClient, err := googleplay.New(googleplay.Config{ + PlayBaseURL: d.play.server.URL, PubSubBaseURL: d.pubsub.server.URL, + RequestTimeout: 5 * time.Second, + }) + if err != nil { + return err + } + + d.service = billing.NewService(billingpostgres.New(d.pool), cipher, verifier, + billing.WithProviders(appleClient, googleClient), + billing.WithNotificationBaseURL(demoNotificationOrigin)) + + logger := zerolog.New(io.Discard) + // SUBSTITUTION 3: the dashboard principal resolver returns a fixed actor + // rather than validating a browser session cookie. Authorization is NOT + // substituted: owner/admin membership is still enforced by the real + // repository queries against the real organization_members row. + resolver := authn.ResolverFunc(func(r *http.Request) (authn.Principal, error) { + if r.Header.Get("X-Demo-Actor") == "" { + return authn.Principal{}, authn.ErrUnauthenticated + } + return authn.Principal{ActorID: r.Header.Get("X-Demo-Actor"), Method: "demo", AuthenticatedAt: time.Now().UTC()}, nil + }) + + handler := httpserver.NewWithDependencies(httpserver.Config{ + ServiceName: "mosaic-billing-demo", RequestTimeout: 30 * time.Second, + AllowedOrigins: []string{demoNotificationOrigin}, + }, 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), + }) + d.server = httptest.NewServer(handler) + return nil +} + +func newDemoKeyring() (string, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return "", err + } + return fmt.Sprintf(`{"version":1,"activeKeyId":"demo-2026-07","keys":{"demo-2026-07":%q}}`, + base64.RawURLEncoding.EncodeToString(key)), nil +} + +// --------------------------------------------------------------------------- +// Stage 0 — setup +// --------------------------------------------------------------------------- + +func (d *demo) stageSetup() error { + d.section("0", "Environment and tenant") + publicKey, serverKey, err := seedTenant(d.ctx, d.pool) + if err != nil { + return err + } + d.publicKey, d.serverKey = publicKey, serverKey + d.note("seeded %s / %s / %s (mode=production), applications %s (ios) and %s (android)", + organizationID, projectID, environmentID, iosApplicationID, androidApplicationID) + d.note("public SDK key %s., secret server key %s.", publicKey.prefix, serverKey.prefix) + d.query("provider product mappings seeded (iOS yearly deliberately absent)", + `SELECT id, provider, provider_product_identifier, product_id, status + FROM provider_product_mappings WHERE project_id=$1 ORDER BY id`, projectID) + + d.step("Enable Mosaic Billing for the Project (off by default)") + status, body := d.authed(http.MethodPut, "/v1/projects/"+projectID+"/billing/settings", + map[string]any{"billingEnabled": true}) + d.http("PUT /v1/projects/{projectId}/billing/settings", status, body) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 1 — Apple +// --------------------------------------------------------------------------- + +func (d *demo) stageApple() error { + d.section("1", "Apple flow") + + d.step("Create the Apple Store Server Credential (secret encrypted at rest)") + secret, err := newApplePrivateKeyPEM() + if err != nil { + return err + } + status, body := d.authed(http.MethodPost, "/v1/projects/"+projectID+"/billing/store-credentials", map[string]any{ + "environmentId": environmentID, "provider": "app_store", "storeEnvironment": "production", + "name": "Demo Apple team key", "secret": string(secret), + "appleIssuerId": "57246542-96fe-1a63-e053-0824d011072a", "appleKeyId": "2X9R4HXF34", + "applications": []map[string]string{{ + "applicationId": iosApplicationID, "platform": "ios", "providerApplicationIdentifier": appleBundleID, + }}, + }) + d.http("POST /v1/projects/{projectId}/billing/store-credentials", status, redactEndpoint(body)) + if status != http.StatusCreated { + return fmt.Errorf("apple 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.appleCredentialID = created.Data.ID + d.intakePath = strings.TrimPrefix(created.Data.NotificationEndpointURL, demoNotificationOrigin) + if d.intakePath == "" { + return errors.New("no notification endpoint URL was returned") + } + + d.step("Prove the stored secret is ciphertext, not the PEM that was posted") + d.query("store_server_credentials row", + `SELECT id, provider, store_environment, status, algorithm, key_id, + octet_length(ciphertext) AS ciphertext_bytes, + encode(substring(ciphertext from 1 for 16),'hex') AS ciphertext_head, + (encode(ciphertext,'escape') LIKE '%PRIVATE KEY%') AS contains_pem_marker, + (intake_token_digest IS NOT NULL) AS has_intake_token_digest, + octet_length(intake_token_digest) AS intake_digest_bytes + FROM store_server_credentials WHERE id=$1`, d.appleCredentialID) + d.query("the plaintext token is not stored anywhere on the row (column list)", + `SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) AS columns + FROM information_schema.columns WHERE table_name='store_server_credentials'`) + + d.step("Client observation through the public SDK endpoint (contract envelope)") + status, body = d.public(http.MethodPost, "/v1/sdk/billing/observations", d.publicKey.raw, clientObservation( + "obs_demo_apple_1", "sub_demo_apple_1", "apple_app_store", + billing.ReferenceAppStoreTransactionID, appleTransactionID)) + d.http("POST /v1/sdk/billing/observations", status, body) + + d.step("Synthetic signed Apple notification through the intake endpoint") + occurred := time.Now().Add(-2 * time.Minute).UTC() + notification, err := d.chain.appleNotificationBody(appleNotificationUUID, appleTransactionID, appleMonthlyProductID, occurred) + if err != nil { + return err + } + signedTransaction, err := d.chain.signJWS(appleTransactionPayload(appleTransactionID, appleMonthlyProductID, occurred)) + if err != nil { + return err + } + d.apple.addTransaction(appleTransactionID, signedTransaction) + + status, body = d.raw(http.MethodPost, d.intakePath, notification, nil) + d.http("POST /v1/billing/apple/notifications/{intakeToken}", status, body) + d.note("request body was %d bytes of signed JWS; it is not reproduced here", len(notification)) + d.query("raw billing input persisted, body encrypted", + `SELECT id, provider, source, source_authority, authentication_result, store_environment, + notification_kind, notification_subtype, ingestion_status, body_state, algorithm, + octet_length(ciphertext) AS ciphertext_bytes, + encode(provider_event_id::bytea,'escape') AS provider_event_id + FROM billing_raw_inputs WHERE project_id=$1 AND provider='app_store' ORDER BY received_at`, projectID) + d.query("validation job queued by intake (intake never validates inline)", + `SELECT j.status, j.attempt_count, j.max_attempts, (j.available_at <= now()) AS available_now + FROM billing_validation_jobs j WHERE j.project_id=$1`, projectID) + + d.step("Run the validation worker job (billing.Service.ProcessNextValidation)") + if err := d.drainValidation(4); err != nil { + return err + } + d.note("Apple stub calls: %v", d.apple.callLog()) + d.query("transaction fact recorded and resolved to a Mosaic Product", + `SELECT f.provider, f.store_environment, f.provider_transaction_id, f.provider_product_identifier, + f.transaction_type, f.fact_kind, f.resolution_state, f.mosaic_product_id, + f.provider_product_mapping_id, f.is_test_transaction, encode(f.fact_digest,'hex') AS fact_digest + FROM billing_transaction_facts f WHERE f.project_id=$1`, projectID) + d.query("resolution snapshot records the exact mapping version used", + `SELECT outcome, resolution_state, candidate_count, provider_product_identifier, + mosaic_product_id, provider_product_mapping_id, matched_mapping_id, mapping_version + FROM billing_product_resolutions WHERE project_id=$1`, projectID) + + d.step("Redeliver the identical notification (Apple retries on any non-2xx)") + status, body = d.raw(http.MethodPost, d.intakePath, notification, nil) + d.http("POST /v1/billing/apple/notifications/{intakeToken} (redelivery)", status, body) + // Two Apple inputs and two jobs: the notification plus the client + // observation from step 3, which is now validated in its own right. Two + // facts for the same transaction, because the observation's normalized fact + // carries no renewal expectation — see the note printed below. + d.query("counts after the redelivery", + `SELECT + (SELECT count(*) FROM billing_raw_inputs WHERE project_id=$1 AND provider='app_store') AS apple_inputs, + (SELECT count(*) FROM billing_transaction_facts WHERE project_id=$1) AS facts, + (SELECT count(*) FROM billing_validation_jobs WHERE project_id=$1) AS jobs, + (SELECT count(*) FROM billing_validation_attempts WHERE project_id=$1) AS attempts`, projectID) + d.query("the notification produced exactly one input and one job on both deliveries", + `SELECT i.source, count(DISTINCT i.id) AS inputs, count(DISTINCT j.id) AS jobs, + count(DISTINCT f.id) AS facts + FROM billing_raw_inputs i + LEFT JOIN billing_validation_jobs j ON j.raw_input_id=i.id + LEFT JOIN billing_transaction_facts f ON f.source_raw_input_id=i.id + WHERE i.project_id=$1 AND i.provider='app_store' + GROUP BY i.source ORDER BY i.source`, projectID) + d.query("ledger entries for the Apple input", + `SELECT entry_type, count(*) FROM billing_ledger_entries + WHERE project_id=$1 GROUP BY entry_type ORDER BY entry_type`, projectID) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 2 — Google +// --------------------------------------------------------------------------- + +func (d *demo) stageGoogle() error { + d.section("2", "Google flow") + + d.step("Create the Google Store Server Credential") + secret, err := newGoogleServiceAccountJSON(googleServiceAccount, pubSubProjectID) + if err != nil { + return err + } + status, body := d.authed(http.MethodPost, "/v1/projects/"+projectID+"/billing/store-credentials", map[string]any{ + "environmentId": environmentID, "provider": "google_play", "storeEnvironment": "production", + "name": "Demo Play service account", "secret": string(secret), + "googleClientEmail": googleServiceAccount, "googlePubSubProjectId": pubSubProjectID, + "googlePubSubSubscriptionId": pubSubSubscriptionID, + "applications": []map[string]string{{ + "applicationId": androidApplicationID, "platform": "android", "providerApplicationIdentifier": googlePackageName, + }}, + }) + d.http("POST /v1/projects/{projectId}/billing/store-credentials", status, redactEndpoint(body)) + if status != http.StatusCreated { + return fmt.Errorf("google 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.googleCredentialID = created.Data.ID + d.note("no notification endpoint URL is issued for Google (RTDN is pulled, not pushed): %q", + created.Data.NotificationEndpointURL) + + d.step("Client observation carrying only the Google token digest") + status, body = d.public(http.MethodPost, "/v1/sdk/billing/observations", d.publicKey.raw, clientObservation( + "obs_demo_google_1", "sub_demo_google_1", "google_play", + billing.ReferenceGooglePlayTokenDigest, hex.EncodeToString(billing.TokenDigest(googlePurchaseToken)))) + d.http("POST /v1/sdk/billing/observations", status, body) + + d.step("Publish an RTDN and run the pull consumer (billing.Service.ProcessNextRTDN)") + d.play.setSubscription(googlePurchaseToken, googleSubscriptionPurchase(time.Now().Add(-3*time.Minute).UTC())) + // The same encoded notification is reused for the redelivery below, because + // Pub/Sub redelivers byte-identical content under a new ackId. + rtdn := rtdnMessageData(googlePackageName, googleSubscriptionID, googlePurchaseToken, 4, time.Now().UTC()) + d.pubsub.enqueue("ack-demo-1", googleMessageID, rtdn, time.Now().UTC()) + + processed, err := d.service.ProcessNextRTDN(d.ctx, "demo-worker") + if err != nil { + return err + } + pulls, acknowledged := d.pubsub.state() + d.note("ProcessNextRTDN processed=%v; pub/sub pulls=%d acknowledged=%v; oauth exchanges=%d", + processed, pulls, acknowledged, d.oauth.exchangeCount()) + d.query("RTDN persisted as a raw input, token never stored in the clear", + `SELECT id, source, source_authority, authentication_result, store_environment, notification_kind, + ingestion_status, body_state, algorithm, octet_length(ciphertext) AS ciphertext_bytes, + encode(transaction_reference_digest,'hex') AS token_digest + FROM billing_raw_inputs WHERE project_id=$1 AND provider='google_play' ORDER BY received_at`, projectID) + + d.step("Validate the RTDN against the authoritative Play API lookup") + if err := d.drainValidation(4); err != nil { + return err + } + d.note("Play stub calls: %v", d.play.callLog()) + d.query("Google transaction fact", + `SELECT provider, store_environment, provider_transaction_id, provider_product_identifier, + provider_base_plan_identifier, transaction_type, fact_kind, resolution_state, + mosaic_product_id, provider_product_mapping_id, is_test_transaction + FROM billing_transaction_facts WHERE project_id=$1 AND provider='google_play'`, projectID) + + d.step("Redeliver the byte-identical Pub/Sub message under a new ackId") + d.pubsub.enqueue("ack-demo-2", googleMessageID, rtdn, time.Now().UTC()) + if _, err := d.service.ProcessNextRTDN(d.ctx, "demo-worker"); err != nil { + return err + } + if err := d.drainValidation(4); err != nil { + return err + } + _, acknowledged = d.pubsub.state() + d.note("acknowledged ack ids: %v (the redelivery is acknowledged, not re-ingested)", acknowledged) + d.query("one RTDN input, one Google fact, one duplicate ledger entry for the redelivery", + `SELECT + (SELECT count(*) FROM billing_raw_inputs WHERE project_id=$1 AND provider='google_play' AND source='google_rtdn') AS rtdn_inputs, + (SELECT count(*) FROM billing_transaction_facts WHERE project_id=$1 AND provider='google_play') AS google_facts, + (SELECT count(*) FROM billing_ledger_entries l JOIN billing_raw_inputs i ON i.id=l.raw_input_id + WHERE l.entry_type='input_duplicate_detected' AND i.source='google_rtdn') AS rtdn_duplicate_entries`, + projectID) + + d.step("Observation-sourced inputs: the credential is resolved from the Environment scope") + d.query("every observation input, the credential its attempt resolved, and the outcome", + `SELECT i.provider, i.provider_event_id, + COALESCE(i.credential_id,'(none on input)') AS input_credential_id, + COALESCE(a.credential_id,'(none)') AS resolved_credential_id, + a.outcome, COALESCE(a.diagnostic_code,'') AS diagnostic_code + FROM billing_raw_inputs i + LEFT JOIN billing_validation_attempts a ON a.raw_input_id=i.id + WHERE i.project_id=$1 AND i.source='client_observation' + ORDER BY i.received_at`, projectID) + d.query("the fact the Apple client observation produced on its own", + `SELECT f.provider, f.provider_transaction_id, f.provider_product_identifier, + f.resolution_state, f.mosaic_product_id, f.renewal_expected, + encode(f.fact_digest,'hex') AS fact_digest + FROM billing_transaction_facts f + JOIN billing_raw_inputs i ON i.id=f.source_raw_input_id + WHERE i.source='client_observation' AND i.project_id=$1`, projectID) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 3 — quarantine and repair +// --------------------------------------------------------------------------- + +func (d *demo) stageQuarantine() error { + d.section("3", "Quarantine: authentic transaction, unmapped provider Product") + + d.step("Deliver a valid notification for com.mosaic.demo.pro.yearly, which has no mapping") + occurred := time.Now().Add(-90 * time.Second).UTC() + notification, err := d.chain.appleNotificationBody(appleYearlyNotificationUUID, appleYearlyTransactionID, appleYearlyProductID, occurred) + if err != nil { + return err + } + signed, err := d.chain.signJWS(appleTransactionPayload(appleYearlyTransactionID, appleYearlyProductID, occurred)) + if err != nil { + return err + } + d.apple.addTransaction(appleYearlyTransactionID, signed) + status, body := d.raw(http.MethodPost, d.intakePath, notification, nil) + d.http("POST /v1/billing/apple/notifications/{intakeToken}", status, body) + + if err := d.drainValidation(4); err != nil { + return err + } + d.query("authenticity verified, resolution failed", + `SELECT a.attempt_number, a.outcome, a.failure_category, a.diagnostic_code, a.store_environment + FROM billing_validation_attempts a + JOIN billing_raw_inputs i ON i.id=a.raw_input_id + WHERE i.provider_event_id=$1 ORDER BY a.attempt_number`, appleYearlyNotificationUUID) + d.query("quarantine record created", + `SELECT q.id, q.reason_code, q.severity, q.status, q.diagnostic_code, q.scopes + FROM billing_quarantine_records q + JOIN billing_raw_inputs i ON i.id=q.raw_input_id + WHERE i.provider_event_id=$1`, appleYearlyNotificationUUID) + d.query("the fact is still recorded, marked unresolved — evidence is never discarded", + `SELECT provider_product_identifier, resolution_state, COALESCE(mosaic_product_id,'(none)') AS mosaic_product_id + FROM billing_transaction_facts WHERE provider_transaction_id=$1`, appleYearlyTransactionID) + + d.step("Operator repair: create the missing Provider Product Mapping") + if err := addAppleYearlyMapping(d.ctx, d.pool); err != nil { + return err + } + d.note("inserted %s → %s", appleYearlyMapping, mosaicYearlyProduct) + + d.step("Re-run validation through the quarantine retry endpoint") + recordID, err := d.quarantineRecordFor(appleYearlyNotificationUUID) + if err != nil { + return err + } + status, body = d.authed(http.MethodPost, + "/v1/projects/"+projectID+"/billing/quarantine/"+recordID+"/retry", nil) + d.http("POST /v1/projects/{projectId}/billing/quarantine/{recordId}/retry", status, body) + if err := d.drainValidation(4); err != nil { + return err + } + d.query("attempt history is append-only: the failed attempt is still there", + `SELECT a.attempt_number, a.outcome, COALESCE(a.diagnostic_code,'') AS diagnostic_code + FROM billing_validation_attempts a + JOIN billing_raw_inputs i ON i.id=a.raw_input_id + WHERE i.provider_event_id=$1 ORDER BY a.attempt_number`, appleYearlyNotificationUUID) + d.query("the original raw input is unchanged and its encrypted body is still present", + `SELECT id, ingestion_status, body_state, algorithm, octet_length(ciphertext) AS ciphertext_bytes, + received_at, envelope_rotated_at + FROM billing_raw_inputs WHERE provider_event_id=$1`, appleYearlyNotificationUUID) + d.query("facts for the yearly transaction, before and after the repair", + `SELECT resolution_state, COALESCE(mosaic_product_id,'(none)') AS mosaic_product_id, + COALESCE(provider_product_mapping_id,'(none)') AS mapping_id, recorded_at + FROM billing_transaction_facts WHERE provider_transaction_id=$1 ORDER BY recorded_at`, + appleYearlyTransactionID) + d.query("quarantine record and its action audit", + `SELECT q.status, q.reason_code, COALESCE(q.closing_attempt_id,'(open)') AS closing_attempt_id, + (SELECT string_agg(action||'/'||outcome, ', ') FROM billing_quarantine_actions + WHERE quarantine_record_id=q.id) AS actions + FROM billing_quarantine_records q WHERE q.id=$1`, recordID) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 4 — retry +// --------------------------------------------------------------------------- + +func (d *demo) stageRetry() error { + d.section("4", "Retry: provider outage, then recovery") + + d.step("Point the Apple stub at a 503 and deliver a new notification") + occurred := time.Now().Add(-60 * time.Second).UTC() + notification, err := d.chain.appleNotificationBody(appleRetryNotificationUUID, appleRetryTransactionID, appleMonthlyProductID, occurred) + if err != nil { + return err + } + signed, err := d.chain.signJWS(appleTransactionPayload(appleRetryTransactionID, appleMonthlyProductID, occurred)) + if err != nil { + return err + } + d.apple.addTransaction(appleRetryTransactionID, signed) + d.apple.setFailure(http.StatusServiceUnavailable) + + status, body := d.raw(http.MethodPost, d.intakePath, notification, nil) + d.http("POST /v1/billing/apple/notifications/{intakeToken}", status, body) + if _, err := d.service.ProcessNextValidation(d.ctx, "demo-worker"); err != nil { + return err + } + d.query("retryable attempt recorded, no fact", + `SELECT a.attempt_number, a.outcome, a.retryable, a.failure_category, a.diagnostic_code, + a.provider_http_status, COALESCE(a.provider_code,'') AS provider_code + FROM billing_validation_attempts a + JOIN billing_raw_inputs i ON i.id=a.raw_input_id + WHERE i.provider_event_id=$1 ORDER BY a.attempt_number`, appleRetryNotificationUUID) + d.query("the job is queued again with backoff, not failed", + `SELECT j.status, j.attempt_count, (j.available_at > now()) AS scheduled_in_future, + round(extract(epoch from (j.available_at - now())))::text AS seconds_until_available + FROM billing_validation_jobs j + JOIN billing_raw_inputs i ON i.id=j.raw_input_id + WHERE i.provider_event_id=$1`, appleRetryNotificationUUID) + + d.step("Recover the stub and wait for the scheduled retry") + d.apple.setFailure(0) + waited, err := d.waitForJob(appleRetryNotificationUUID, 90*time.Second) + 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.drainValidation(4); err != nil { + return err + } + d.query("the failed attempt is preserved beside the successful one", + `SELECT a.attempt_number, a.outcome, COALESCE(a.diagnostic_code,'') AS diagnostic_code, a.latency_ms + FROM billing_validation_attempts a + JOIN billing_raw_inputs i ON i.id=a.raw_input_id + WHERE i.provider_event_id=$1 ORDER BY a.attempt_number`, appleRetryNotificationUUID) + d.query("fact recorded on the retry", + `SELECT provider_transaction_id, resolution_state, mosaic_product_id, fact_kind + FROM billing_transaction_facts WHERE provider_transaction_id=$1`, appleRetryTransactionID) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 5 — reconciliation +// --------------------------------------------------------------------------- + +func (d *demo) stageReconciliation() error { + d.section("5", "Reconciliation: a notification Mosaic never received") + + d.step("Build a notification and deliberately NOT deliver it to the intake endpoint") + occurred := time.Now().Add(-45 * time.Second).UTC() + notification, err := d.chain.appleNotificationBody(appleMissedNotificationUUID, appleMissedTransactionID, appleMonthlyProductID, occurred) + if err != nil { + return err + } + signed, err := d.chain.signJWS(appleTransactionPayload(appleMissedTransactionID, appleMonthlyProductID, occurred)) + if err != nil { + return err + } + d.apple.addTransaction(appleMissedTransactionID, signed) + + var envelope struct { + SignedPayload string `json:"signedPayload"` + } + if err := json.Unmarshal([]byte(notification), &envelope); err != nil { + return err + } + // Apple's Get Notification History will report it as a failed delivery. + d.apple.setHistory(envelope.SignedPayload) + d.query("Mosaic has no input for this notification UUID", + `SELECT count(*) AS inputs FROM billing_raw_inputs WHERE provider_event_id=$1`, appleMissedNotificationUUID) + + d.step("Queue a reconciliation run (apple_notification_history)") + status, body := d.authed(http.MethodPost, + "/v1/projects/"+projectID+"/environments/"+environmentID+"/billing/reconciliation-runs", map[string]any{ + "credentialId": d.appleCredentialID, "provider": "app_store", + "strategy": "apple_notification_history", + "windowStart": time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339), + "windowEnd": time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + }) + d.http("POST .../billing/reconciliation-runs", status, body) + + d.step("Run the reconciliation worker job") + processed, err := d.service.ProcessNextReconciliation(d.ctx, "demo-worker") + if err != nil { + return err + } + d.note("ProcessNextReconciliation processed=%v", processed) + d.query("run summary recorded", + `SELECT strategy, trigger, status, examined_count, discovered_count, duplicate_count, failure_count, + COALESCE(last_error_code,'') AS last_error_code, started_at IS NOT NULL AS started, completed_at IS NOT NULL AS completed + FROM billing_reconciliation_runs WHERE project_id=$1`, projectID) + d.query("the missed notification was discovered and ingested through the same pipeline", + `SELECT id, source, source_authority, authentication_result, ingestion_status, body_state, correlation_id + FROM billing_raw_inputs WHERE provider_event_id=$1`, appleMissedNotificationUUID) + + d.step("Validate the discovered input") + if err := d.drainValidation(4); err != nil { + return err + } + d.query("fact recorded from the recovered notification", + `SELECT provider_transaction_id, resolution_state, mosaic_product_id, fact_kind + FROM billing_transaction_facts WHERE provider_transaction_id=$1`, appleMissedTransactionID) + + d.step("Run reconciliation again over the same window (idempotency)") + status, body = d.authed(http.MethodPost, + "/v1/projects/"+projectID+"/environments/"+environmentID+"/billing/reconciliation-runs", map[string]any{ + "credentialId": d.appleCredentialID, "provider": "app_store", + "strategy": "apple_notification_history", + "windowStart": time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339), + "windowEnd": time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + }) + d.http("POST .../billing/reconciliation-runs (second run)", status, body) + if _, err := d.service.ProcessNextReconciliation(d.ctx, "demo-worker"); err != nil { + return err + } + d.query("second run reports the item as a duplicate, not a discovery", + `SELECT status, examined_count, discovered_count, duplicate_count + FROM billing_reconciliation_runs WHERE project_id=$1 ORDER BY created_at`, projectID) + d.query("still one input and one fact for the recovered notification", + `SELECT (SELECT count(*) FROM billing_raw_inputs WHERE provider_event_id=$1) AS inputs, + (SELECT count(*) FROM billing_transaction_facts WHERE provider_transaction_id=$2) AS facts`, + appleMissedNotificationUUID, appleMissedTransactionID) + + d.step("The other wired strategy: google_token_requery") + var attemptsBefore int + if err := d.pool.QueryRow(d.ctx, + `SELECT count(*) FROM billing_validation_attempts WHERE project_id=$1`, projectID).Scan(&attemptsBefore); err != nil { + return err + } + status, body = d.authed(http.MethodPost, + "/v1/projects/"+projectID+"/environments/"+environmentID+"/billing/reconciliation-runs", map[string]any{ + "credentialId": d.googleCredentialID, "provider": "google_play", + "strategy": "google_token_requery", + "windowStart": time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339), + "windowEnd": time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + }) + d.http("POST .../billing/reconciliation-runs (google_token_requery)", status, body) + playCallsBefore := len(d.play.callLog()) + if _, err := d.service.ProcessNextReconciliation(d.ctx, "demo-worker"); err != nil { + return err + } + d.query("google_token_requery run summary", + `SELECT strategy, status, examined_count, discovered_count, duplicate_count, failure_count + FROM billing_reconciliation_runs WHERE project_id=$1 AND strategy='google_token_requery'`, projectID) + var attemptsAfter int + if err := d.pool.QueryRow(d.ctx, + `SELECT count(*) FROM billing_validation_attempts WHERE project_id=$1`, projectID).Scan(&attemptsAfter); err != nil { + return err + } + d.note("validation attempts before the run: %d; after: %d (+%d)", + attemptsBefore, attemptsAfter, attemptsAfter-attemptsBefore) + d.note("Play API calls before the run: %d; after: %d (+%d)", + playCallsBefore, len(d.play.callLog()), len(d.play.callLog())-playCallsBefore) + d.query("the run re-queried only Google inputs, and appended an attempt to each", + `SELECT i.provider, i.source, count(a.id) AS attempts + FROM billing_raw_inputs i + LEFT JOIN billing_validation_attempts a ON a.raw_input_id=i.id + WHERE i.project_id=$1 GROUP BY i.provider, i.source ORDER BY i.provider, i.source`, projectID) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 6 — replay +// --------------------------------------------------------------------------- + +func (d *demo) stageReplay() error { + d.section("6", "Replay / revalidation of a prior Raw Billing Input") + + rawInputID, err := d.rawInputFor(appleNotificationUUID) + if err != nil { + return err + } + d.note("replaying %s (the Apple notification from stage 1)", rawInputID) + d.query("state before replay", + `SELECT (SELECT count(*) FROM billing_validation_attempts WHERE raw_input_id=$1) AS attempts, + (SELECT count(*) FROM billing_transaction_facts WHERE source_raw_input_id=$1) AS facts, + (SELECT count(*) FROM billing_transaction_facts WHERE project_id=$2) AS facts_total`, + rawInputID, projectID) + + d.step("Queue a replay job through the API") + status, body := d.authed(http.MethodPost, + "/v1/projects/"+projectID+"/environments/"+environmentID+"/billing/replay-jobs", map[string]any{ + "kind": "revalidation", "rawInputId": rawInputID, + }) + d.http("POST .../billing/replay-jobs", status, body) + + d.step("Run the replay worker job, then drain validation") + processed, err := d.service.ProcessNextReplay(d.ctx, "demo-worker") + if err != nil { + return err + } + d.note("ProcessNextReplay processed=%v", processed) + d.query("replay job summary", + `SELECT kind, status, validator_version, examined_count, unchanged_count, new_fact_count, + conflict_count, COALESCE(comparison_result,'') AS comparison_result + FROM billing_replay_jobs WHERE project_id=$1`, projectID) + d.note("Apple stub calls during the replay: %d total (the replay really re-read the store)", len(d.apple.callLog())) + d.query("state after replay", + `SELECT (SELECT count(*) FROM billing_validation_attempts WHERE raw_input_id=$1) AS attempts, + (SELECT count(*) FROM billing_transaction_facts WHERE source_raw_input_id=$1) AS facts, + (SELECT count(*) FROM billing_transaction_facts WHERE project_id=$2) AS facts_total`, + rawInputID, projectID) + d.query("attempt history for the replayed input — appended, never rewritten", + `SELECT attempt_number, outcome, validator_version, started_at + FROM billing_validation_attempts WHERE raw_input_id=$1 ORDER BY attempt_number`, rawInputID) + d.query("the replayed attempt recomputed the identical fact digest, so nothing was appended", + `SELECT encode(fact_digest,'hex') AS fact_digest, resolution_state, mosaic_product_id, recorded_at + FROM billing_transaction_facts WHERE source_raw_input_id=$1 ORDER BY recorded_at`, rawInputID) + d.query("the deduplication is recorded rather than silent", + `SELECT entry_type, count(*) FROM billing_ledger_entries + WHERE raw_input_id=$1 GROUP BY entry_type ORDER BY entry_type`, rawInputID) + return nil +} + +// --------------------------------------------------------------------------- +// Stage 7 — no customer-access state exists +// --------------------------------------------------------------------------- + +func (d *demo) stageNoAccessState() error { + d.section("7", "No customer-access or entitlement state exists") + + d.query("tables whose name suggests customer access, entitlement state, or a subscriber record", + `SELECT table_name FROM information_schema.tables + WHERE table_schema='public' AND ( + table_name ILIKE '%customer%' OR table_name ILIKE '%subscriber%' OR + table_name ILIKE '%access_grant%' OR table_name ILIKE '%entitlement_state%' OR + table_name ILIKE '%subscription_state%' OR table_name ILIKE '%user_entitlement%' OR + table_name ILIKE '%revenuecat_migration%') + ORDER BY table_name`) + d.query("every table whose name contains 'entitlement' (Phase 3A catalog definitions only)", + `SELECT t.table_name, string_agg(c.column_name, ', ' ORDER BY c.ordinal_position) AS columns + FROM information_schema.tables t + JOIN information_schema.columns c ON c.table_name=t.table_name AND c.table_schema=t.table_schema + WHERE t.table_schema='public' AND t.table_name ILIKE '%entitlement%' + GROUP BY t.table_name ORDER BY t.table_name`) + d.query("no billing table carries a customer identity, a price, or a currency", + `SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema='public' AND table_name LIKE 'billing_%' AND ( + column_name ILIKE '%customer%' OR column_name ILIKE '%subscriber%' OR + column_name ILIKE '%user_id%' OR column_name ILIKE '%account_token%' OR + column_name ILIKE '%price%' OR column_name ILIKE '%currency%' OR + column_name ILIKE '%amount%' OR column_name ILIKE '%email%') + ORDER BY table_name, column_name`) + d.query("every billing table created by Phase 9A", + `SELECT table_name FROM information_schema.tables + WHERE table_schema='public' AND (table_name LIKE 'billing_%' OR table_name LIKE 'store_server_%') + ORDER BY table_name`) + return nil +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ( + appleTransactionID = "2000000512345671" + appleNotificationUUID = "8f2c1a4e-1111-4a1b-9c11-demo00000001" + + appleYearlyTransactionID = "2000000512345672" + appleYearlyNotificationUUID = "8f2c1a4e-2222-4a1b-9c11-demo00000002" + + appleRetryTransactionID = "2000000512345673" + appleRetryNotificationUUID = "8f2c1a4e-3333-4a1b-9c11-demo00000003" + + appleMissedTransactionID = "2000000512345674" + appleMissedNotificationUUID = "8f2c1a4e-4444-4a1b-9c11-demo00000004" + + googlePurchaseToken = "demo.AO-J1OxSyntheticPurchaseToken.0000000000000001" + googleMessageID = "8123456789012345" +) + +func googleSubscriptionPurchase(start time.Time) map[string]any { + return map[string]any{ + "kind": "androidpublisher#subscriptionPurchaseV2", + "regionCode": "US", + "startTime": start.Format(time.RFC3339), + "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE", + "latestOrderId": "GPA.0000-0000-0000-00001", + "lineItems": []map[string]any{{ + "productId": googleSubscriptionID, + "expiryTime": start.Add(30 * 24 * time.Hour).Format(time.RFC3339), + "offerDetails": map[string]any{"basePlanId": googleBasePlanID}, + "autoRenewingPlan": map[string]any{"autoRenewEnabled": true}, + }}, + "acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED", + } +} + +// clientObservation builds the Billing Ingestion Contract v1 client record. It +// is written out longhand rather than generated so the evidence document can +// show exactly what an SDK sends. +func clientObservation(observationID, submissionID, platform, referenceKind, reference string) map[string]any { + return map[string]any{ + "billingIngestionContractVersion": "1", + "recordType": "clientTransactionObservation", + "payload": map[string]any{ + "observationId": observationID, + "submissionId": submissionID, + "providerId": platform, + "storePlatform": platform, + "transactionReference": map[string]string{"referenceKind": referenceKind, "value": reference}, + "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", + }, + }, + } +} + +// --------------------------------------------------------------------------- +// Drivers +// --------------------------------------------------------------------------- + +// drainValidation runs the same job function cmd/worker's billing_validation +// family runs, until the queue reports nothing available. +func (d *demo) drainValidation(limit int) error { + for index := 0; index < limit; index++ { + processed, err := d.service.ProcessNextValidation(d.ctx, "demo-worker") + if err != nil { + return err + } + if !processed { + return nil + } + } + return nil +} + +func (d *demo) waitForJob(notificationUUID string, budget time.Duration) (time.Duration, error) { + started := time.Now() + for time.Since(started) < budget { + var available bool + err := d.pool.QueryRow(d.ctx, + `SELECT j.available_at <= now() FROM billing_validation_jobs j + JOIN billing_raw_inputs i ON i.id=j.raw_input_id + WHERE i.provider_event_id=$1`, notificationUUID).Scan(&available) + if err != nil { + return 0, err + } + if available { + return time.Since(started), nil + } + time.Sleep(time.Second) + } + return 0, fmt.Errorf("retry for %s did not become available within %s", notificationUUID, budget) +} + +func (d *demo) quarantineRecordFor(notificationUUID string) (string, error) { + var id string + err := d.pool.QueryRow(d.ctx, + `SELECT q.id FROM billing_quarantine_records q + JOIN billing_raw_inputs i ON i.id=q.raw_input_id + WHERE i.provider_event_id=$1 ORDER BY q.first_seen_at DESC LIMIT 1`, notificationUUID).Scan(&id) + return id, err +} + +func (d *demo) rawInputFor(notificationUUID string) (string, error) { + var id string + err := d.pool.QueryRow(d.ctx, + `SELECT id FROM billing_raw_inputs WHERE provider_event_id=$1`, notificationUUID).Scan(&id) + return id, err +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +func (d *demo) authed(method, path string, body any) (int, string) { + return d.raw(method, path, encode(body), map[string]string{"X-Demo-Actor": ownerActorID}) +} + +func (d *demo) public(method, path, key string, body any) (int, string) { + return d.raw(method, path, encode(body), map[string]string{"Authorization": "Bearer " + key}) +} + +func (d *demo) raw(method, path, body string, headers map[string]string) (int, string) { + 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() + } + 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() + } + defer func() { _ = response.Body.Close() }() + payload, _ := io.ReadAll(response.Body) + return response.StatusCode, strings.TrimSpace(string(payload)) +} + +func encode(body any) string { + if body == nil { + return "" + } + encoded, err := json.Marshal(body) + if err != nil { + return "" + } + return string(encoded) +} + +// redactEndpoint removes the one-time intake token from a credential response. +// The token is an unauthenticated bearer value in a URL path; it must not reach +// an evidence document. +func redactEndpoint(body string) string { + const marker = `"notificationEndpointUrl":"` + index := strings.Index(body, marker) + if index < 0 { + return body + } + rest := body[index+len(marker):] + end := strings.Index(rest, `"`) + if end < 0 { + return body + } + url := rest[:end] + cut := strings.LastIndex(url, "/") + if cut < 0 { + return body + } + return body[:index+len(marker)] + url[:cut+1] + "" + body[index+len(marker)+end:] +} + +// --------------------------------------------------------------------------- +// Evidence printing +// --------------------------------------------------------------------------- + +func (d *demo) section(number, title string) { + fmt.Printf("\n\n########## STAGE %s — %s ##########\n", number, title) + d.stepNumber = 0 +} + +func (d *demo) step(title string) { + d.stepNumber++ + fmt.Printf("\n--- step %d: %s\n", d.stepNumber, title) +} + +func (d *demo) note(format string, args ...any) { + fmt.Printf(" note: "+format+"\n", args...) +} + +func (d *demo) http(label string, status int, body string) { + fmt.Printf(" HTTP %s -> %d\n", label, status) + fmt.Printf(" %s\n", body) +} + +// query runs a read and prints it as an aligned table. Every value printed here +// comes straight out of PostgreSQL; nothing is reformatted beyond alignment. +func (d *demo) query(label, sql string, args ...any) { + fmt.Printf(" SQL: %s\n", label) + rows, err := d.pool.Query(d.ctx, sql, args...) + if err != nil { + fmt.Printf(" ERROR: %v\n", err) + return + } + defer rows.Close() + descriptions := rows.FieldDescriptions() + headers := make([]string, len(descriptions)) + for index, description := range descriptions { + headers[index] = description.Name + } + table := [][]string{headers} + for rows.Next() { + values, err := rows.Values() + if err != nil { + fmt.Printf(" ERROR: %v\n", err) + return + } + record := make([]string, len(values)) + for index, value := range values { + record[index] = render(value) + } + table = append(table, record) + } + if err := rows.Err(); err != nil { + fmt.Printf(" ERROR: %v\n", err) + return + } + if len(table) == 1 { + fmt.Printf(" (no rows)\n") + return + } + widths := make([]int, len(headers)) + for _, record := range table { + for index, cell := range record { + if len(cell) > widths[index] { + widths[index] = len(cell) + } + } + } + for rowIndex, record := range table { + cells := make([]string, len(record)) + for index, cell := range record { + cells[index] = cell + strings.Repeat(" ", widths[index]-len(cell)) + } + fmt.Printf(" %s\n", strings.TrimRight(strings.Join(cells, " | "), " ")) + if rowIndex == 0 { + separators := make([]string, len(widths)) + for index, width := range widths { + separators[index] = strings.Repeat("-", width) + } + fmt.Printf(" %s\n", strings.Join(separators, "-+-")) + } + } +} + +func render(value any) string { + switch typed := value.(type) { + case nil: + return "NULL" + case []byte: + return hex.EncodeToString(typed) + case time.Time: + return typed.UTC().Format("2006-01-02T15:04:05.000Z") + default: + return fmt.Sprintf("%v", typed) + } +} diff --git a/apps/api/cmd/billingdemo/seed.go b/apps/api/cmd/billingdemo/seed.go new file mode 100644 index 00000000..84943d02 --- /dev/null +++ b/apps/api/cmd/billingdemo/seed.go @@ -0,0 +1,221 @@ +//go:build billingdemo + +// This command is excluded from every ordinary build. +// +// It constructs the real Mosaic router and the real billing service but injects +// a locally generated trust anchor through appstorejws.WithRoot, which is a +// verification seam that must never exist in a deployed image. cmd/api and +// cmd/worker call NewVerifier() with no options, so the seam is unreachable +// from the deployed path — but a buildable binary in the same module is one +// stray Dockerfile COPY away from being shipped. The tag makes that impossible +// rather than improbable: +// +// DATABASE_URL=postgres://... go run -tags billingdemo ./cmd/billingdemo +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// The identifiers below are fixed so the evidence document can quote them and a +// second run reproduces the same rows. +const ( + organizationID = "org_demo9a" + projectID = "proj_demo9a" + environmentID = "env_demo9a" + ownerActorID = "actor_demo9a_owner" + + iosApplicationID = "app_demo9a_ios" + androidApplicationID = "app_demo9a_android" + + appleBundleID = "com.mosaic.demo" + googlePackageName = "com.mosaic.demo.android" + + appleMonthlyProductID = "com.mosaic.demo.pro.monthly" + appleYearlyProductID = "com.mosaic.demo.pro.yearly" + googleSubscriptionID = "sub.pro.monthly" + googleBasePlanID = "monthly" + + mosaicMonthlyProduct = "prd_demo9a_monthly" + mosaicYearlyProduct = "prd_demo9a_yearly" + mosaicAndroidProduct = "prd_demo9a_android_monthly" + + appleMonthlyMapping = "ppm_demo9a_ios_monthly" + appleYearlyMapping = "ppm_demo9a_ios_yearly" + googleMonthlyMapping = "ppm_demo9a_android_monthly" + pubSubProjectID = "mosaic-demo-play" + pubSubSubscriptionID = "mosaic-rtdn-sub" + googleServiceAccount = "mosaic-rtdn@mosaic-demo-play.iam.gserviceaccount.com" + demoNotificationOrigin = "https://billing.demo.mosaic.local" +) + +type apiKey struct { + id string + prefix string + raw string +} + +func newAPIKey(prefix string) (apiKey, error) { + buffer := make([]byte, 24) + if _, err := rand.Read(buffer); err != nil { + return apiKey{}, err + } + secret := base64.RawURLEncoding.EncodeToString(buffer) + return apiKey{id: "key_" + prefix, prefix: prefix, raw: prefix + "." + secret}, nil +} + +// seedTenant creates the workspace a billing demonstration needs. It writes only +// tables that other Mosaic phases already own (organizations, projects, +// environments, applications, products, provider product mappings, API keys, +// organization membership). Nothing under billing_* is written here: every +// billing row in this demonstration is produced by the API, the service, or the +// worker job functions. +func seedTenant(ctx context.Context, pool *pgxpool.Pool) (publicKey, serverKey apiKey, err error) { + now := time.Now().UTC() + if err = resetTenant(ctx, pool); err != nil { + return apiKey{}, apiKey{}, err + } + + publicKey, err = newAPIKey("mos_pk_demo9a") + if err != nil { + return apiKey{}, apiKey{}, err + } + serverKey, err = newAPIKey("mos_sk_demo9a") + 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 9A',$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 NOTHING`, []any{organizationID, ownerActorID, now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,'demo9a','Phase 9A Demo','active',$3,$3) ON CONFLICT (id) DO NOTHING`, []any{projectID, organizationID, 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 applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Demo iOS','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, []any{iosApplicationID, projectID, appleBundleID, now}}, + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Demo Android','android',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, []any{androidApplicationID, projectID, googlePackageName, 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{mosaicMonthlyProduct, projectID, 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{mosaicYearlyProduct, projectID, now}}, + {`INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,'android-monthly','Android Pro Monthly','subscription','connected','mock',true,$3,$3) ON CONFLICT (id) DO NOTHING`, + []any{mosaicAndroidProduct, projectID, now}}, + + // The iOS monthly and Android mappings exist from the start. The iOS + // yearly mapping is deliberately absent so the quarantine demonstration + // has a genuinely unmapped provider Product. + {`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{appleMonthlyMapping, projectID, mosaicMonthlyProduct, iosApplicationID, appleMonthlyProductID, environmentID, now}}, + {`INSERT INTO provider_product_mappings( + id,project_id,product_id,application_id,provider,provider_product_identifier,status, + environment_id,platform,provider_base_plan_identifier,availability,sync_state,created_at,updated_at) + VALUES ($1,$2,$3,$4,'google_play',$5,'active',$6,'android',$7,'available','current',$8,$8)`, + []any{googleMonthlyMapping, projectID, mosaicAndroidProduct, androidApplicationID, googleSubscriptionID, environmentID, googleBasePlanID, 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, environmentID, publicKey.prefix, publicDigest[:], ownerActorID, now, iosApplicationID, projectID}}, + {`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, environmentID, serverKey.prefix, serverDigest[:], ownerActorID, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + return apiKey{}, apiKey{}, fmt.Errorf("seed: %s: %w", statement.query[:40], err) + } + } + return publicKey, serverKey, nil +} + +// addAppleYearlyMapping is the operator repair the quarantine demonstration +// performs between the failed and successful resolutions. +func addAppleYearlyMapping(ctx context.Context, pool *pgxpool.Pool) error { + now := time.Now().UTC() + _, err := pool.Exec(ctx, + `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)`, + appleYearlyMapping, projectID, mosaicYearlyProduct, iosApplicationID, appleYearlyProductID, environmentID, now) + return err +} + +// resetTenant clears any previous run. The append-only triggers are disabled for +// the duration of the delete only — the demonstration itself never touches them, +// and every append-only guarantee is exercised with the triggers in place. +func resetTenant(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`, + } + deletes := []struct { + query string + arg string + }{ + {`DELETE FROM billing_ledger_entries WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_replay_jobs WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_reconciliation_runs WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_quarantine_actions WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_quarantine_records WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_transaction_facts WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_product_resolutions WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_validation_jobs WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_validation_attempts WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_raw_inputs WHERE project_id=$1`, projectID}, + {`DELETE FROM store_server_credential_events WHERE project_id=$1`, projectID}, + {`DELETE FROM store_server_credential_applications WHERE project_id=$1`, projectID}, + {`DELETE FROM store_server_credentials WHERE project_id=$1`, projectID}, + {`DELETE FROM billing_project_settings WHERE project_id=$1`, projectID}, + {`DELETE FROM provider_product_mappings WHERE project_id=$1`, projectID}, + {`DELETE FROM api_keys WHERE environment_id=$1`, environmentID}, + } + 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`, + } + 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: %s: %w", statement.query, err) + } + } + for _, statement := range enable { + _, _ = pool.Exec(ctx, statement) + } + return nil +} diff --git a/apps/api/cmd/billingdemo/stubs.go b/apps/api/cmd/billingdemo/stubs.go new file mode 100644 index 00000000..75f626e5 --- /dev/null +++ b/apps/api/cmd/billingdemo/stubs.go @@ -0,0 +1,402 @@ +//go:build billingdemo + +// This command is excluded from every ordinary build. +// +// It constructs the real Mosaic router and the real billing service but injects +// a locally generated trust anchor through appstorejws.WithRoot, which is a +// verification seam that must never exist in a deployed image. cmd/api and +// cmd/worker call NewVerifier() with no options, so the seam is unreachable +// from the deployed path — but a buildable binary in the same module is one +// stray Dockerfile COPY away from being shipped. The tag makes that impossible +// rather than improbable: +// +// DATABASE_URL=postgres://... go run -tags billingdemo ./cmd/billingdemo +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "time" +) + +// The servers below stand in for Apple's App Store Server API, Google's Play +// Developer API, and Google Cloud Pub/Sub. They are LOCAL STUBS: they speak the +// documented request and response shapes over loopback HTTP, and nothing in +// them is a recording of a real provider response. Mosaic's own provider +// clients — appstoreserver.Client and googleplay.Client — are the real ones and +// are pointed at these hosts through their existing base-URL configuration. + +// --------------------------------------------------------------------------- +// Apple App Store Server API stub +// --------------------------------------------------------------------------- + +type appleStub struct { + mutex sync.Mutex + // transactions maps a transaction id to the signed transaction the API + // returns for it. + transactions map[string]string + // history is what Get Notification History returns. + history []string + // failWith, when non-zero, makes every transaction lookup answer that status + // until it is cleared. This is how the retry demonstration simulates an + // Apple outage. + failWith int + // calls records every path the real client actually requested. + calls []string + + server *httptest.Server +} + +func newAppleStub() *appleStub { + stub := &appleStub{transactions: map[string]string{}} + mux := http.NewServeMux() + mux.HandleFunc("/inApps/v1/transactions/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/inApps/v1/transactions/") + stub.mutex.Lock() + stub.calls = append(stub.calls, r.Method+" "+r.URL.Path) + failWith, signed := stub.failWith, stub.transactions[id] + stub.mutex.Unlock() + + if failWith != 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(failWith) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errorCode": 5000000, "errorMessage": "General internal error.", + }) + return + } + if signed == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errorCode": 4040010, "errorMessage": "Transaction id not found.", + }) + return + } + writeJSON(w, http.StatusOK, map[string]string{"signedTransactionInfo": signed}) + }) + mux.HandleFunc("/inApps/v1/notifications/history", func(w http.ResponseWriter, r *http.Request) { + stub.mutex.Lock() + stub.calls = append(stub.calls, r.Method+" "+r.URL.Path) + history := append([]string(nil), stub.history...) + stub.mutex.Unlock() + + items := make([]map[string]any, 0, len(history)) + for _, signedPayload := range history { + items = append(items, map[string]any{ + "signedPayload": signedPayload, + "sendAttempts": []map[string]any{{ + "attemptDate": time.Now().Add(-time.Hour).UnixMilli(), "sendAttemptResult": "TIMED_OUT", + }}, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"notificationHistory": items, "hasMore": false}) + }) + stub.server = httptest.NewServer(mux) + return stub +} + +func (s *appleStub) addTransaction(id, signed string) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.transactions[id] = signed +} + +func (s *appleStub) setHistory(signedPayloads ...string) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.history = append([]string(nil), signedPayloads...) +} + +func (s *appleStub) setFailure(status int) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.failWith = status +} + +func (s *appleStub) callLog() []string { + s.mutex.Lock() + defer s.mutex.Unlock() + return append([]string(nil), s.calls...) +} + +// --------------------------------------------------------------------------- +// Google Play Developer API stub +// --------------------------------------------------------------------------- + +type playStub struct { + mutex sync.Mutex + subscriptions map[string]map[string]any + products map[string]map[string]any + orders map[string]map[string]any + failWith int + calls []string + server *httptest.Server +} + +func newPlayStub() *playStub { + stub := &playStub{ + subscriptions: map[string]map[string]any{}, + products: map[string]map[string]any{}, + orders: map[string]map[string]any{}, + } + mux := http.NewServeMux() + mux.HandleFunc("/androidpublisher/v3/applications/", func(w http.ResponseWriter, r *http.Request) { + stub.mutex.Lock() + stub.calls = append(stub.calls, r.Method+" "+r.URL.Path) + failWith := stub.failWith + stub.mutex.Unlock() + + if failWith != 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(failWith) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": failWith, "status": "UNAVAILABLE", "message": "backend unavailable"}, + }) + return + } + + path := r.URL.Path + switch { + case strings.Contains(path, "/purchases/subscriptionsv2/tokens/"): + token := path[strings.LastIndex(path, "/")+1:] + stub.mutex.Lock() + purchase := stub.subscriptions[token] + stub.mutex.Unlock() + if purchase == nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": 404, "status": "NOT_FOUND"}}) + return + } + writeJSON(w, http.StatusOK, purchase) + case strings.Contains(path, "/purchases/products/"): + token := path[strings.LastIndex(path, "/")+1:] + stub.mutex.Lock() + purchase := stub.products[token] + stub.mutex.Unlock() + if purchase == nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": 404, "status": "NOT_FOUND"}}) + return + } + writeJSON(w, http.StatusOK, purchase) + case strings.Contains(path, "/orders/"): + orderID := path[strings.LastIndex(path, "/")+1:] + stub.mutex.Lock() + order := stub.orders[orderID] + stub.mutex.Unlock() + if order == nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": 404, "status": "NOT_FOUND"}}) + return + } + writeJSON(w, http.StatusOK, order) + default: + writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": 404, "status": "NOT_FOUND"}}) + } + }) + stub.server = httptest.NewServer(mux) + return stub +} + +func (s *playStub) setSubscription(token string, purchase map[string]any) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.subscriptions[token] = purchase +} + +func (s *playStub) setFailure(status int) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.failWith = status +} + +func (s *playStub) callLog() []string { + s.mutex.Lock() + defer s.mutex.Unlock() + return append([]string(nil), s.calls...) +} + +// --------------------------------------------------------------------------- +// Pub/Sub stub +// --------------------------------------------------------------------------- + +type pubsubStub struct { + mutex sync.Mutex + pending []map[string]any + acknowledged []string + pulls int + server *httptest.Server +} + +func newPubSubStub() *pubsubStub { + stub := &pubsubStub{} + mux := http.NewServeMux() + mux.HandleFunc("/v1/projects/", func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, ":pull"): + stub.mutex.Lock() + stub.pulls++ + messages := stub.pending + stub.pending = nil + stub.mutex.Unlock() + if messages == nil { + messages = []map[string]any{} + } + writeJSON(w, http.StatusOK, map[string]any{"receivedMessages": messages}) + case strings.HasSuffix(r.URL.Path, ":acknowledge"): + var request struct { + AckIDs []string `json:"ackIds"` + } + _ = json.NewDecoder(r.Body).Decode(&request) + stub.mutex.Lock() + stub.acknowledged = append(stub.acknowledged, request.AckIDs...) + stub.mutex.Unlock() + writeJSON(w, http.StatusOK, map[string]any{}) + default: + writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": 404}}) + } + }) + stub.server = httptest.NewServer(mux) + return stub +} + +// enqueue makes one RTDN available on the next pull. ackID and messageID are +// separate on purpose: Pub/Sub redelivers the same messageId under a new ackId, +// which is exactly what the duplicate demonstration replays. +func (s *pubsubStub) enqueue(ackID, messageID, data string, publishTime time.Time) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.pending = append(s.pending, map[string]any{ + "ackId": ackID, + "message": map[string]any{ + "data": data, + "messageId": messageID, + "publishTime": publishTime.UTC().Format(time.RFC3339Nano), + }, + }) +} + +func (s *pubsubStub) state() (pulls int, acknowledged []string) { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.pulls, append([]string(nil), s.acknowledged...) +} + +// --------------------------------------------------------------------------- +// Google OAuth token endpoint +// --------------------------------------------------------------------------- + +// googleplay.ParseServiceAccount pins token_uri to Google's real endpoint, so +// the token exchange cannot be redirected through the key file. Rather than +// weaken that control, the demo runs a TLS stub and a loopback CONNECT proxy, +// and installs the proxy on http.DefaultTransport before the real +// googleplay.Client is constructed (the client clones DefaultTransport). The +// real client therefore performs the real RS256 JWT-bearer assertion and a real +// HTTPS token exchange; only the host it lands on is local. +type oauthStub struct { + mutex sync.Mutex + exchanges int + server *httptest.Server + proxy net.Listener + proxyURL *url.URL +} + +func newOAuthStub() (*oauthStub, error) { + stub := &oauthStub{} + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + stub.mutex.Lock() + stub.exchanges++ + stub.mutex.Unlock() + if r.PostForm.Get("assertion") == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": "demo-access-token-redacted", "expires_in": 3600, "token_type": "Bearer", + }) + }) + stub.server = httptest.NewTLSServer(mux) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + stub.proxy = listener + stub.proxyURL, err = url.Parse("http://" + listener.Addr().String()) + if err != nil { + return nil, err + } + target := strings.TrimPrefix(stub.server.URL, "https://") + go func() { + for { + connection, err := listener.Accept() + if err != nil { + return + } + go serveConnect(connection, target) + } + }() + return stub, nil +} + +// serveConnect answers a single CONNECT request by dialling the local TLS stub +// regardless of the host asked for, then piping bytes both ways. +func serveConnect(client net.Conn, target string) { + defer func() { _ = client.Close() }() + buffer := make([]byte, 4096) + read, err := client.Read(buffer) + if err != nil || !strings.HasPrefix(string(buffer[:read]), "CONNECT ") { + return + } + upstream, err := net.Dial("tcp", target) + if err != nil { + _, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return + } + defer func() { _ = upstream.Close() }() + if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + return + } + done := make(chan struct{}) + go func() { _, _ = io.Copy(upstream, client); close(done) }() + _, _ = io.Copy(client, upstream) + <-done +} + +// install points http.DefaultTransport's proxy at the CONNECT listener for +// Google's token host only, and trusts the TLS stub's certificate. +func (s *oauthStub) install() { + transport := http.DefaultTransport.(*http.Transport) + transport.Proxy = func(request *http.Request) (*url.URL, error) { + if request.URL.Host == "oauth2.googleapis.com" { + return s.proxyURL, nil + } + return nil, nil + } + pool := s.server.Client().Transport.(*http.Transport).TLSClientConfig + // httptest's TLS certificate names example.com, so the handshake is verified + // against that rather than skipped. + transport.TLSClientConfig = &tls.Config{RootCAs: pool.RootCAs, ServerName: "example.com"} +} + +func (s *oauthStub) exchangeCount() int { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.exchanges +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + fmt.Println("stub encode failure:", err) + } +} diff --git a/apps/api/cmd/billingdemo/vectors.go b/apps/api/cmd/billingdemo/vectors.go new file mode 100644 index 00000000..33f7791b --- /dev/null +++ b/apps/api/cmd/billingdemo/vectors.go @@ -0,0 +1,277 @@ +//go:build billingdemo + +// This command is excluded from every ordinary build. +// +// It constructs the real Mosaic router and the real billing service but injects +// a locally generated trust anchor through appstorejws.WithRoot, which is a +// verification seam that must never exist in a deployed image. cmd/api and +// cmd/worker call NewVerifier() with no options, so the seam is unreachable +// from the deployed path — but a buildable binary in the same module is one +// stray Dockerfile COPY away from being shipped. The tag makes that impossible +// rather than improbable: +// +// DATABASE_URL=postgres://... go run -tags billingdemo ./cmd/billingdemo +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +// The Apple material below is SYNTHETIC. Apple's real signing chain cannot be +// reproduced offline, so the demo generates its own three-certificate chain and +// injects its root through appstorejws.WithRoot — the same seam the verifier's +// own unit tests use. Everything downstream of the signature check is exercised +// exactly as it would be in production; the signature itself proves only that +// Mosaic's verifier accepts a chain it was told to trust. + +// appleWWDROID is Apple's "App Store" certificate extension OID. The verifier +// requires it on the intermediate, so the synthetic chain carries it. +var appleWWDROID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1} + +type demoChain struct { + root *x509.Certificate + leafKey *ecdsa.PrivateKey + encoded []string +} + +func newDemoChain() (demoChain, error) { + mint := func(template, parent *x509.Certificate, publicKey any, signerKey any) (*x509.Certificate, []byte, error) { + if parent == nil { + parent = template + } + der, err := x509.CreateCertificate(rand.Reader, template, parent, publicKey, signerKey) + if err != nil { + return nil, nil, err + } + parsed, err := x509.ParseCertificate(der) + return parsed, der, err + } + + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return demoChain{}, err + } + 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), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + root, rootDER, err := mint(rootTemplate, nil, &rootKey.PublicKey, rootKey) + if err != nil { + return demoChain{}, err + } + + intermediateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return demoChain{}, err + } + intermediateTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Mosaic Demo Intermediate CA (SYNTHETIC)"}, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + ExtraExtensions: []pkix.Extension{{Id: appleWWDROID, Value: []byte{0x05, 0x00}}}, + } + intermediate, intermediateDER, err := mint(intermediateTemplate, root, &intermediateKey.PublicKey, rootKey) + if err != nil { + return demoChain{}, err + } + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return demoChain{}, err + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "Mosaic Demo Leaf (SYNTHETIC)"}, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + _, leafDER, err := mint(leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey) + if err != nil { + return demoChain{}, err + } + + return demoChain{ + root: root, + leafKey: leafKey, + encoded: []string{ + base64.StdEncoding.EncodeToString(leafDER), + base64.StdEncoding.EncodeToString(intermediateDER), + base64.StdEncoding.EncodeToString(rootDER), + }, + }, nil +} + +// signJWS builds a compact JWS the way Apple does: ES256 over base64url header +// and payload, with the raw R||S signature form. +func (c demoChain) signJWS(payload map[string]any) (string, error) { + headerBytes, err := json.Marshal(map[string]any{"alg": "ES256", "x5c": c.encoded}) + if err != nil { + return "", err + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + + base64.RawURLEncoding.EncodeToString(payloadBytes) + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, c.leafKey, digest[:]) + if err != nil { + return "", err + } + signature := make([]byte, 64) + rBytes, sBytes := r.Bytes(), s.Bytes() + copy(signature[32-len(rBytes):32], rBytes) + copy(signature[64-len(sBytes):], sBytes) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +// appleTransactionPayload is the JWSTransactionDecodedPayload a validation +// lookup returns. +func appleTransactionPayload(transactionID, productID string, occurred time.Time) map[string]any { + return map[string]any{ + "transactionId": transactionID, + "originalTransactionId": transactionID, + "webOrderLineItemId": "1000000" + transactionID[len(transactionID)-6:], + "bundleId": appleBundleID, + "productId": productID, + "subscriptionGroupIdentifier": "21456789", + "purchaseDate": occurred.UnixMilli(), + "originalPurchaseDate": occurred.UnixMilli(), + "expiresDate": occurred.Add(30 * 24 * time.Hour).UnixMilli(), + "quantity": 1, + "type": "Auto-Renewable Subscription", + "transactionReason": "PURCHASE", + "inAppOwnershipType": "PURCHASED", + "signedDate": occurred.UnixMilli(), + "environment": "Production", + "storefront": "USA", + } +} + +// appleNotificationBody builds the exact JSON body Apple POSTs to the intake +// endpoint: {"signedPayload": ""}. +func (c demoChain) appleNotificationBody(uuid, transactionID, productID string, occurred time.Time) (string, error) { + signedTransaction, err := c.signJWS(appleTransactionPayload(transactionID, productID, occurred)) + if err != nil { + return "", err + } + signedRenewal, err := c.signJWS(map[string]any{ + "originalTransactionId": transactionID, + "autoRenewStatus": 1, + "autoRenewProductId": productID, + "productId": productID, + "signedDate": occurred.UnixMilli(), + "environment": "Production", + }) + if err != nil { + return "", err + } + signedPayload, err := c.signJWS(map[string]any{ + "notificationType": "SUBSCRIBED", + "subtype": "INITIAL_BUY", + "notificationUUID": uuid, + "version": "2.0", + "signedDate": occurred.UnixMilli(), + "data": map[string]any{ + "appAppleId": 1234567890, + "bundleId": appleBundleID, + "bundleVersion": "1", + "environment": "Production", + "signedTransactionInfo": signedTransaction, + "signedRenewalInfo": signedRenewal, + "status": 1, + }, + }) + if err != nil { + return "", err + } + body, err := json.Marshal(map[string]string{"signedPayload": signedPayload}) + return string(body), err +} + +// --------------------------------------------------------------------------- +// Credential material (synthetic) +// --------------------------------------------------------------------------- + +// newApplePrivateKeyPEM produces a PKCS#8 P-256 key in the shape of an Apple +// In-App Purchase .p8. It is generated locally and is not an Apple key. +func newApplePrivateKeyPEM() ([]byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} + +// newGoogleServiceAccountJSON produces a service-account key file in Google's +// documented shape with a locally generated RSA key. token_uri is Google's real +// endpoint because ParseServiceAccount pins it — a deliberate control that +// stops a doctored key redirecting signed assertions. The demo reaches its +// local token stub through a loopback CONNECT proxy instead of weakening it. +func newGoogleServiceAccountJSON(clientEmail, projectID string) ([]byte, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + return json.Marshal(map[string]string{ + "type": "service_account", + "project_id": projectID, + "private_key_id": "demo-key-0001", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), + "client_email": clientEmail, + "client_id": "100000000000000000001", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": fmt.Sprintf("https://www.googleapis.com/robot/v1/metadata/x509/%s", clientEmail), + }) +} + +// rtdnMessageData builds the base64 `data` member of a Pub/Sub message carrying +// a subscription Real-time Developer Notification. +func rtdnMessageData(packageName, subscriptionID, purchaseToken string, notificationType int, when time.Time) string { + body, _ := json.Marshal(map[string]any{ + "version": "1.0", + "packageName": packageName, + "eventTimeMillis": fmt.Sprintf("%d", when.UnixMilli()), + "subscriptionNotification": map[string]any{ + "version": "1.0", + "notificationType": notificationType, + "purchaseToken": purchaseToken, + "subscriptionId": subscriptionID, + }, + }) + return base64.StdEncoding.EncodeToString(body) +} diff --git a/apps/api/cmd/keyring/main.go b/apps/api/cmd/keyring/main.go index cb06b23d..142b1e60 100644 --- a/apps/api/cmd/keyring/main.go +++ b/apps/api/cmd/keyring/main.go @@ -21,6 +21,7 @@ import ( "time" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/billingpostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/cloudworkspacepostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/config" "github.com/Mujhtech/mosaic/apps/api/internal/platform/database" @@ -80,22 +81,36 @@ func run(args []string) error { } defer pool.Close() repository := cloudworkspacepostgres.New(pool) + // Phase 9A added Store Server Credential and Raw Billing Input envelopes. + // They are sealed under the same keyring, so rotation has to reach them: + // a key left sealing billing rows cannot safely be dropped from the keyring. + billingRepository := billingpostgres.New(pool) switch action { case "inspect": - return inspect(ctx, repository, cipher) + return inspect(ctx, repository, billingRepository, cipher) case "rotate": - return rotate(ctx, repository, cipher, *batchSize, *dryRun) + if err := rotate(ctx, repository, cipher, *batchSize, *dryRun); err != nil { + return err + } + return rotateBilling(ctx, billingRepository, cipher, *batchSize, *dryRun) default: return fmt.Errorf("unsupported keyring action %q", action) } } -func inspect(ctx context.Context, repository *cloudworkspacepostgres.Repository, cipher *providercredential.AESGCMCipher) error { +func inspect(ctx context.Context, repository *cloudworkspacepostgres.Repository, billingRepository *billingpostgres.Repository, cipher *providercredential.AESGCMCipher) error { counts, err := repository.CredentialCountsByKeyID(ctx) if err != nil { return err } + billingCounts, err := billingRepository.EnvelopeCountsByKeyID(ctx) + if err != nil { + return err + } + for keyID, count := range billingCounts { + counts[keyID] += count + } known := make(map[string]struct{}, len(cipher.KeyIDs())) for _, id := range cipher.KeyIDs() { known[id] = struct{}{} @@ -198,6 +213,67 @@ func rotate(ctx context.Context, repository *cloudworkspacepostgres.Repository, return nil } +// rotateBilling reseals Phase 9A envelopes under the active key. +// +// The scope is rebuilt from the row itself rather than assumed, because the v2 +// additional data binds the ciphertext to the organization, Project, subject +// kind, subject id, and class; resealing under a reconstructed-but-wrong scope +// would produce a row that decrypts nowhere. +func rotateBilling(ctx context.Context, repository *billingpostgres.Repository, cipher *providercredential.AESGCMCipher, batchSize int, dryRun bool) error { + activeKeyID := cipher.ActiveKeyID() + rotated := 0 + for { + envelopes, err := repository.EnvelopesNotUnderKey(ctx, activeKeyID, batchSize) + if err != nil { + return err + } + if len(envelopes) == 0 { + break + } + if dryRun { + fmt.Printf("would rotate %d billing envelope(s) in this batch\n", len(envelopes)) + rotated += len(envelopes) + break + } + resealed := make([]billingpostgres.BillingEnvelope, 0, len(envelopes)) + for _, envelope := range envelopes { + scope := envelope.Scope() + plaintext, err := cipher.DecryptSubject(providercredential.Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, + CredentialClass: envelope.CredentialClass, Fingerprint: envelope.Fingerprint, + }, scope) + if err != nil { + return fmt.Errorf("billing envelope %s/%s cannot be decrypted with the configured keyring; keep key %q in the keyring and retry: %w", + envelope.Table, envelope.RowID, envelope.KeyID, err) + } + sealed, err := cipher.EncryptSubject(plaintext, scope) + zero(plaintext) + if err != nil { + return fmt.Errorf("re-encrypt billing envelope %s/%s: %w", envelope.Table, envelope.RowID, err) + } + envelope.Version = sealed.Version + envelope.Algorithm = sealed.Algorithm + envelope.KeyID = sealed.KeyID + envelope.Nonce = sealed.Nonce + envelope.Ciphertext = sealed.Ciphertext + envelope.Fingerprint = sealed.Fingerprint + resealed = append(resealed, envelope) + } + if err := repository.ReplaceEnvelopes(ctx, resealed, time.Now().UTC()); err != nil { + return err + } + rotated += len(resealed) + fmt.Printf("rotated %d billing envelope(s)\n", rotated) + } + if dryRun { + fmt.Printf("dry run complete: at least %d billing envelope(s) need rotation to key %s\n", rotated, activeKeyID) + return nil + } + fmt.Printf("billing rotation complete: %d envelope(s) now sealed under %s\n", rotated, activeKeyID) + return nil +} + // zero clears decrypted credential bytes as soon as they are no longer needed. func zero(value []byte) { for i := range value { diff --git a/apps/api/cmd/worker/main.go b/apps/api/cmd/worker/main.go index 57d4d600..9d50d54b 100644 --- a/apps/api/cmd/worker/main.go +++ b/apps/api/cmd/worker/main.go @@ -21,14 +21,19 @@ import ( "github.com/rs/zerolog" "github.com/Mujhtech/mosaic/apps/api/internal/analytics" + "github.com/Mujhtech/mosaic/apps/api/internal/billing" "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/billingpostgres" "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" "github.com/Mujhtech/mosaic/apps/api/internal/platform/database" "github.com/Mujhtech/mosaic/apps/api/internal/platform/experimentpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" "github.com/Mujhtech/mosaic/apps/api/internal/platform/logging" "github.com/Mujhtech/mosaic/apps/api/internal/platform/objectstoreminio" "github.com/Mujhtech/mosaic/apps/api/internal/platform/revenuecat" @@ -138,6 +143,43 @@ func run() (runErr error) { providerService = cloudworkspace.NewService(cloudworkspacepostgres.New(pool), cloudworkspace.WithProviderOperations(cipher, client, cfg.Providers.SnapshotTTL)) } + var billingService *billing.Service + var billingRepository *billingpostgres.Repository + if cfg.Billing.Enabled { + billingCipher, err := providercredential.NewAESGCMCipher(cfg.Providers.CredentialKeyring, rand.Reader) + if err != nil { + return fmt.Errorf("configure billing credential encryption: %w", err) + } + verifier, err := appstorejws.NewVerifier() + if err != nil { + return fmt.Errorf("configure Apple notification verification: %w", err) + } + appleClient, err := appstoreserver.New(appstoreserver.Config{ + ProductionBaseURL: cfg.Billing.AppleProductionBaseURL, + SandboxBaseURL: cfg.Billing.AppleSandboxBaseURL, + RequestTimeout: cfg.Providers.RequestTimeout, + ConnectTimeout: cfg.Providers.ConnectTimeout, + MaxResponseBytes: cfg.Providers.MaxResponseBytes, + }) + if err != nil { + return fmt.Errorf("configure App Store Server client: %w", err) + } + googleClient, err := googleplay.New(googleplay.Config{ + PlayBaseURL: cfg.Billing.GooglePlayBaseURL, + PubSubBaseURL: cfg.Billing.GooglePubSubBaseURL, + RequestTimeout: cfg.Providers.RequestTimeout, + ConnectTimeout: cfg.Providers.ConnectTimeout, + MaxResponseBytes: cfg.Providers.MaxResponseBytes, + }) + if err != nil { + return fmt.Errorf("configure Google Play client: %w", err) + } + billingRepository = billingpostgres.New(pool) + billingService = billing.NewService(billingRepository, billingCipher, verifier, + billing.WithProviders(appleClient, googleClient), + billing.WithRetention(cfg.Billing.RawRetention())) + } + workerID, err := os.Hostname() if err != nil || workerID == "" { workerID = "mosaic-worker" @@ -164,11 +206,29 @@ func run() (runErr error) { if err := experimentRepository.RegisterQueueMetrics(); err != nil { return fmt.Errorf("register Experiment queue metrics: %w", err) } + if billingRepository != nil { + // Billing queues publish depth, oldest age, and dead-letter count from + // day one rather than being added after the first incident. + if err := billingRepository.RegisterQueueMetrics(); err != nil { + return fmt.Errorf("register billing queue metrics: %w", err) + } + } - families := make([]jobFamily, 0, 3) + families := make([]jobFamily, 0, 8) if providerService != nil { families = append(families, jobFamily{"provider_sync", providerService.ProcessNextProviderSync}) } + if billingService != nil { + // Validation runs first in the round-robin because a store notification + // waiting on validation is the latency an operator actually sees. + families = append(families, + jobFamily{"billing_validation", billingService.ProcessNextValidation}, + jobFamily{"billing_rtdn", billingService.ProcessNextRTDN}, + jobFamily{"billing_reconciliation", billingService.ProcessNextReconciliation}, + jobFamily{"billing_replay", billingService.ProcessNextReplay}, + jobFamily{"billing_retention", billingService.ProcessRetention}, + ) + } families = append(families, jobFamily{"analytics", analyticsService.ProcessNextJob}, jobFamily{"experiment_schedule", experimentService.ProcessNextSchedule}, @@ -213,6 +273,11 @@ func run() (runErr error) { if providerService != nil && cfg.Providers.WorkerPollInterval < interval { interval = cfg.Providers.WorkerPollInterval } + // Billing carries its own interval so store-notification latency is not + // coupled to analytics aggregation load. + if billingService != nil && cfg.Billing.WorkerPollInterval < interval { + interval = cfg.Billing.WorkerPollInterval + } select { case <-runContext.Done(): logger.Info().Msg("worker stopped gracefully") diff --git a/apps/api/internal/billing/digest.go b/apps/api/internal/billing/digest.go new file mode 100644 index 00000000..937c25aa --- /dev/null +++ b/apps/api/internal/billing/digest.go @@ -0,0 +1,188 @@ +package billing + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strconv" + "strings" + "time" +) + +// Idempotency keys are domain-separated SHA-256 digests. Domain separation +// matters here more than usual: an Apple notification UUID and a Pub/Sub message +// id are both opaque strings, and without a domain prefix a value that happened +// to collide across providers would deduplicate two unrelated inputs into one. +// +// None of these keys is derived from a timestamp, a Product, a display name, an +// amount, or any customer identifier. Every one of those changes independently +// of the delivery it is meant to identify. + +func digestOf(domain string, parts ...string) []byte { + hasher := sha256.New() + hasher.Write([]byte(domain)) + for _, part := range parts { + hasher.Write([]byte{0}) + hasher.Write([]byte(part)) + } + return hasher.Sum(nil) +} + +// AppleNotificationKey identifies one App Store Server Notification. +// notificationUUID is the identifier Apple documents for duplicate detection. +func AppleNotificationKey(notificationUUID string) []byte { + return digestOf("mosaic-billing-apple-notification-v1", notificationUUID) +} + +// AppleTransactionKey identifies one Apple transaction in one Store Environment. +func AppleTransactionKey(storeEnvironment, transactionID string) []byte { + return digestOf("mosaic-billing-apple-transaction-v1", storeEnvironment, transactionID) +} + +// GoogleRTDNKey identifies one Pub/Sub delivery. Google's messageId is unique +// per subscription rather than globally, so the subscription name is part of +// the key, and the notification content digest is folded in so a subscription +// replayed under a recycled id cannot mask a different notification. +func GoogleRTDNKey(subscription, messageID string, contentDigest []byte) []byte { + return digestOf("mosaic-billing-google-rtdn-v1", subscription, messageID, hex.EncodeToString(contentDigest)) +} + +// GooglePurchaseKey identifies one Google purchase by token digest. The raw +// purchase token is never part of a key that gets persisted or logged. +func GooglePurchaseKey(packageName string, tokenDigest []byte) []byte { + return digestOf("mosaic-billing-google-purchase-v1", packageName, hex.EncodeToString(tokenDigest)) +} + +// UnverifiedInputKey identifies unverified intake for one credential, one +// failure reason, and one hour. +// +// It is deliberately not derived from the body. The notification endpoint has +// no rate limiter — a 429 to Apple spends a non-renewable delivery attempt — so +// a body-derived key would let anyone holding a leaked intake token grow the +// table without bound. The bucket keeps what an operator can act on (which +// credential, what kind of failure, when) and drops what they cannot (the +// specific garbage, which is not retained anyway). +func UnverifiedInputKey(credentialID, reason, hourBucket string) []byte { + return digestOf("mosaic-billing-apple-unverified-v1", credentialID, reason, hourBucket) +} + +// ObservationKey identifies one client or trusted-server submission. +func ObservationKey(environmentID, submissionID string) []byte { + return digestOf("mosaic-billing-observation-v1", environmentID, submissionID) +} + +// TokenDigest is the cross-SDK contract for referring to a Google purchase +// token without transmitting it: SHA-256 over the UTF-8 token, lowercase hex. +// The Android adapter already computes exactly this, and the server recomputes +// it rather than trusting a client-supplied digest whenever it holds the token. +func TokenDigest(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// 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. +func ContentDigest(body []byte) []byte { + var canonical any + if err := json.Unmarshal(body, &canonical); err == nil { + if encoded, err := json.Marshal(canonical); err == nil { + sum := sha256.Sum256(encoded) + return sum[:] + } + } + sum := sha256.Sum256(body) + return sum[:] +} + +// FactDigest is the identity of a Transaction Fact within an Environment. +// +// It covers everything that gives the fact meaning and deliberately excludes +// the fact's own id, its provenance columns, and recorded_at. That exclusion is +// what makes replay a structural no-op: re-validating the same input against +// 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. +func FactDigest(fact TransactionFact) []byte { + fields := []string{ + fact.EnvironmentID, + fact.ApplicationID, + fact.Provider, + fact.StoreEnvironment, + fact.ProviderTransactionID, + fact.ProviderOriginalTransactionID, + hex.EncodeToString(fact.PurchaseChainDigest), + hex.EncodeToString(fact.SupersedesChainDigest), + fact.TransactionType, + fact.FactKind, + timeField(&fact.OccurredAt), + timeField(fact.PeriodStartAt), + timeField(fact.PeriodEndAt), + timeField(fact.RevokedAt), + timeField(fact.RefundedAt), + boolField(fact.RenewalExpected), + strconv.FormatBool(fact.IsTestTransaction), + fact.ProviderProductIdentifier, + fact.ProviderBasePlanIdentifier, + fact.ProviderOfferIdentifier, + fact.ResolutionState, + fact.MosaicProductID, + fact.ProviderProductMappingID, + int64Field(fact.ResolvedMappingVersion), + strconv.Itoa(fact.ValidatorVersion), + strconv.Itoa(fact.FactVersion), + } + return digestOf("mosaic-billing-fact-v1", fields...) +} + +func timeField(value *time.Time) string { + if value == nil || value.IsZero() { + return "" + } + return strconv.FormatInt(value.UTC().UnixMilli(), 10) +} + +func boolField(value *bool) string { + if value == nil { + return "" + } + return strconv.FormatBool(*value) +} + +func int64Field(value *int64) string { + if value == nil { + return "" + } + return strconv.FormatInt(*value, 10) +} + +// SafeProviderCode bounds any provider-supplied identifier to the charset the +// contract's safeProviderCode allows: printable ASCII, no control characters, +// at most 128 runes. Every reference a client may submit is checked against +// this, which structurally excludes a JWS or a raw Google purchase token from +// the untrusted observation endpoint — both exceed the length bound by an order +// of magnitude. +func SafeProviderCode(value string) (string, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" || len([]rune(trimmed)) > 128 { + return "", false + } + for _, r := range trimmed { + if r < 0x20 || r > 0x7e { + return "", false + } + } + return trimmed, true +} + +// ValidHexDigest reports whether value is a lowercase hex SHA-256 digest. +func ValidHexDigest(value string) ([]byte, bool) { + if len(value) != 64 { + return nil, false + } + decoded, err := hex.DecodeString(value) + if err != nil || strings.ToLower(value) != value { + return nil, false + } + return decoded, true +} diff --git a/apps/api/internal/billing/digest_test.go b/apps/api/internal/billing/digest_test.go new file mode 100644 index 00000000..7d8773ed --- /dev/null +++ b/apps/api/internal/billing/digest_test.go @@ -0,0 +1,158 @@ +package billing + +import ( + "bytes" + "strings" + "testing" + "time" +) + +// The untrusted observation endpoint is the only place an attacker-controlled +// string reaches the ingestion pipeline. These tests pin the structural +// property the contract relies on: the reference bound is narrow enough that a +// JWS or a raw Google purchase token cannot fit through it, so the endpoint +// cannot be used to smuggle bearer material into storage. +func TestSafeProviderCodeExcludesTokensAndSignedPayloads(t *testing.T) { + // A realistic Apple signedPayload is thousands of characters; a Google + // purchase token is a few hundred. Both exceed the 128-rune bound. + signedPayload := strings.Repeat("eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlF", 40) + purchaseToken := strings.Repeat("gtokenabcdefghijklmnop", 12) + + for name, value := range map[string]string{ + "signed payload": signedPayload, + "purchase token": purchaseToken, + } { + if _, ok := SafeProviderCode(value); ok { + t.Fatalf("%s (%d chars) passed the reference bound", name, len(value)) + } + } + + // Control characters and non-ASCII are refused so a reference cannot carry + // a log-injection or terminal-escape payload into an operator's console. + for _, value := range []string{"abc\ndef", "abc\x00def", "abc
def", "café"} { + if _, ok := SafeProviderCode(value); ok { + t.Fatalf("reference %q with unsafe characters was accepted", value) + } + } + + if _, ok := SafeProviderCode("2000000123456789"); !ok { + t.Fatal("a legitimate Apple transaction id was rejected") + } +} + +// Fact identity is what makes replay a no-op. Two validations of the same +// transaction against the same mapping history must produce the same digest, +// and any change in meaning must produce a different one. +func TestFactDigestIsStableAndMeaningSensitive(t *testing.T) { + base := TransactionFact{ + EnvironmentID: "env_prod", + ApplicationID: "app_ios", + Provider: ProviderAppStore, + StoreEnvironment: StoreProduction, + ProviderTransactionID: "2000000123456789", + TransactionType: TypeAutoRenewableSubscription, + FactKind: KindRenewal, + OccurredAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + ProviderProductIdentifier: "fixture.pro.monthly", + ResolutionState: StateActiveMapping, + MosaicProductID: "prod_pro", + ProviderProductMappingID: "map_active", + ValidatorVersion: 1, + FactVersion: 1, + } + + first := FactDigest(base) + + // Provenance and identity columns are deliberately excluded: a replay + // produces a new attempt id and a new recorded_at, and if those changed the + // digest every replay would append a duplicate fact. + replayed := base + replayed.ID = "btf_other" + replayed.SourceRawInputID = "bri_other" + replayed.ValidationAttemptID = "bva_other" + replayed.RecordedAt = time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC) + if !bytes.Equal(first, FactDigest(replayed)) { + t.Fatal("provenance changes altered the fact digest, so replay would duplicate facts") + } + + // Anything that changes what the fact says must change the digest, + // otherwise a genuinely different outcome would be silently absorbed by the + // unique constraint. + for name, mutate := range map[string]func(*TransactionFact){ + "product": func(f *TransactionFact) { f.MosaicProductID = "prod_other" }, + "kind": func(f *TransactionFact) { f.FactKind = KindRefund }, + "occurred at": func(f *TransactionFact) { f.OccurredAt = f.OccurredAt.Add(time.Hour) }, + "mapping": func(f *TransactionFact) { f.ProviderProductMappingID = "map_other" }, + "environment": func(f *TransactionFact) { f.StoreEnvironment = StoreSandbox }, + "resolution": func(f *TransactionFact) { f.ResolutionState = StateUnresolved }, + "validator": func(f *TransactionFact) { f.ValidatorVersion = 2 }, + "transaction": func(f *TransactionFact) { f.ProviderTransactionID = "2000000987654321" }, + } { + changed := base + mutate(&changed) + if bytes.Equal(first, FactDigest(changed)) { + t.Fatalf("changing the %s did not change the fact digest", name) + } + } +} + +// Idempotency keys must be domain-separated. Without it an Apple notification +// UUID that happened to equal a Pub/Sub message id would deduplicate two +// unrelated deliveries into one, losing a transaction. +func TestIdempotencyKeysAreDomainSeparated(t *testing.T) { + shared := "fixture-shared-identifier" + keys := map[string][]byte{ + "apple notification": AppleNotificationKey(shared), + "apple transaction": AppleTransactionKey(StoreProduction, shared), + "google rtdn": GoogleRTDNKey("projects/p/subscriptions/s", shared, TokenDigest(shared)), + "google purchase": GooglePurchaseKey("fixture.package", TokenDigest(shared)), + "observation": ObservationKey("env_prod", shared), + } + seen := make(map[string]string, len(keys)) + for name, key := range keys { + if len(key) != 32 { + t.Fatalf("%s key is %d bytes, want 32", name, len(key)) + } + encoded := string(key) + if other, collision := seen[encoded]; collision { + t.Fatalf("%s and %s produced the same key from the same identifier", name, other) + } + seen[encoded] = name + } +} + +// The Google token digest is a cross-SDK contract: SHA-256 over the UTF-8 +// token, lowercase hex. The Android adapter computes it independently, so a +// change here would silently break notification-to-observation attribution. +func TestTokenDigestMatchesCrossSDKContract(t *testing.T) { + // SHA-256("fixture-purchase-token"), lowercase hex. + digest := TokenDigest("fixture-purchase-token") + if len(digest) != 32 { + t.Fatalf("digest is %d bytes, want 32", len(digest)) + } + encoded := hexOf(digest) + if encoded != strings.ToLower(encoded) { + t.Fatal("digest is not lowercase hex") + } + decoded, ok := ValidHexDigest(encoded) + if !ok || !bytes.Equal(decoded, digest) { + t.Fatal("the digest form the SDKs submit does not round-trip") + } + if _, ok := ValidHexDigest(strings.ToUpper(encoded)); ok { + t.Fatal("an uppercase digest was accepted, breaking the documented contract") + } +} + +// There must be no submission outcome that claims validation: the endpoint +// responds before the store has been consulted, so any such member would be a +// lie the SDKs could surface to a customer. +func TestNoSubmissionOutcomeClaimsValidation(t *testing.T) { + for _, outcome := range []string{ + SubmissionAccepted, SubmissionDuplicate, + SubmissionPermanentlyRejected, SubmissionRetryableFailure, + } { + if outcome == "validated" || strings.Contains(outcome, "confirmed") { + t.Fatalf("submission outcome %q claims validation", outcome) + } + } +} diff --git a/apps/api/internal/billing/errors.go b/apps/api/internal/billing/errors.go new file mode 100644 index 00000000..944c9a01 --- /dev/null +++ b/apps/api/internal/billing/errors.go @@ -0,0 +1,66 @@ +package billing + +import "errors" + +// Application-level sentinels. Handlers map these to HTTP status codes in one +// place; nothing else in the package compares error strings. +var ( + ErrUnauthenticated = errors.New("billing request is unauthenticated") + ErrForbidden = errors.New("billing request is not permitted") + ErrNotFound = errors.New("billing resource was not found") + ErrConflict = errors.New("billing request conflicts with current state") + ErrInvalid = errors.New("billing request is invalid") + ErrRateLimited = errors.New("billing request is rate limited") + ErrBillingDisabled = errors.New("Mosaic Billing is not enabled for this Project") + // ErrUnavailable is the only condition an intake endpoint answers non-2xx + // for: Mosaic genuinely could not durably record the input. + ErrUnavailable = errors.New("billing storage is temporarily unavailable") + // ErrValidationBusy reports that another worker holds a live lease on the + // input a caller asked to revalidate. It is a "come back later" signal, not + // a failure: the caller leaves its own job queued rather than running a + // second validation concurrently with the one already in flight. + ErrValidationBusy = errors.New("the input is already being validated") + // ErrApplicationNotScoped reports that the input's Application is not inside + // the credential's scope, so no correct per-request bundle id exists. It is + // distinct from ErrCredentialUnusable because the operator action differs: + // scope the Application to the credential, rather than replace the key. + ErrApplicationNotScoped = errors.New("the Application is not scoped to this Store Server Credential") + // ErrCredentialsStillActive blocks disabling Mosaic Billing while a Store + // Server Credential is still active. Disabling with credentials in place + // would mean Apple keeps posting notifications Mosaic refuses to record, + // silently spending a five-attempt retry budget that is never re-issued; + // revoking the credential first is what actually stops the store. + ErrCredentialsStillActive = errors.New("active Store Server Credentials must be revoked before Mosaic Billing can be disabled") + // ErrCredentialUnusable covers a revoked or undecryptable Store Server + // Credential — one that exists but cannot be used. It never carries the + // underlying cryptographic error. + ErrCredentialUnusable = errors.New("store server credential is unusable") + // ErrCredentialMissing is distinct from ErrCredentialUnusable: the tenant + // scope has no active Store Server Credential for the provider at all. + // + // The two are separated because the operator action is different and the + // severity is different. An unusable credential is a broken secret, and the + // fix is rotation. A missing credential means Mosaic was asked to validate + // against a store it has never been connected to, and the fix is to connect + // it. Reporting the second as the first sends an operator to rotate a + // credential that does not exist. + ErrCredentialMissing = errors.New("no store server credential is configured for this environment") +) + +// SafeError wraps a failure so that a 5xx response never carries the cause into +// the operator log through response.Error's cause-logging path. +// +// response.Error logs the cause behind every 5xx. That is correct for ordinary +// routes and wrong for billing: a provider transport error, a JSON decode +// error, or a database error on this path can quote a fragment of a signed +// payload, a purchase token, or an Authorization header. SafeError keeps the +// stable code and drops everything else, following the same reasoning as the +// authn resolver redaction in platform/authn/principal.go. +type SafeError struct { + Code string + // Kind is the Go type name of the original error, retained for triage. It + // is a type name only and can never contain a value. + Kind string +} + +func (e *SafeError) Error() string { return "billing operation failed: " + e.Code } diff --git a/apps/api/internal/billing/model.go b/apps/api/internal/billing/model.go new file mode 100644 index 00000000..d7eb0f5e --- /dev/null +++ b/apps/api/internal/billing/model.go @@ -0,0 +1,601 @@ +package billing + +import "time" + +// ValidatorVersion is stamped on every Validation Attempt and Transaction Fact. +// It exists so a replay can be asked for explicitly ("re-run these inputs under +// 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 + +// Providers. +const ( + ProviderAppStore = "app_store" + ProviderGooglePlay = "google_play" +) + +// Store Environment. Distinct from a Mosaic Environment throughout: the two are +// always two separate values on every record and every UI control. +const ( + StoreSandbox = "sandbox" + StoreProduction = "production" + StoreUnclassified = "unclassified" +) + +// Raw Billing Input sources. +const ( + SourceAppleNotification = "apple_notification" + SourceAppleNotificationHistory = "apple_notification_history" + SourceAppleTransactionHistory = "apple_transaction_history" + SourceGoogleRTDN = "google_rtdn" + SourceGoogleTokenRequery = "google_token_requery" + SourceClientObservation = "client_observation" + SourceTrustedServerObservation = "trusted_server_observation" +) + +// Source authority. A client observation is a trigger, never proof; a trusted +// server observation is a trigger from a more accountable caller, still never +// proof. Only the store itself is authoritative, and only after validation. +const ( + AuthorityStoreNotification = "store_notification" + AuthorityStoreReconciliation = "store_reconciliation" + AuthorityClient = "client_observation" + AuthorityTrustedServer = "trusted_server_observation" +) + +// Authentication results recorded on a Raw Billing Input. +const ( + AuthVerifiedSignature = "verified_signature" + AuthVerifiedTransport = "verified_transport" + AuthUnauthenticated = "unauthenticated_client" + AuthFailed = "failed" +) + +// Ingestion statuses. +const ( + IngestAccepted = "accepted" + IngestDuplicate = "duplicate" + IngestConflicted = "conflicted" + IngestQuarantined = "quarantined" +) + +// Validation attempt outcomes. +const ( + OutcomeValidated = "validated" + OutcomeRecordedNoFact = "recorded_no_fact" + OutcomeQuarantined = "quarantined" + OutcomeRetryableFailure = "retryable_failure" + OutcomePermanentlyFailed = "permanently_failed" +) + +// Submission statuses returned to an observation caller, matching the frozen +// Billing Ingestion Contract v1 status set verbatim. There is deliberately no +// member meaning validated, verified, confirmed, or entitled: an observation +// endpoint cannot honestly report any of those, because validation has not +// happened when the response is written. +const ( + SubmissionAccepted = "accepted_for_validation" + SubmissionDuplicate = "duplicate" + SubmissionPermanentlyRejected = "permanently_rejected" + SubmissionRetryableFailure = "retryable_failure" +) + +// Permanent submission codes. Resubmitting the identical document cannot +// succeed. The vocabulary is the contract's `permanentCode` enum; a code outside +// it would fail schema validation in every SDK that checks, so these constants +// exist rather than free-form strings. +const ( + CodeObservationSchemaInvalid = "observation_schema_invalid" + CodeUnknownField = "unknown_field" + CodeInvalidIdentifier = "invalid_identifier" + CodeInvalidTimestamp = "invalid_timestamp" + CodeObservationTooLarge = "observation_too_large" + CodeProviderReferenceMalformed = "provider_reference_malformed" + CodeReferenceKindUnsupported = "reference_kind_not_supported_for_platform" + CodeSensitiveValueRejected = "sensitive_value_rejected" + CodeAuthorityNotAllowed = "authority_not_allowed" + CodeBillingNotEnabled = "billing_not_enabled_for_environment" + CodeObservationIDConflict = "observation_id_conflict" +) + +// Retryable submission codes. Transient; resubmit the identical document later. +const ( + CodeRateLimited = "rate_limited" + CodeStorageUnavailable = "storage_temporarily_unavailable" + CodeServiceUnavailable = "service_temporarily_unavailable" + CodeIngestionTimeout = "ingestion_timeout" + CodeValidationBacklogFull = "validation_backlog_saturated" +) + +// SubmissionRecordType and BillingContractVersion identify the response record. +const ( + BillingContractVersion = "1" + SubmissionRecordType = "observationSubmissionResult" +) + +// Reference kinds a client may submit. The discriminator resolves the +// long-standing ambiguity between the iOS raw-decimal transaction id and the +// Android token digest. +const ( + ReferenceAppStoreTransactionID = "app_store_transaction_id" + ReferenceGooglePlayTokenDigest = "google_play_token_digest" + ReferenceGooglePlayOrderID = "google_play_order_id" +) + +// Transaction types. Phase 9A supports auto-renewable subscriptions and +// non-consumables only; a consumable quarantines rather than producing a fact +// it cannot model. +const ( + TypeAutoRenewableSubscription = "auto_renewable_subscription" + TypeNonConsumable = "non_consumable" +) + +// Fact kinds. +const ( + KindInitialPurchase = "initial_purchase" + KindRenewal = "renewal" + KindOneTimePurchase = "one_time_purchase" + KindPlanChange = "plan_change" + KindOfferRedeemed = "offer_redeemed" + KindRefund = "refund" + KindRevocation = "revocation" + KindExpiration = "expiration" + KindGracePeriodStart = "grace_period_start" + KindBillingRetryStart = "billing_retry_start" + KindCancellationScheduled = "cancellation_scheduled" + KindAutoRenewDisabled = "auto_renew_disabled" + KindAutoRenewEnabled = "auto_renew_enabled" + KindPurchaseSuperseded = "purchase_superseded" + KindPaused = "paused" + KindResumed = "resumed" +) + +// Product-resolution outcomes. +const ( + ResolutionResolved = "resolved" + ResolutionUnknown = "unknown" + ResolutionAmbiguous = "ambiguous" + ResolutionCrossEnvironmentMismatch = "cross_environment_mismatch" + ResolutionUnsupportedProductType = "unsupported_product_type" +) + +// Resolution states recorded in a Resolution Snapshot. +const ( + StateActiveMapping = "active_mapping" + StateArchivedMapping = "archived_mapping" + StateReplacementChain = "replacement_chain" + StateUnresolved = "unresolved" +) + +// Quarantine reason codes. +const ( + QuarantineSignatureInvalid = "signature_invalid" + QuarantineApplicationMismatch = "application_mismatch" + QuarantineEnvironmentMismatch = "environment_mismatch" + QuarantineStoreEnvironmentMismatch = "store_environment_mismatch" + QuarantineCredentialUnavailable = "credential_unavailable" + QuarantineCredentialRevoked = "credential_revoked" + // QuarantineMissingCredential is not the same failure as + // credential_unavailable. It says the Environment has no Store Server + // Credential for the provider at all, so the input can never be validated + // until one is connected — a setup gap, not a broken secret. + QuarantineMissingCredential = "missing_validation_credential" + QuarantineProductUnknown = "product_unknown" + QuarantineProductAmbiguous = "product_ambiguous" + QuarantineCrossEnvironmentMismatch = "cross_environment_mismatch" + QuarantineUnsupportedProductType = "unsupported_product_type" + QuarantineUnsupportedTransaction = "unsupported_transaction_type" + QuarantineMalformedReference = "malformed_reference" + QuarantineInputContentConflict = "input_content_conflict" + QuarantineReplayConflict = "replay_conflict" + QuarantineProviderPermanentlyFailed = "provider_permanently_failed" + QuarantineValidationExhausted = "validation_exhausted" +) + +// Quarantine statuses. There is no status meaning "operator declared this +// valid": the only exit that yields a Transaction Fact is a successful +// revalidation against the store. +const ( + QuarantineOpen = "open" + QuarantineRetrying = "retrying" + QuarantineClosedAfterSuccess = "closed_after_success" + QuarantineClosedSuperseded = "closed_superseded" +) + +// Ledger entry types. +const ( + LedgerInputReceived = "input_received" + LedgerInputAuthenticated = "input_authenticated" + LedgerInputDuplicateDetected = "input_duplicate_detected" + LedgerValidationStarted = "validation_started" + LedgerValidationSucceeded = "validation_succeeded" + LedgerValidationFailed = "validation_failed" + LedgerProductResolved = "product_resolved" + LedgerProductResolutionFailed = "product_resolution_failed" + LedgerFactRecorded = "fact_recorded" + LedgerFactDeduplicated = "fact_deduplicated" + LedgerInputQuarantined = "input_quarantined" + LedgerQuarantineClosed = "quarantine_closed" + LedgerReconciliationStarted = "reconciliation_started" + LedgerReconciliationDiscovery = "reconciliation_discovery" + LedgerReconciliationCompleted = "reconciliation_completed" + LedgerReplayStarted = "replay_started" + LedgerReplayCompleted = "replay_completed" + LedgerRevalidationCompleted = "revalidation_completed" + LedgerCredentialHealthChanged = "credential_health_changed" +) + +// Credential classes for the encryption envelope. +const ( + ClassAppleInAppPurchaseKey = "appleInAppPurchaseKey" + ClassGoogleServiceAccountKey = "googleServiceAccountKey" + ClassBillingRawPayload = "billingRawPayload" +) + +// Actor is the authenticated dashboard principal. +type Actor struct{ ID string } + +// Envelope is the persisted encryption envelope for a credential or a raw body. +type Envelope struct { + Version int + Algorithm string + KeyID string + Nonce []byte + Ciphertext []byte + Fingerprint []byte +} + +// StoreServerCredential is the operator-facing view. It never carries secret +// material: the envelope stays in the repository layer and the API returns only +// the non-secret identifiers. +type StoreServerCredential struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + Provider string `json:"provider"` + StoreEnvironment string `json:"storeEnvironment"` + Name string `json:"name"` + Status string `json:"status"` + HealthStatus string `json:"healthStatus"` + AppleIssuerID string `json:"appleIssuerId,omitempty"` + AppleKeyID string `json:"appleKeyId,omitempty"` + GoogleClientEmail string `json:"googleClientEmail,omitempty"` + GooglePubSubProjectID string `json:"googlePubSubProjectId,omitempty"` + GooglePubSubSubscription string `json:"googlePubSubSubscriptionId,omitempty"` + Applications []CredentialApplication `json:"applications"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + LastTestedAt *time.Time `json:"lastTestedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + RotatedAt *time.Time `json:"rotatedAt,omitempty"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` + // NotificationEndpointURL is populated only on create and rotate. It embeds + // the one-time intake token and is never returned by any read. + NotificationEndpointURL string `json:"notificationEndpointUrl,omitempty"` +} + +// CredentialApplication binds one Application to a credential and records the +// store-side identifier (`bundleId` for Apple, `packageName` for Google) the +// verified payload must match. +type CredentialApplication struct { + ApplicationID string `json:"applicationId"` + Platform string `json:"platform"` + ProviderApplicationIdentifier string `json:"providerApplicationIdentifier"` +} + +// CredentialSecret is the decrypted material, used only inside the worker. +type CredentialSecret struct { + Credential StoreServerCredential + // Plaintext is the .p8 PEM for Apple or the service-account JSON for Google. + Plaintext []byte + // OrganizationID is required to rebuild the encryption scope. + OrganizationID string + EnvironmentMode string +} + +// RawInput is a persisted Raw Billing Input. +type RawInput struct { + ID string + ProjectID string + OrganizationID string + EnvironmentID string + EnvironmentMode string + ApplicationID string + CredentialID string + Provider string + Source string + SourceAuthority string + ProviderEventID string + IdempotencyKey []byte + ContentDigest []byte + TransactionReferenceDigest []byte + BodyState string + Envelope *Envelope + AuthenticationResult string + StoreEnvironment string + NotificationKind string + NotificationSubtype string + IngestionStatus string + CorrelationID string + ProviderOccurredAt *time.Time + ReceivedAt time.Time + ExpiresAt time.Time +} + +// ValidationAttempt is one append-only record of one validation try. +type ValidationAttempt struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + RawInputID string `json:"rawInputId"` + CredentialID string `json:"credentialId,omitempty"` + AttemptNumber int `json:"attemptNumber"` + ValidatorVersion int `json:"validatorVersion"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + Outcome string `json:"outcome"` + Retryable bool `json:"retryable"` + FailureCategory string `json:"failureCategory,omitempty"` + DiagnosticCode string `json:"diagnosticCode,omitempty"` + ProviderCode string `json:"providerCode,omitempty"` + ProviderHTTPStatus int `json:"providerHttpStatus,omitempty"` + StoreEnvironment string `json:"storeEnvironment"` + LatencyMs int `json:"latencyMs"` + ReplayOfAttemptID string `json:"replayOfAttemptId,omitempty"` + CorrelationID string `json:"correlationId"` +} + +// TransactionFact is the normalized, provider-independent statement. +type TransactionFact 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"` + ProviderTransactionID string `json:"providerTransactionId"` + ProviderOriginalTransactionID string `json:"providerOriginalTransactionId,omitempty"` + PurchaseChainDigest []byte `json:"-"` + SupersedesChainDigest []byte `json:"-"` + TransactionType string `json:"transactionType"` + FactKind string `json:"factKind"` + OccurredAt time.Time `json:"occurredAt"` + PeriodStartAt *time.Time `json:"periodStartAt,omitempty"` + PeriodEndAt *time.Time `json:"periodEndAt,omitempty"` + 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"` +} + +// QuarantineRecord is one input that cannot safely proceed. +type QuarantineRecord struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + RawInputID string `json:"rawInputId"` + ApplicationID string `json:"applicationId,omitempty"` + Provider string `json:"provider"` + // StoreEnvironment is carried from the quarantined input so the operator + // surface can keep sandbox and production apart. It is never empty: an + // input whose environment was not classified before it quarantined reports + // "unclassified" explicitly rather than an absent field, because a missing + // value on this surface reads as production to a careless eye. + StoreEnvironment string `json:"storeEnvironment"` + // ProviderProductIdentifier is the store Product the quarantined input + // named, carried from the input's resolution attempt. For the common + // product_unknown case it is the single most actionable field on the + // record — it is exactly what the operator has to create a mapping for — + // and without it the dashboard had to send them elsewhere to find it. + ProviderProductIdentifier string `json:"providerProductIdentifier,omitempty"` + ReasonCode string `json:"reasonCode"` + Severity string `json:"severity"` + Scopes []string `json:"scopes"` + Status string `json:"status"` + AttemptCount int `json:"attemptCount"` + FirstSeenAt time.Time `json:"firstSeenAt"` + LastAttemptAt time.Time `json:"lastAttemptAt"` + ClosingAttemptID string `json:"closingAttemptId,omitempty"` + SupersededByRecordID string `json:"supersededByRecordId,omitempty"` + ClosedAt *time.Time `json:"closedAt,omitempty"` + DiagnosticCode string `json:"diagnosticCode,omitempty"` +} + +// ReconciliationRun is one bounded, restart-safe reconciliation pass. +type ReconciliationRun struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + CredentialID string `json:"credentialId"` + Provider string `json:"provider"` + Trigger string `json:"trigger"` + Strategy string `json:"strategy"` + Status string `json:"status"` + WindowStart time.Time `json:"windowStart"` + // CursorToken is the provider pagination position a restarted run resumes + // from. It is opaque and bounded, and is never a credential. + CursorToken string `json:"-"` + // Cursor is the keyset position of the last input examined. It is distinct + // from CursorToken: one is a provider position, the other a Mosaic row + // position, and sharing a column would conflate them. + Cursor InputCursor `json:"-"` + WindowEnd time.Time `json:"windowEnd"` + ExaminedCount int64 `json:"examinedCount"` + DiscoveredCount int64 `json:"discoveredCount"` + DuplicateCount int64 `json:"duplicateCount"` + // ConflictCount records discoveries that contradicted a fact already on + // record, as distinct from discoveries that were merely new. Gate 9A + // requires reconciliation to detect missing *or conflicting* state, and + // without a separate counter the two are indistinguishable. + ConflictCount int64 `json:"conflictCount"` + FailureCount int64 `json:"failureCount"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} + +// ReplayJob is one replay or revalidation. +type ReplayJob struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + Kind string `json:"kind"` + RawInputID string `json:"rawInputId,omitempty"` + WindowStart *time.Time `json:"windowStart,omitempty"` + WindowEnd *time.Time `json:"windowEnd,omitempty"` + ValidatorVersion int `json:"validatorVersion"` + Status string `json:"status"` + ComparisonResult string `json:"comparisonResult,omitempty"` + ExaminedCount int64 `json:"examinedCount"` + UnchangedCount int64 `json:"unchangedCount"` + NewFactCount int64 `json:"newFactCount"` + ConflictCount int64 `json:"conflictCount"` + LastErrorCode string `json:"lastErrorCode,omitempty"` + Cursor InputCursor `json:"-"` + CreatedAt time.Time `json:"createdAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} + +// InputCursor is a keyset position over Raw Billing Inputs ordered by +// (received_at, id). +// +// A keyset rather than an offset: inputs are appended continuously, so an +// offset would skip rows as the table grows underneath a multi-pass scan. Zero +// value means "start at the beginning". +type InputCursor struct { + ReceivedAt *time.Time + InputID string +} + +// Set reports whether the cursor names a position. +func (c InputCursor) Set() bool { return c.ReceivedAt != nil && c.InputID != "" } + +// LedgerEntry is one append-only operational event. +type LedgerEntry struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + EnvironmentID string `json:"environmentId"` + EntryType string `json:"entryType"` + RawInputID string `json:"rawInputId,omitempty"` + ValidationAttemptID string `json:"validationAttemptId,omitempty"` + TransactionFactID string `json:"transactionFactId,omitempty"` + CredentialID string `json:"credentialId,omitempty"` + Detail map[string]string `json:"detail,omitempty"` + CorrelationID string `json:"correlationId"` + OccurredAt time.Time `json:"occurredAt"` +} + +// ObservationScope is the tenant an observation authenticated into. +type ObservationScope struct { + APIKeyID string + OrganizationID string + ProjectID string + EnvironmentID string + EnvironmentMode string + ApplicationID string + Platform string +} + +// Observation is a validated client or trusted-server submission. +type Observation struct { + SubmissionID string + // ReferenceKind discriminates how Reference must be interpreted. + ReferenceKind string + Reference string + // OrderReference is Google's optional order id. It is never a deduplication + // key: promotional purchases have no order id, so using it would silently + // drop them. + OrderReference string + // PurchaseToken is populated only by the trusted server endpoint. It is + // encrypted on receipt and never logged. + PurchaseToken string + StoreEnvironment string + ObservedAt time.Time +} + +// SubmissionResult is the observation submission payload. +// +// It is the contract's `observationSubmissionResult` record verbatim, so every +// SDK decodes one platform-neutral shape. The schema declares +// additionalProperties:false, which is why nothing Mosaic-internal (a request +// id, a raw input id, a queue position) may be added here. +type SubmissionResult struct { + SubmissionID string `json:"submissionId"` + // ReceivedAt is when Mosaic durably recorded the submission, in the + // contract's UTC timestamp form. + ReceivedAt string `json:"receivedAt"` + Status string `json:"status"` + // Code is required for permanently_rejected and retryable_failure and + // forbidden for the other two statuses. + Code string `json:"code,omitempty"` + // RetryAfterSeconds appears only on retryable_failure. + RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` + // EstimatedValidationDelaySeconds appears only on accepted_for_validation. + // It is advisory: it says when validation is likely to run, never that it + // succeeded. + EstimatedValidationDelaySeconds int `json:"estimatedValidationDelaySeconds,omitempty"` +} + +// SubmissionEnvelope wraps a submission result in the contract record envelope. +type SubmissionEnvelope struct { + BillingIngestionContractVersion string `json:"billingIngestionContractVersion"` + RecordType string `json:"recordType"` + Payload SubmissionResult `json:"payload"` +} + +// Envelope renders the result as the contract record readers expect. +func (result SubmissionResult) Envelope() SubmissionEnvelope { + return SubmissionEnvelope{ + BillingIngestionContractVersion: BillingContractVersion, + RecordType: SubmissionRecordType, + Payload: result, + } +} + +// ContractTimestamp renders an instant in the contract's UTC timestamp form: +// RFC 3339 with millisecond precision and a literal Z, which is what the +// schema pattern accepts. +func ContractTimestamp(value time.Time) string { + return value.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// ValidationJob is one leased unit of validation work. +type ValidationJob struct { + ID string + ProjectID string + EnvironmentID string + RawInputID string + Provider string + AttemptCount int + MaxAttempts int +} + +// MappingCandidate is one Product mapping row considered during resolution. +type MappingCandidate struct { + ID string + ProjectID string + MosaicProductID string + MosaicProductType string + Status string + ArchivedAt *time.Time + ReplacesMappingID string + ProviderBasePlanIdentifier string + ProviderOfferIdentifier string + Version int64 +} diff --git a/apps/api/internal/billing/repository.go b/apps/api/internal/billing/repository.go new file mode 100644 index 00000000..2bf8c11b --- /dev/null +++ b/apps/api/internal/billing/repository.go @@ -0,0 +1,298 @@ +package billing + +import ( + "context" + "time" +) + +// ListOptions is the shared cursor-pagination shape for billing reads. +type ListOptions struct { + Limit int + Cursor string + // Filters are closed sets validated at the transport boundary before they + // reach SQL. + Status string + ReasonCode string + Provider string + // RawInputID narrows a validation-attempt list to one input's history, + // which is how an operator follows a quarantined input's attempts. + RawInputID string + From *time.Time + To *time.Time +} + +// InputFilter narrows a replay or reconciliation scan over Raw Billing Inputs. +// +// Both members exist because of the same failure: a scan that is wider than the +// work the run can actually do reports counts it did not earn. A Google +// reconciliation over an unfiltered Environment picks up Apple notifications it +// cannot re-query, and a token re-query over every Google input picks up +// observations that carry a digest rather than a token — which can never +// succeed, so every run would report `partial` and the alarm would never clear. +// The zero value means "everything", which is what replay wants. +type InputFilter struct { + // Provider limits the scan to one store. + Provider string + // Sources limits the scan to inputs that arrived by particular routes. Empty + // means every source. + Sources []string +} + +// Page is one page of results plus the cursor for the next. +type Page[T any] struct { + Items []T `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} + +// CredentialInput is a create or rotate request. Secret is the raw .p8 PEM or +// service-account JSON and is zeroed by the service once sealed. +type CredentialInput struct { + // CredentialID is generated by the service before encryption, because the + // envelope's additional data binds the ciphertext to this exact row. + CredentialID string + ProjectID string + EnvironmentID string + Provider string + StoreEnvironment string + Name string + Secret []byte + AppleIssuerID string + AppleKeyID string + GoogleClientEmail string + GooglePubSubProjectID string + GooglePubSubSubscription string + Applications []CredentialApplication +} + +// IntakeIdentity is what an Apple intake token resolves to. It is deliberately +// minimal: the intake path must be a single indexed read before any body is +// parsed. +type IntakeIdentity struct { + CredentialID string + ProjectID string + OrganizationID string + EnvironmentID string + EnvironmentMode string + StoreEnvironment string + Provider string + Status string +} + +// PersistResult reports what an idempotent raw-input write actually did. +type PersistResult struct { + RawInputID string + Status string + // Conflicted is set when the idempotency key already existed with a + // different content digest, which is evidence of either a provider bug or a + // forgery attempt and always quarantines. + Conflicted bool + Enqueued bool +} + +// AttemptOutcome is everything one validation attempt produced, written in one +// transaction so an attempt, its resolution, its fact, its ledger entries, and +// its queue transition can never disagree. +type AttemptOutcome struct { + Attempt ValidationAttempt + Resolution *ResolutionRecord + Fact *TransactionFact + Ledger []LedgerEntry + Quarantine *QuarantineWrite + // NextAvailableAt schedules a retry; zero completes or fails the job. + NextAvailableAt time.Time + JobStatus string +} + +// ResolutionRecord is the persisted Resolution Snapshot. +type ResolutionRecord struct { + ID string + ProjectID string + EnvironmentID string + ApplicationID string + ValidationAttemptID string + RawInputID string + Provider string + ProviderProductIdentifier string + ProviderBasePlanIdentifier string + ProviderOfferIdentifier string + Outcome string + ResolutionState string + MosaicProductID string + ProviderProductMappingID string + MatchedMappingID string + MappingVersion *int64 + CandidateCount int + DiagnosticCode string + OccurredAt time.Time + ResolvedAt time.Time +} + +// QuarantineWrite opens or updates a quarantine record. +type QuarantineWrite struct { + RawInputID string + ApplicationID string + Provider string + ReasonCode string + Severity string + Scopes []string + DiagnosticCode string + OccurredAt time.Time +} + +// Repository is the persistence port. Authorization for operator-facing reads +// and writes is enforced in SQL alongside the query, following the analytics +// precedent, so no caller can reach a tenant's billing data by forgetting a +// check. +type Repository interface { + // Settings and tenancy. + BillingEnabled(ctx context.Context, projectID string) (bool, error) + // Settings is the authorized operator read. It reports enablement alongside + // the credential count, because whether billing *can* be disabled depends on + // it and a caller should not have to infer that from a failed write. + Settings(ctx context.Context, actor Actor, projectID string) (Settings, error) + OrganizationForProject(ctx context.Context, projectID string) (string, error) + // EnvironmentScope returns the Environment's own mode and owning + // organization. Callers that persist a Raw Billing Input must read the mode + // from here rather than deriving it, because the mode participates in a + // composite foreign key. + EnvironmentScope(ctx context.Context, projectID, environmentID string) (mode string, organizationID string, err error) + SetBillingEnabled(ctx context.Context, actor Actor, projectID string, enabled bool, now time.Time) error + + // Credentials. + CreateCredential(ctx context.Context, actor Actor, input CredentialInput, envelope Envelope, class string, intakeTokenDigest []byte, now time.Time) (StoreServerCredential, error) + RotateCredential(ctx context.Context, actor Actor, projectID, credentialID string, envelope Envelope, intakeTokenDigest []byte, now time.Time) (StoreServerCredential, error) + RevokeCredential(ctx context.Context, actor Actor, projectID, credentialID string, now time.Time) (StoreServerCredential, error) + ListCredentials(ctx context.Context, actor Actor, projectID string) ([]StoreServerCredential, error) + GetCredential(ctx context.Context, actor Actor, projectID, credentialID string) (StoreServerCredential, error) + // CredentialSecretFor decrypts through the supplied opener. The repository + // owns the envelope columns; the service owns the cipher. + CredentialSecretFor(ctx context.Context, projectID, credentialID string) (StoreServerCredential, Envelope, string, string, string, error) + // ProviderApplicationIdentifier resolves the store-side identifier for one + // Application in one credential's scope. Apple's per-request `bid` must + // name the Application the transaction belongs to, not whichever Application + // happens to sort first. + ProviderApplicationIdentifier(ctx context.Context, credentialID, applicationID string) (string, error) + CredentialForApplication(ctx context.Context, environmentID, provider, providerApplicationIdentifier string) (IntakeIdentity, string, error) + // CredentialForEnvironment resolves the single active credential a + // (Project, provider, Environment) scope has. Migration 00022 makes that + // tuple UNIQUE, so the answer is unambiguous by construction rather than by + // a "pick the first" rule. It exists for inputs that arrive without a + // credential of their own — observations, whose tenancy comes from an API + // key — and returns ErrCredentialMissing when the scope has none. + CredentialForEnvironment(ctx context.Context, projectID, provider, environmentID string) (IntakeIdentity, error) + RecordCredentialEvent(ctx context.Context, projectID, credentialID, action, outcome, diagnosticCode, actorID string, now time.Time) error + UpdateCredentialHealth(ctx context.Context, projectID, credentialID, health, errorCode string, tested bool, now time.Time) error + // ActiveCredentials lists every usable credential for the worker loops. + ActiveCredentials(ctx context.Context, provider string) ([]IntakeIdentity, error) + + // Intake. + ResolveIntakeToken(ctx context.Context, tokenDigest []byte) (IntakeIdentity, error) + PersistRawInput(ctx context.Context, input RawInput, enqueue bool, now time.Time) (PersistResult, error) + RawInput(ctx context.Context, projectID, rawInputID string) (RawInput, error) + // ApplicationForIdentifier maps a verified bundle id or package name onto an + // Application inside the credential's scope. + ApplicationForIdentifier(ctx context.Context, credentialID, identifier string) (string, string, error) + + // Observations. + AuthenticateSDKKey(ctx context.Context, raw string) (ObservationScope, error) + AuthenticateServerKey(ctx context.Context, raw string) (ObservationScope, error) + + // Validation worker. + LeaseValidationJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (ValidationJob, bool, error) + // LeaseValidationJobFor creates (or takes over) the validation job for one + // named Raw Billing Input and returns it already leased to workerID. + // + // It exists because replay and Google reconciliation must revalidate an + // input that has already been ingested. Routing them back through + // PersistRawInput cannot work: that path is idempotent by design, so a + // second write of an existing input takes the duplicate branch and is + // suppressed — including the enqueue. Handing back a leased job instead + // means the caller runs the identical validation pipeline the worker runs, + // 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 + // 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 + NextAttemptNumber(ctx context.Context, rawInputID string) (int, error) + // FactDigestsForInput returns the lowercase-hex fact digests already recorded + // for one input. It is the baseline a replay compares its recomputed digest + // against, which is what makes the comparison a real one rather than an + // assumption that nothing changed. + FactDigestsForInput(ctx context.Context, projectID, rawInputID string) ([]string, error) + + // Resolution. + MappingCandidates(ctx context.Context, environmentID, applicationID, platform, provider, providerProductIdentifier string) ([]MappingCandidate, error) + MappingSuccessors(ctx context.Context, projectID string, mappingIDs []string) (map[string]MappingCandidate, error) + + // Reads. + ListFacts(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[TransactionFact], error) + ListAttempts(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[ValidationAttempt], error) + ListLedger(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[LedgerEntry], error) + ListQuarantine(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[QuarantineRecord], error) + Quarantine(ctx context.Context, actor Actor, projectID, recordID string) (QuarantineRecord, error) + + // OpenQuarantine records a quarantine for an input outside the validation + // attempt transaction, used when a reconciliation discovery contradicts a + // fact already on record. + OpenQuarantine(ctx context.Context, projectID, environmentID string, write QuarantineWrite) error + + // Recovery actions. + RequeueValidation(ctx context.Context, actor Actor, projectID, recordID string, now time.Time) (QuarantineRecord, error) + CloseQuarantineSuperseded(ctx context.Context, actor Actor, projectID, recordID, supersededBy string, now time.Time) (QuarantineRecord, error) + + // Reconciliation and replay. + CreateReconciliationRun(ctx context.Context, actor Actor, run ReconciliationRun, now time.Time) (ReconciliationRun, error) + ListReconciliationRuns(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[ReconciliationRun], error) + LeaseReconciliationRun(ctx context.Context, workerID string, now, leaseUntil time.Time) (ReconciliationRun, bool, error) + UpdateReconciliationProgress(ctx context.Context, run ReconciliationRun, cursorToken string, cursor InputCursor, now time.Time) error + CompleteReconciliationRun(ctx context.Context, run ReconciliationRun, status, errorCode string, now time.Time) error + + CreateReplayJob(ctx context.Context, actor Actor, job ReplayJob, now time.Time) (ReplayJob, error) + ListReplayJobs(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[ReplayJob], error) + LeaseReplayJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (ReplayJob, bool, error) + // ReplayInputs selects the inputs a replay or reconciliation run will + // revalidate, narrowed by filter. Replay passes the zero filter, because + // replaying a window deliberately covers everything in it. + // ReplayInputs returns one bounded page of candidate inputs starting after + // cursor, plus the cursor to resume from. A page shorter than limit means + // the window is exhausted. + ReplayInputs(ctx context.Context, job ReplayJob, filter InputFilter, cursor InputCursor, limit int) ([]RawInput, InputCursor, error) + CompleteReplayJob(ctx context.Context, job ReplayJob, comparison, errorCode string, now time.Time) error + // UpdateReplayProgress commits counters and the cursor and returns the job + // to the queue so the next pass resumes where this one stopped. + UpdateReplayProgress(ctx context.Context, job ReplayJob, cursor InputCursor, now time.Time) error + + // Retention. The only path that removes a raw body. + ExpireRawInputBodies(ctx context.Context, now time.Time, limit int) (int64, error) + + // Health. + Health(ctx context.Context, actor Actor, projectID, environmentID string) (Health, error) +} + +// Settings is the per-Project billing configuration. +type Settings struct { + ProjectID string `json:"projectId"` + BillingEnabled bool `json:"billingEnabled"` + // ActiveCredentialCount is why CanDisable may be false. Surfacing the + // number rather than only the verdict lets the caller say *what* is + // blocking rather than only that something is. + ActiveCredentialCount int `json:"activeCredentialCount"` + // CanDisable reports whether a disable would be accepted right now. + CanDisable bool `json:"canDisable"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// Health is the billing health summary shown to operators. +type Health struct { + EnvironmentID string `json:"environmentId"` + BillingEnabled bool `json:"billingEnabled"` + CredentialCount int `json:"credentialCount"` + UnhealthyCredentials int `json:"unhealthyCredentials"` + QueueDepth int64 `json:"queueDepth"` + OldestQueuedAgeSecs float64 `json:"oldestQueuedAgeSeconds"` + OpenQuarantineCount int64 `json:"openQuarantineCount"` + FactCount int64 `json:"factCount"` + LastFactRecordedAt *time.Time `json:"lastFactRecordedAt,omitempty"` + LastReconciliationAt *time.Time `json:"lastReconciliationAt,omitempty"` +} diff --git a/apps/api/internal/billing/resolution.go b/apps/api/internal/billing/resolution.go new file mode 100644 index 00000000..69ae1877 --- /dev/null +++ b/apps/api/internal/billing/resolution.go @@ -0,0 +1,224 @@ +package billing + +import ( + "sort" + "time" +) + +// maxChainHops bounds the replacement walk. UNIQUE (replaces_mapping_id) plus +// the self-reference CHECK added in migration 00022 already make the chain +// linear and acyclic, so this is a defensive stop rather than a correctness +// requirement: if it is ever reached the data violates a constraint, and +// treating that as ambiguity is safer than looping. +const maxChainHops = 32 + +// ResolutionInput is everything the resolver is allowed to consider. +// +// The absence of fields here is the point. There is no display name, no price, +// no billing period, and no fuzzy identifier, because Product resolution must +// never guess: an incorrect commerce Product attributed to a transaction is a +// named release blocker, and a near-match is indistinguishable from a correct +// match once it has been written to an append-only ledger. +type ResolutionInput struct { + Provider string + ProviderProductIdentifier string + ProviderBasePlanIdentifier string + ProviderOfferIdentifier string + // OccurredAt is the transaction's own instant. Mapping history is evaluated + // as of this moment, not as of now, so a transaction from before a mapping + // was archived still resolves to what it meant at the time. + OccurredAt time.Time + TransactionType string + // Candidates are every mapping in the Environment/Application/platform scope + // carrying this provider Product identifier, in any status. + Candidates []MappingCandidate + // Successors maps a mapping id to the mapping that replaces it. + Successors map[string]MappingCandidate +} + +// Resolution is the Resolution Snapshot: the exact mapping version used, so a +// replay months later reproduces the same answer. +type Resolution struct { + Outcome string + State string + // MosaicProductID is the Product whose meaning was adopted. + MosaicProductID string + // MappingID is the mapping the Product came from. + MappingID string + // MatchedMappingID is the mapping that actually matched the transaction, + // which differs from MappingID when a replacement chain was followed. Both + // are recorded so provenance is exact rather than merely useful. + MatchedMappingID string + MappingVersion int64 + CandidateCount int + DiagnosticCode string +} + +// Resolve performs deterministic Product resolution. +// +// Order: an active mapping, then the mapping that was live at the transaction's +// own occurrence time, then the linear replacement chain forward from it. +// Everything else is `unknown` or `ambiguous`, and both quarantine. +func Resolve(input ResolutionInput) Resolution { + candidates := filterByPlan(input) + result := Resolution{CandidateCount: len(candidates)} + + current := make([]MappingCandidate, 0, len(candidates)) + historical := make([]MappingCandidate, 0, len(candidates)) + for _, candidate := range candidates { + switch { + case candidate.ArchivedAt == nil && isCurrentStatus(candidate.Status): + current = append(current, candidate) + case candidate.ArchivedAt != nil && candidate.ArchivedAt.After(input.OccurredAt): + // The mapping was still live when the transaction happened. + historical = append(historical, candidate) + } + } + + switch { + case len(current) > 1: + result.Outcome = ResolutionAmbiguous + result.DiagnosticCode = "multiple_current_mappings" + return result + case len(current) == 1: + return finish(result, input, current[0], current[0], StateActiveMapping) + } + + if len(historical) == 0 { + result.Outcome = ResolutionUnknown + result.DiagnosticCode = "no_mapping_for_provider_product" + return result + } + + // Oldest archival first: the mapping archived soonest after the transaction + // is the one that was in force when it happened. + sort.SliceStable(historical, func(i, j int) bool { + if historical[i].ArchivedAt.Equal(*historical[j].ArchivedAt) { + return historical[i].ID < historical[j].ID + } + return historical[i].ArchivedAt.Before(*historical[j].ArchivedAt) + }) + if len(historical) > 1 && historical[0].ArchivedAt.Equal(*historical[1].ArchivedAt) { + // Two mappings archived at the same instant cannot be ordered by intent. + result.Outcome = ResolutionAmbiguous + result.DiagnosticCode = "ambiguous_archived_mappings" + return result + } + + matched := historical[0] + adopted, state, ok := walkChain(matched, input.Successors) + if !ok { + result.Outcome = ResolutionAmbiguous + result.DiagnosticCode = "replacement_chain_exceeded" + return result + } + return finish(result, input, adopted, matched, state) +} + +// finish applies the checks that depend on the adopted Product rather than on +// which mapping matched. +func finish(result Resolution, input ResolutionInput, adopted, matched MappingCandidate, state string) Resolution { + if adopted.MosaicProductID == "" { + result.Outcome = ResolutionUnknown + result.DiagnosticCode = "mapping_has_no_product" + return result + } + // A subscription transaction that resolves to a one-time Product, or the + // reverse, is a configuration error rather than a fact. Recording it would + // put a contradiction into an append-only ledger. + if !typeCompatible(input.TransactionType, adopted.MosaicProductType) { + result.Outcome = ResolutionUnsupportedProductType + result.DiagnosticCode = "product_type_mismatch" + return result + } + result.Outcome = ResolutionResolved + result.State = state + result.MosaicProductID = adopted.MosaicProductID + result.MappingID = adopted.ID + result.MatchedMappingID = matched.ID + result.MappingVersion = adopted.Version + return result +} + +// walkChain follows replaces_mapping_id forward from a matched archived mapping +// to the operator's current declared intent. +func walkChain(matched MappingCandidate, successors map[string]MappingCandidate) (MappingCandidate, string, bool) { + adopted := matched + state := StateArchivedMapping + seen := map[string]struct{}{matched.ID: {}} + for hop := 0; hop < maxChainHops; hop++ { + successor, ok := successors[adopted.ID] + if !ok { + return adopted, state, true + } + if _, repeated := seen[successor.ID]; repeated { + return MappingCandidate{}, "", false + } + seen[successor.ID] = struct{}{} + adopted = successor + state = StateReplacementChain + } + return MappingCandidate{}, "", false +} + +// filterByPlan narrows Google candidates by base plan when the mapping declares +// one. A mapping that declares a base plan is a statement that it only covers +// that plan, so a transaction on a different plan must not match it. +func filterByPlan(input ResolutionInput) []MappingCandidate { + if input.Provider != ProviderGooglePlay { + return input.Candidates + } + filtered := make([]MappingCandidate, 0, len(input.Candidates)) + for _, candidate := range input.Candidates { + if candidate.ProviderBasePlanIdentifier != "" && + candidate.ProviderBasePlanIdentifier != input.ProviderBasePlanIdentifier { + continue + } + if candidate.ProviderOfferIdentifier != "" && + candidate.ProviderOfferIdentifier != input.ProviderOfferIdentifier { + continue + } + filtered = append(filtered, candidate) + } + return filtered +} + +func isCurrentStatus(status string) bool { + switch status { + case "draft", "active", "attention_required": + return true + default: + return false + } +} + +// typeCompatible pairs a store transaction type with a Mosaic Product type. +func typeCompatible(transactionType, productType string) bool { + switch transactionType { + case TypeAutoRenewableSubscription: + return productType == "subscription" + case TypeNonConsumable: + return productType == "one_time_non_consumable" + default: + return false + } +} + +// QuarantineReasonFor maps a non-resolved outcome onto the quarantine reason it +// produces. Resolution failures always quarantine: an unresolved Product means +// Mosaic saw a real purchase of something it does not recognise, which is an +// operator action, not a discardable event. +func QuarantineReasonFor(outcome string) (string, bool) { + switch outcome { + case ResolutionUnknown: + return QuarantineProductUnknown, true + case ResolutionAmbiguous: + return QuarantineProductAmbiguous, true + case ResolutionCrossEnvironmentMismatch: + return QuarantineCrossEnvironmentMismatch, true + case ResolutionUnsupportedProductType: + return QuarantineUnsupportedProductType, true + default: + return "", false + } +} diff --git a/apps/api/internal/billing/resolution_test.go b/apps/api/internal/billing/resolution_test.go new file mode 100644 index 00000000..4caf722a --- /dev/null +++ b/apps/api/internal/billing/resolution_test.go @@ -0,0 +1,223 @@ +package billing + +import ( + "testing" + "time" +) + +// Product resolution is the highest-risk correctness surface in Phase 9A: +// attributing a transaction to the wrong Mosaic Product writes a wrong +// statement into an append-only ledger that has no update path. These tests +// pin the two properties that matter — resolution is evaluated as of the +// transaction's own time, and it never guesses — at the layer where the rules +// actually live. + +func at(value string) time.Time { + parsed, _ := time.Parse(time.RFC3339, value) + return parsed.UTC() +} + +func timePtr(value string) *time.Time { + parsed := at(value) + return &parsed +} + +func subscriptionInput(occurredAt time.Time, candidates []MappingCandidate, successors map[string]MappingCandidate) ResolutionInput { + return ResolutionInput{ + Provider: ProviderAppStore, + ProviderProductIdentifier: "fixture.pro.monthly", + OccurredAt: occurredAt, + TransactionType: TypeAutoRenewableSubscription, + Candidates: candidates, + Successors: successors, + } +} + +func TestResolveUsesActiveMapping(t *testing.T) { + result := Resolve(subscriptionInput(at("2026-06-01T00:00:00Z"), []MappingCandidate{ + {ID: "map_active", MosaicProductID: "prod_pro", MosaicProductType: "subscription", Status: "active", Version: 7}, + }, nil)) + + if result.Outcome != ResolutionResolved || result.State != StateActiveMapping { + t.Fatalf("got outcome %q state %q, want resolved/active_mapping", result.Outcome, result.State) + } + if result.MosaicProductID != "prod_pro" || result.MappingID != "map_active" { + t.Fatalf("resolved to %q via %q", result.MosaicProductID, result.MappingID) + } + // The Resolution Snapshot must record the exact version used, otherwise a + // replay cannot prove it reproduced the same decision. + if result.MappingVersion != 7 { + t.Fatalf("mapping version %d, want 7", result.MappingVersion) + } +} + +// A transaction that happened while a mapping was still live must resolve +// through that mapping even though it has since been archived. Evaluating +// against "now" instead would silently reattribute historical transactions +// every time an operator reorganizes their catalog. +func TestResolveUsesMappingLiveAtTransactionTime(t *testing.T) { + result := Resolve(subscriptionInput(at("2026-01-15T00:00:00Z"), []MappingCandidate{ + { + ID: "map_old", MosaicProductID: "prod_legacy", MosaicProductType: "subscription", + Status: "archived", ArchivedAt: timePtr("2026-03-01T00:00:00Z"), Version: 3, + }, + }, nil)) + + if result.Outcome != ResolutionResolved || result.State != StateArchivedMapping { + t.Fatalf("got outcome %q state %q, want resolved/archived_mapping", result.Outcome, result.State) + } + if result.MosaicProductID != "prod_legacy" { + t.Fatalf("resolved to %q, want prod_legacy", result.MosaicProductID) + } +} + +// A mapping archived before the transaction happened was not in force and must +// not match. Without this a transaction could resolve through a mapping that +// had already been retired. +func TestResolveIgnoresMappingArchivedBeforeTransaction(t *testing.T) { + result := Resolve(subscriptionInput(at("2026-06-01T00:00:00Z"), []MappingCandidate{ + { + ID: "map_retired", MosaicProductID: "prod_legacy", MosaicProductType: "subscription", + Status: "archived", ArchivedAt: timePtr("2026-03-01T00:00:00Z"), + }, + }, nil)) + + if result.Outcome != ResolutionUnknown { + t.Fatalf("got outcome %q, want unknown", result.Outcome) + } +} + +// When the operator replaced a mapping, the replacement declares the intended +// continuity, so the successor's Product is adopted — but provenance records +// the mapping that actually matched, so the history stays exact. +func TestResolveFollowsReplacementChain(t *testing.T) { + matched := MappingCandidate{ + ID: "map_v1", MosaicProductID: "prod_v1", MosaicProductType: "subscription", + Status: "archived", ArchivedAt: timePtr("2026-03-01T00:00:00Z"), Version: 1, + } + successors := map[string]MappingCandidate{ + "map_v1": {ID: "map_v2", MosaicProductID: "prod_v2", MosaicProductType: "subscription", Status: "active", Version: 2}, + } + + result := Resolve(subscriptionInput(at("2026-01-15T00:00:00Z"), []MappingCandidate{matched}, successors)) + + if result.Outcome != ResolutionResolved || result.State != StateReplacementChain { + t.Fatalf("got outcome %q state %q, want resolved/replacement_chain", result.Outcome, result.State) + } + if result.MosaicProductID != "prod_v2" { + t.Fatalf("adopted Product %q, want prod_v2", result.MosaicProductID) + } + if result.MatchedMappingID != "map_v1" || result.MappingID != "map_v2" { + t.Fatalf("provenance lost: matched %q adopted %q", result.MatchedMappingID, result.MappingID) + } +} + +// A cycle would make the walk non-terminating. The schema forbids one, so +// reaching this branch means the data is already broken and the safe answer is +// ambiguity rather than a guess or a hang. +func TestResolveRejectsCyclicReplacementChain(t *testing.T) { + matched := MappingCandidate{ + ID: "map_a", MosaicProductID: "prod_a", MosaicProductType: "subscription", + Status: "archived", ArchivedAt: timePtr("2026-03-01T00:00:00Z"), + } + successors := map[string]MappingCandidate{ + "map_a": {ID: "map_b", MosaicProductID: "prod_b", MosaicProductType: "subscription", Status: "archived"}, + "map_b": {ID: "map_a", MosaicProductID: "prod_a", MosaicProductType: "subscription", Status: "archived"}, + } + + result := Resolve(subscriptionInput(at("2026-01-15T00:00:00Z"), []MappingCandidate{matched}, successors)) + if result.Outcome != ResolutionAmbiguous { + t.Fatalf("got outcome %q, want ambiguous", result.Outcome) + } +} + +// Two live mappings for the same provider Product cannot be ordered by intent. +// Picking either would be a guess, and Mosaic never guesses. +func TestResolveRejectsAmbiguousCandidates(t *testing.T) { + result := Resolve(subscriptionInput(at("2026-06-01T00:00:00Z"), []MappingCandidate{ + {ID: "map_one", MosaicProductID: "prod_one", MosaicProductType: "subscription", Status: "active"}, + {ID: "map_two", MosaicProductID: "prod_two", MosaicProductType: "subscription", Status: "active"}, + }, nil)) + + if result.Outcome != ResolutionAmbiguous { + t.Fatalf("got outcome %q, want ambiguous", result.Outcome) + } + if result.MosaicProductID != "" { + t.Fatalf("ambiguous resolution still produced Product %q", result.MosaicProductID) + } +} + +// A subscription transaction resolving to a one-time Product is a +// configuration contradiction. Recording it would put a self-inconsistent fact +// into a ledger with no update path. +func TestResolveRejectsProductTypeMismatch(t *testing.T) { + result := Resolve(subscriptionInput(at("2026-06-01T00:00:00Z"), []MappingCandidate{ + {ID: "map_one_time", MosaicProductID: "prod_lifetime", MosaicProductType: "one_time_non_consumable", Status: "active"}, + }, nil)) + + if result.Outcome != ResolutionUnsupportedProductType { + t.Fatalf("got outcome %q, want unsupported_product_type", result.Outcome) + } +} + +// A Google mapping that declares a base plan covers only that plan. Matching a +// transaction on a different plan would attribute revenue for one plan to +// another. +func TestResolveHonoursGoogleBasePlanScope(t *testing.T) { + input := ResolutionInput{ + Provider: ProviderGooglePlay, + ProviderProductIdentifier: "fixture.pro", + ProviderBasePlanIdentifier: "annual", + OccurredAt: at("2026-06-01T00:00:00Z"), + TransactionType: TypeAutoRenewableSubscription, + Candidates: []MappingCandidate{ + {ID: "map_monthly", MosaicProductID: "prod_monthly", MosaicProductType: "subscription", + Status: "active", ProviderBasePlanIdentifier: "monthly"}, + {ID: "map_annual", MosaicProductID: "prod_annual", MosaicProductType: "subscription", + Status: "active", ProviderBasePlanIdentifier: "annual"}, + }, + } + + result := Resolve(input) + if result.Outcome != ResolutionResolved || result.MosaicProductID != "prod_annual" { + t.Fatalf("got outcome %q Product %q, want resolved/prod_annual", result.Outcome, result.MosaicProductID) + } +} + +// Every non-resolved outcome must quarantine. A resolution failure that +// silently produced nothing would leave a confirmed purchase invisible. +func TestUnresolvedOutcomesQuarantine(t *testing.T) { + for _, outcome := range []string{ + ResolutionUnknown, ResolutionAmbiguous, + ResolutionCrossEnvironmentMismatch, ResolutionUnsupportedProductType, + } { + if _, quarantines := QuarantineReasonFor(outcome); !quarantines { + t.Fatalf("outcome %q does not quarantine", outcome) + } + } + if _, quarantines := QuarantineReasonFor(ResolutionResolved); quarantines { + t.Fatal("a resolved outcome must not quarantine") + } +} + +// Resolution must be reproducible: the same inputs replayed later produce the +// same snapshot, which is what makes replay a no-op rather than a rewrite. +func TestResolveIsDeterministic(t *testing.T) { + candidates := []MappingCandidate{ + {ID: "map_b", MosaicProductID: "prod_b", MosaicProductType: "subscription", + Status: "archived", ArchivedAt: timePtr("2026-04-01T00:00:00Z"), Version: 2}, + {ID: "map_a", MosaicProductID: "prod_a", MosaicProductType: "subscription", + Status: "archived", ArchivedAt: timePtr("2026-03-01T00:00:00Z"), Version: 1}, + } + first := Resolve(subscriptionInput(at("2026-01-15T00:00:00Z"), candidates, nil)) + second := Resolve(subscriptionInput(at("2026-01-15T00:00:00Z"), candidates, nil)) + + if first != second { + t.Fatalf("resolution is not deterministic: %+v vs %+v", first, second) + } + // The mapping archived soonest after the transaction is the one that was in + // force when it happened. + if first.MappingID != "map_a" { + t.Fatalf("resolved via %q, want map_a", first.MappingID) + } +} diff --git a/apps/api/internal/billing/retry.go b/apps/api/internal/billing/retry.go new file mode 100644 index 00000000..ed790990 --- /dev/null +++ b/apps/api/internal/billing/retry.go @@ -0,0 +1,215 @@ +package billing + +import ( + "context" + "errors" + "math/rand/v2" + "net" + "net/http" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" +) + +// Failure categories. The split that matters is retryable versus permanent: +// retrying a permanent failure burns provider quota and never recovers, while +// dead-lettering a transient failure loses a real transaction. +const ( + CategoryTransient = "transient" + CategoryRateLimited = "rate_limited" + CategoryAuth = "auth" + CategoryQuota = "quota" + CategoryNotFoundRetryable = "not_found_retryable" + CategoryNotFoundTerminal = "not_found_terminal" + CategoryInvalid = "invalid" + CategorySignature = "signature" + CategoryResolution = "resolution" + CategoryConfiguration = "configuration" +) + +// Classification is the retry decision for one failure. +type Classification struct { + Category string + Retryable bool + // RetryAt is an absolute instant the provider asked us to wait until. It + // takes precedence over computed backoff when it is later. + RetryAt time.Time + // ProviderCode and HTTPStatus are the safe, bounded diagnostics persisted on + // the Validation Attempt. No provider response body is ever kept. + ProviderCode string + HTTPStatus int + // Diagnostic is Mosaic's own stable code. + Diagnostic string +} + +// appleRetryableCodes are the Apple error codes documented as retryable. Apple +// names them explicitly, which is why they are enumerated rather than inferred +// from the status. +var appleRetryableCodes = map[string]string{ + "4040002": CategoryNotFoundRetryable, // AccountNotFoundRetryableError + "4040004": CategoryNotFoundRetryable, // AppNotFoundRetryableError + "5000001": CategoryTransient, // GeneralInternalRetryableError + "4040006": CategoryNotFoundRetryable, // OriginalTransactionIdNotFoundRetryableError +} + +// Classify maps a provider failure onto a retry decision. +func Classify(err error, now time.Time) Classification { + var appleErr *appstoreserver.Error + if errors.As(err, &appleErr) { + return classifyApple(appleErr, now) + } + var googleErr *googleplay.Error + if errors.As(err, &googleErr) { + return classifyGoogle(googleErr, now) + } + return classifyTransport(err) +} + +func classifyApple(err *appstoreserver.Error, now time.Time) Classification { + result := Classification{ProviderCode: err.AppleCode, HTTPStatus: err.HTTPStatus} + if !err.RetryAt.IsZero() { + result.RetryAt = err.RetryAt + } + if category, ok := appleRetryableCodes[err.AppleCode]; ok { + result.Category, result.Retryable, result.Diagnostic = category, true, "apple_retryable_error" + return result + } + switch { + case err.HTTPStatus == http.StatusTooManyRequests: + result.Category, result.Retryable, result.Diagnostic = CategoryRateLimited, true, "apple_rate_limited" + case err.HTTPStatus == http.StatusUnauthorized: + // One retry with a freshly minted assertion, then terminal: a JWT that + // is still rejected after a fresh signing is a credential problem an + // operator must fix, not a wait. + result.Category, result.Retryable, result.Diagnostic = CategoryAuth, true, "apple_unauthorized" + case err.HTTPStatus == http.StatusNotFound: + result.Category, result.Retryable, result.Diagnostic = CategoryNotFoundTerminal, false, "apple_transaction_not_found" + case err.HTTPStatus >= 500: + result.Category, result.Retryable, result.Diagnostic = CategoryTransient, true, "apple_server_error" + case err.HTTPStatus >= 400: + result.Category, result.Retryable, result.Diagnostic = CategoryInvalid, false, "apple_request_rejected" + case err.HTTPStatus == 0: + return classifyTransport(err) + default: + result.Category, result.Retryable, result.Diagnostic = CategoryInvalid, false, "apple_unexpected_response" + } + return result +} + +func classifyGoogle(err *googleplay.Error, now time.Time) Classification { + result := Classification{ProviderCode: err.GoogleCode, HTTPStatus: err.HTTPStatus} + if err.RetryAfter > 0 { + result.RetryAt = now.Add(err.RetryAfter) + } + switch { + case err.GoogleCode == "RESOURCE_EXHAUSTED" || err.HTTPStatus == http.StatusTooManyRequests: + result.Category, result.Retryable, result.Diagnostic = CategoryQuota, true, "google_quota_exhausted" + case err.GoogleCode == "invalid_grant" || err.GoogleCode == "unauthorized_client": + // The service-account key is rejected outright. Retrying cannot fix it. + result.Category, result.Retryable, result.Diagnostic = CategoryConfiguration, false, "google_credential_rejected" + case err.HTTPStatus == http.StatusUnauthorized: + result.Category, result.Retryable, result.Diagnostic = CategoryAuth, true, "google_unauthorized" + case err.HTTPStatus == http.StatusForbidden: + result.Category, result.Retryable, result.Diagnostic = CategoryConfiguration, false, "google_forbidden" + case err.HTTPStatus == http.StatusNotFound: + // Google 404s a token it does not know. That is terminal: the token will + // not become known later. + result.Category, result.Retryable, result.Diagnostic = CategoryNotFoundTerminal, false, "google_purchase_not_found" + case err.HTTPStatus >= 500: + result.Category, result.Retryable, result.Diagnostic = CategoryTransient, true, "google_server_error" + case err.HTTPStatus >= 400: + result.Category, result.Retryable, result.Diagnostic = CategoryInvalid, false, "google_request_rejected" + case err.HTTPStatus == 0: + return classifyTransport(err) + default: + result.Category, result.Retryable, result.Diagnostic = CategoryInvalid, false, "google_unexpected_response" + } + return result +} + +// classifyTransport handles dial, TLS, and deadline failures, which carry no +// provider status at all. +func classifyTransport(err error) Classification { + switch { + case errors.Is(err, context.DeadlineExceeded): + return Classification{Category: CategoryTransient, Retryable: true, Diagnostic: "provider_timeout"} + case errors.Is(err, context.Canceled): + return Classification{Category: CategoryTransient, Retryable: true, Diagnostic: "provider_cancelled"} + } + var netErr net.Error + if errors.As(err, &netErr) { + return Classification{Category: CategoryTransient, Retryable: true, Diagnostic: "provider_network_error"} + } + return Classification{Category: CategoryTransient, Retryable: true, Diagnostic: "provider_unreachable"} +} + +// MaxAuthAttempts caps how many attempts an authentication failure may consume. +// +// A 401 gets exactly one retry, because Mosaic mints a fresh assertion on every +// request and a genuinely transient signing hiccup will be gone by the second +// one. A credential that is *still* rejected after a fresh signing is revoked, +// expired, or wrong — an operator action, not a wait. Letting it run the full +// eight-attempt budget would multiply provider load across forty minutes of +// backoff per input during an outage the operator can already see, and plan §6 +// lists revoked credentials among the permanent categories that go straight to +// quarantine. +const MaxAuthAttempts = 2 + +// ExhaustedFor reports the attempt ceiling a classification is subject to. +// Most categories use the queue's own budget; authentication is capped much +// lower for the reason above. +func (c Classification) ExhaustedFor(attemptNumber, maxAttempts int) bool { + if c.Category == CategoryAuth && attemptNumber >= MaxAuthAttempts { + return true + } + return attemptNumber >= maxAttempts +} + +// Permanent builds a non-retryable classification for a Mosaic-side decision +// such as an invalid signature or an unresolvable Product. +func Permanent(category, diagnostic string) Classification { + return Classification{Category: category, Retryable: false, Diagnostic: diagnostic} +} + +const ( + backoffBase = 15 * time.Second + backoffCap = 10 * time.Minute + // MaxValidationAttempts bounds a single input's validation. Eight attempts + // across the backoff schedule below spans roughly forty minutes, which + // comfortably outlasts a provider incident without holding a queue slot for + // days. + MaxValidationAttempts = 8 +) + +// NextAttemptAt computes when a retry becomes available. +// +// Jitter is applied because every notification for a popular application +// arrives in the same instant; without it, a provider outage would produce a +// synchronized retry burst at each backoff step and turn a recoverable incident +// into a self-inflicted rate limit. +func NextAttemptAt(now time.Time, attempt int, classification Classification, random *rand.Rand) 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 random != nil { + factor = 1 + (random.Float64()*2-1)*0.25 + } + scheduled := now.Add(time.Duration(float64(delay) * factor)) + // A provider instruction always wins when it asks for a longer wait. It is + // never allowed to shorten the wait: that would let a misbehaving upstream + // pull Mosaic into a hot loop. + if !classification.RetryAt.IsZero() && classification.RetryAt.After(scheduled) { + return classification.RetryAt + } + return scheduled +} diff --git a/apps/api/internal/billing/retry_test.go b/apps/api/internal/billing/retry_test.go new file mode 100644 index 00000000..763de9d0 --- /dev/null +++ b/apps/api/internal/billing/retry_test.go @@ -0,0 +1,217 @@ +package billing + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" +) + +// Retry classification decides whether a failed validation is tried again or +// dead-lettered. Getting it wrong in one direction produces a retry storm +// against a provider; in the other it permanently loses a recoverable +// transaction. The table below pins the documented provider failures to the +// side they belong on. +func TestClassifyAssignsRetryability(t *testing.T) { + now := time.Now().UTC() + cases := []struct { + name string + err error + category string + retryable bool + }{ + {"apple 429", &appstoreserver.Error{HTTPStatus: http.StatusTooManyRequests}, CategoryRateLimited, true}, + {"apple 500", &appstoreserver.Error{HTTPStatus: http.StatusInternalServerError}, CategoryTransient, true}, + {"apple retryable code", &appstoreserver.Error{HTTPStatus: 404, AppleCode: "4040002"}, CategoryNotFoundRetryable, true}, + // A transaction Apple does not know will not become known by waiting. + {"apple 404 terminal", &appstoreserver.Error{HTTPStatus: http.StatusNotFound}, CategoryNotFoundTerminal, false}, + {"apple 400", &appstoreserver.Error{HTTPStatus: http.StatusBadRequest}, CategoryInvalid, false}, + {"google quota", &googleplay.Error{HTTPStatus: 429, GoogleCode: "RESOURCE_EXHAUSTED"}, CategoryQuota, true}, + {"google 503", &googleplay.Error{HTTPStatus: http.StatusServiceUnavailable}, CategoryTransient, true}, + // A rejected service-account key is an operator action, not a wait. + {"google invalid grant", &googleplay.Error{HTTPStatus: 400, GoogleCode: "invalid_grant"}, CategoryConfiguration, false}, + {"google 403", &googleplay.Error{HTTPStatus: http.StatusForbidden}, CategoryConfiguration, false}, + {"google 404", &googleplay.Error{HTTPStatus: http.StatusNotFound}, CategoryNotFoundTerminal, false}, + {"timeout", context.DeadlineExceeded, CategoryTransient, true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + classification := Classify(testCase.err, now) + if classification.Category != testCase.category { + t.Fatalf("category %q, want %q", classification.Category, testCase.category) + } + if classification.Retryable != testCase.retryable { + t.Fatalf("retryable %v, want %v", classification.Retryable, testCase.retryable) + } + }) + } +} + +// Apple's Retry-After is an absolute UNIX timestamp in milliseconds, unlike the +// RFC 7231 delta-seconds every other API in this repository sends. Reading an +// absolute value as a delta schedules the retry tens of thousands of years out, +// which presents as a permanently stalled queue with no error anywhere. This +// test is the reason the two parsers are separate functions. +func TestAppleRetryAfterIsAbsoluteMilliseconds(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + retryAt := now.Add(90 * time.Second) + + parsed, ok := appstoreserver.ParseRetryAfter(millisString(retryAt), now) + if !ok { + t.Fatal("a valid absolute-millisecond Retry-After was not parsed") + } + if !parsed.Equal(retryAt) { + t.Fatalf("parsed %s, want %s", parsed, retryAt) + } + + // A delta-seconds value, if misread as absolute milliseconds, lands in 1970 + // and is in the past — the guard must treat it as absent rather than + // scheduling a retry that already expired. + if _, ok := appstoreserver.ParseRetryAfter("120", now); ok { + t.Fatal("a delta-seconds value was accepted as an absolute timestamp") + } + // An implausibly distant value is a misread rather than an instruction. + if _, ok := appstoreserver.ParseRetryAfter(millisString(now.Add(72*time.Hour)), now); ok { + t.Fatal("an implausibly distant Retry-After was honoured") + } + for _, value := range []string{"", "soon", "-1", "0"} { + if _, ok := appstoreserver.ParseRetryAfter(value, now); ok { + t.Fatalf("malformed Retry-After %q was accepted", value) + } + } +} + +// Google follows RFC 7231, so the delta-seconds form must still parse. Keeping +// both behaviours under test is what stops a future refactor from collapsing +// them into one parser. +func TestGoogleRetryAfterIsDeltaSeconds(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + delay, ok := googleplay.ParseRetryAfter("120", now) + if !ok || delay != 2*time.Minute { + t.Fatalf("parsed %s ok=%v, want 2m", delay, ok) + } + if _, ok := googleplay.ParseRetryAfter(millisString(now.Add(time.Minute)), now); ok { + t.Fatal("an absolute-millisecond value was accepted as delta-seconds") + } +} + +// A provider instruction may push a retry later but must never pull it earlier: +// otherwise a misbehaving upstream could drive Mosaic into a hot loop. +func TestNextAttemptHonoursLongerProviderInstruction(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + far := now.Add(30 * time.Minute) + + scheduled := NextAttemptAt(now, 1, Classification{RetryAt: far}, nil) + if !scheduled.Equal(far) { + t.Fatalf("scheduled %s, want the provider instant %s", scheduled, far) + } + + near := now.Add(time.Second) + scheduled = NextAttemptAt(now, 3, Classification{RetryAt: near}, nil) + if !scheduled.After(near) { + t.Fatalf("a shorter provider instruction shortened the backoff to %s", scheduled) + } +} + +// Backoff must grow and stay bounded: unbounded growth strands work, and no +// growth defeats the purpose during an outage. +func TestBackoffGrowsAndIsCapped(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + previous := time.Duration(0) + for attempt := range 6 { + delay := NextAttemptAt(now, attempt, Classification{}, nil).Sub(now) + if delay <= previous { + t.Fatalf("attempt %d delay %s did not grow beyond %s", attempt, delay, previous) + } + previous = delay + } + capped := NextAttemptAt(now, 30, Classification{}, nil).Sub(now) + if capped > 10*time.Minute { + t.Fatalf("backoff cap exceeded: %s", capped) + } +} + +// SafeError must not carry the original error's text: it is what stops a +// provider payload fragment reaching the 5xx cause-logging path. +func TestSafeFailureDropsUnderlyingMessage(t *testing.T) { + secret := "purchase token gto5s...redacted-secret-value" + wrapped := safeFailure(errors.New(secret), "billing_lease_failed") + + if wrapped == nil { + t.Fatal("safeFailure returned nil for a non-nil error") + } + var safe *SafeError + if !errors.As(wrapped, &safe) { + t.Fatalf("safeFailure returned %T, want *SafeError", wrapped) + } + if contains(wrapped.Error(), "redacted-secret-value") { + t.Fatalf("safe error leaked the cause: %q", wrapped.Error()) + } + if safe.Code != "billing_lease_failed" { + t.Fatalf("code %q, want billing_lease_failed", safe.Code) + } + // Unwrapping must not reach the original error either, or errors.Is on a + // caller's side could still surface it. + if errors.Unwrap(wrapped) != nil { + t.Fatal("safe error still wraps the original cause") + } +} + +func millisString(value time.Time) string { + millis := value.UnixMilli() + digits := "" + for millis > 0 { + digits = string(rune('0'+millis%10)) + digits + millis /= 10 + } + return digits +} + +func contains(haystack, needle string) bool { + if len(needle) > len(haystack) { + return false + } + for index := 0; index+len(needle) <= len(haystack); index++ { + if haystack[index:index+len(needle)] == needle { + return true + } + } + return false +} + +// An authentication failure must not consume the full attempt budget. +// +// Mosaic mints a fresh assertion on every request, so a credential still +// rejected after a second signing is revoked, expired, or wrong — an operator +// action, not a wait. Letting it run all eight attempts multiplies provider +// load across ~40 minutes of backoff per input during an outage the operator +// can already see, and plan §6 lists revoked credentials among the permanent +// categories. +func TestAuthFailuresAreCappedBelowTheQueueBudget(t *testing.T) { + auth := Classification{Category: CategoryAuth, Retryable: true} + transient := Classification{Category: CategoryTransient, Retryable: true} + + if auth.ExhaustedFor(1, MaxValidationAttempts) { + t.Fatal("the first authentication failure was treated as terminal; one retry with a fresh assertion is the point") + } + if !auth.ExhaustedFor(MaxAuthAttempts, MaxValidationAttempts) { + t.Fatalf("an authentication failure was still retryable at attempt %d", MaxAuthAttempts) + } + if MaxAuthAttempts >= MaxValidationAttempts { + t.Fatalf("the auth cap (%d) does not actually cap anything below the queue budget (%d)", + MaxAuthAttempts, MaxValidationAttempts) + } + + // Every other retryable category keeps the queue's own budget: a provider + // outage is exactly the case the eight attempts exist for. + if transient.ExhaustedFor(MaxAuthAttempts, MaxValidationAttempts) { + t.Fatal("the auth cap leaked onto transient failures, which would dead-letter a recoverable outage early") + } + if !transient.ExhaustedFor(MaxValidationAttempts, MaxValidationAttempts) { + t.Fatal("a transient failure never exhausts") + } +} diff --git a/apps/api/internal/billing/service.go b/apps/api/internal/billing/service.go new file mode 100644 index 00000000..287bec6f --- /dev/null +++ b/apps/api/internal/billing/service.go @@ -0,0 +1,693 @@ +package billing + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "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/appstorejws" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// MaxNotificationBytes bounds an Apple notification body. Apple's payloads are +// a few kilobytes; this is generous while still refusing an unbounded write on +// an unauthenticated endpoint. +const MaxNotificationBytes = 256 << 10 + +// MaxObservationBytes bounds an observation body. +const MaxObservationBytes = 8 << 10 + +// Service is the billing application service. Handlers are thin wrappers over +// it and it owns every transaction boundary, authorization decision, and +// provider interaction. +type Service struct { + repository Repository + cipher providercredential.SubjectCipher + verifier *appstorejws.Verifier + apple AppleClient + google GoogleClient + + now func() time.Time + random io.Reader + jitter *mathrand.Rand + tracer trace.Tracer + + retention time.Duration + // notificationBaseURL is the public origin Apple posts notifications to. It + // is used only to render the endpoint URL returned on create and rotate. + notificationBaseURL string + + intakeAccepted metric.Int64Counter + intakeRejected metric.Int64Counter + signatureFailure metric.Int64Counter + validationOutcome metric.Int64Counter + factsAppended metric.Int64Counter + factsDeduped metric.Int64Counter + providerRequests metric.Int64Counter + validationLatency metric.Float64Histogram +} + +// AppleClient is the App Store Server API port. +type AppleClient interface { + TransactionInfo(ctx context.Context, credential appstoreserver.Credential, transactionID string) (string, error) + TransactionHistory(ctx context.Context, credential appstoreserver.Credential, transactionID, revision string) (appstoreserver.HistoryPage, error) + NotificationHistory(ctx context.Context, credential appstoreserver.Credential, request appstoreserver.NotificationHistoryRequest, paginationToken string) (appstoreserver.NotificationHistoryPage, error) +} + +// GoogleClient is the Play Developer API and Pub/Sub port. +type GoogleClient interface { + GetSubscription(ctx context.Context, account *googleplay.ServiceAccount, packageName, purchaseToken string) (googleplay.SubscriptionPurchase, error) + GetProduct(ctx context.Context, account *googleplay.ServiceAccount, packageName, productID, purchaseToken string) (googleplay.ProductPurchase, error) + GetOrder(ctx context.Context, account *googleplay.ServiceAccount, packageName, orderID string) (googleplay.Order, error) + Pull(ctx context.Context, account *googleplay.ServiceAccount, projectID, subscriptionID string, maxMessages int) ([]googleplay.ReceivedMessage, error) + Acknowledge(ctx context.Context, account *googleplay.ServiceAccount, projectID, subscriptionID string, ackIDs []string) error +} + +type ServiceOption func(*Service) + +func WithClock(now func() time.Time) ServiceOption { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +func WithRandom(random io.Reader) ServiceOption { + return func(s *Service) { + if random != nil { + s.random = random + } + } +} + +func WithProviders(apple AppleClient, google GoogleClient) ServiceOption { + return func(s *Service) { s.apple, s.google = apple, google } +} + +func WithRetention(retention time.Duration) ServiceOption { + return func(s *Service) { + if retention > 0 { + s.retention = retention + } + } +} + +func WithNotificationBaseURL(base string) ServiceOption { + return func(s *Service) { s.notificationBaseURL = strings.TrimRight(strings.TrimSpace(base), "/") } +} + +func NewService(repository Repository, cipher providercredential.SubjectCipher, verifier *appstorejws.Verifier, options ...ServiceOption) *Service { + meter := otel.Meter("mosaic/billing") + service := &Service{ + repository: repository, + cipher: cipher, + verifier: verifier, + now: func() time.Time { return time.Now().UTC() }, + random: rand.Reader, + jitter: mathrand.New(mathrand.NewPCG(uint64(time.Now().UnixNano()), 0x9E3779B97F4A7C15)), + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/billing"), + retention: 90 * 24 * time.Hour, + } + service.intakeAccepted, _ = meter.Int64Counter("mosaic.billing.intake.accepted") + service.intakeRejected, _ = meter.Int64Counter("mosaic.billing.intake.rejected") + service.signatureFailure, _ = meter.Int64Counter("mosaic.billing.signature.failures") + service.validationOutcome, _ = meter.Int64Counter("mosaic.billing.validation.outcomes") + service.factsAppended, _ = meter.Int64Counter("mosaic.billing.facts.appended") + service.factsDeduped, _ = meter.Int64Counter("mosaic.billing.facts.deduplicated") + service.providerRequests, _ = meter.Int64Counter("mosaic.billing.provider.requests") + service.validationLatency, _ = meter.Float64Histogram("mosaic.billing.validation.latency", + metric.WithUnit("ms")) + for _, option := range options { + option(service) + } + return service +} + +// --------------------------------------------------------------------------- +// Apple notification intake +// --------------------------------------------------------------------------- + +// AcceptAppleNotification is the whole synchronous intake path: verify, persist, +// enqueue, return. +// +// It performs no outbound provider call. Apple retries a failed V2 notification +// only five times in production and never in sandbox, so an intake that blocks +// on Apple's own API spends a finite, unrecoverable retry budget on Mosaic's +// latency. Everything that can fail slowly happens in the worker instead. +// +// The only condition that produces a non-2xx is a storage failure: an unknown +// bundle, a wrong Application, or an unsupported notification type all record +// the input and answer 2xx, because a retry from Apple would produce the same +// outcome while consuming a retry Mosaic may genuinely need later. +func (s *Service) AcceptAppleNotification(ctx context.Context, intakeToken string, body []byte, correlationID string) error { + ctx, span := s.tracer.Start(ctx, "billing.intake.apple") + defer span.End() + now := s.now() + + digest := sha256.Sum256([]byte(intakeToken)) + identity, err := s.repository.ResolveIntakeToken(ctx, digest[:]) + if err != nil { + // An unknown or revoked token has no tenant to attribute the body to. + // Persisting an unattributable body would be an unbounded write on an + // unauthenticated endpoint, so nothing is stored beyond the counter. + s.intakeRejected.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderAppStore), + attribute.String("reason", "unknown_intake_token"))) + return ErrNotFound + } + span.SetAttributes( + attribute.String("mosaic.project.id", identity.ProjectID), + attribute.String("mosaic.environment.id", identity.EnvironmentID), + ) + + // Defense in depth. The credential rule on the settings endpoint is the + // real guarantee — billing cannot be disabled while a credential is live, + // so a resolvable intake token implies an enabled Project. This check + // covers the window where a Project was disabled by some other path, and + // makes "off means nothing is recorded" true of the notification path and + // not only of SDK observations. Apple is answered 202 either way: a 4xx + // would spend one of five non-renewable retries on a condition retrying + // cannot fix. + if !s.billingEnabled(ctx, identity.ProjectID) { + s.intakeRejected.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderAppStore), + attribute.String("reason", "billing_disabled"))) + return nil + } + + var envelope struct { + SignedPayload string `json:"signedPayload"` + } + if err := json.Unmarshal(body, &envelope); err != nil || envelope.SignedPayload == "" { + return s.recordUnverifiedApple(ctx, identity, body, correlationID, "malformed_body", now) + } + + notification, err := s.verifier.DecodeNotification(envelope.SignedPayload) + if err != nil { + // A signature failure on a valid intake token means either a forgery or + // a misdirected notification. Both are security signals and neither is + // something Apple can fix by retrying. + s.signatureFailure.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderAppStore), + attribute.String("reason", string(appstorejws.ReasonOf(err))))) + return s.recordUnverifiedApple(ctx, identity, body, correlationID, string(appstorejws.ReasonOf(err)), now) + } + + storeEnvironment := StoreUnclassified + bundleID := "" + if notification.Data != nil { + bundleID = notification.Data.BundleID + storeEnvironment = normalizeAppleEnvironment(notification.Data.Environment) + } + + // Tenant identity always comes from the intake token, never from the + // payload: bundle ids are not globally unique across Mosaic tenants, so + // trusting the payload for attribution would let anyone holding a genuine + // Apple notification steer it into another tenant's ledger. The verified + // bundle id is only ever used to *check* the Application, and a mismatch + // quarantines. + applicationID, _, appErr := s.repository.ApplicationForIdentifier(ctx, identity.CredentialID, bundleID) + mismatch := appErr != nil || applicationID == "" + + input := RawInput{ + ProjectID: identity.ProjectID, + OrganizationID: identity.OrganizationID, + EnvironmentID: identity.EnvironmentID, + EnvironmentMode: identity.EnvironmentMode, + ApplicationID: applicationID, + CredentialID: identity.CredentialID, + Provider: ProviderAppStore, + Source: SourceAppleNotification, + SourceAuthority: AuthorityStoreNotification, + ProviderEventID: notification.NotificationUUID, + IdempotencyKey: AppleNotificationKey(notification.NotificationUUID), + ContentDigest: ContentDigest(body), + AuthenticationResult: AuthVerifiedSignature, + StoreEnvironment: storeEnvironment, + NotificationKind: boundedCode(notification.NotificationType), + NotificationSubtype: boundedCode(notification.Subtype), + IngestionStatus: IngestAccepted, + CorrelationID: correlationID, + ReceivedAt: now, + ExpiresAt: now.Add(s.retention), + } + if when, ok := appstorejws.Millis(notification.SignedDate); ok { + input.ProviderOccurredAt = &when + } + if notification.Data != nil && notification.Data.SignedTransactionInfo != "" { + // The transaction reference digest is the attribution join between a + // notification and an earlier client observation. The transaction id + // itself is decoded only if the inner JWS also verifies. + if transaction, err := s.verifier.DecodeTransaction(notification.Data.SignedTransactionInfo); err == nil { + input.TransactionReferenceDigest = AppleTransactionKey(storeEnvironment, transaction.TransactionID) + } + } + if mismatch { + input.IngestionStatus = IngestQuarantined + } + + if err := s.sealBody(&input, body); err != nil { + return err + } + result, err := s.repository.PersistRawInput(ctx, input, !mismatch, now) + if err != nil { + // The only failure Apple should retry. + return ErrUnavailable + } + s.observeIntake(ctx, ProviderAppStore, result) + return nil +} + +// recordUnverifiedApple stores the metadata of an input that failed +// verification. The body is deliberately not sealed: a payload that did not +// verify is not evidence of anything and retaining it would grow an +// attacker-controlled table. +func (s *Service) recordUnverifiedApple(ctx context.Context, identity IntakeIdentity, body []byte, correlationID, reason string, now time.Time) error { + // The bucket, not the body, is the identity of an unverified input. + // + // The notification endpoint has no rate limiter by design — a 429 to Apple + // spends one of five non-renewable delivery attempts — so anything keyed by + // content digest grows without bound: an intake token is an unauthenticated + // bearer value in a URL, and whoever holds one could post a million distinct + // malformed bodies and get a million rows. Collapsing onto + // (credential, reason, hour) caps that at twenty-four rows per credential + // per reason per day while preserving everything an operator can act on: + // which credential is receiving garbage, of what kind, and when. + // + // The content digest is derived from the same bucket rather than the body, + // because the body is deliberately not retained here — an unverified payload + // is not evidence of anything — and a body-derived digest would make every + // repeat look like a content conflict, which is a security-severity signal + // this is not. + bucket := now.UTC().Truncate(time.Hour).Format(time.RFC3339) + identityKey := UnverifiedInputKey(identity.CredentialID, boundedCode(reason), bucket) + input := RawInput{ + ProjectID: identity.ProjectID, + OrganizationID: identity.OrganizationID, + EnvironmentID: identity.EnvironmentID, + EnvironmentMode: identity.EnvironmentMode, + CredentialID: identity.CredentialID, + Provider: ProviderAppStore, + Source: SourceAppleNotification, + SourceAuthority: AuthorityStoreNotification, + IdempotencyKey: identityKey, + ContentDigest: identityKey, + BodyState: "not_retained", + AuthenticationResult: AuthFailed, + StoreEnvironment: StoreUnclassified, + IngestionStatus: IngestQuarantined, + CorrelationID: correlationID, + ReceivedAt: now, + ExpiresAt: now.Add(s.retention), + } + if _, err := s.repository.PersistRawInput(ctx, input, false, now); err != nil { + return ErrUnavailable + } + // The counter is where volume lives. The row records that it happened; the + // metric records how often, without a row per occurrence. + s.intakeRejected.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderAppStore), + attribute.String("reason", boundedCode(reason)))) + return nil +} + +// sealBody encrypts a raw body under the Phase 9A envelope domain. The tenant +// is already known by the time this runs, which is the precondition the +// encryption design requires. +func (s *Service) sealBody(input *RawInput, body []byte) error { + if s.cipher == nil || len(body) == 0 { + input.BodyState = "not_retained" + return nil + } + // The subject id must be stable before encryption, so the repository is told + // the id rather than generating one. + if input.ID == "" { + id, err := s.newID("bri") + if err != nil { + return err + } + input.ID = id + } + envelope, err := s.cipher.EncryptSubject(body, providercredential.SubjectScope{ + OrganizationID: input.OrganizationID, + ProjectID: input.ProjectID, + SubjectKind: providercredential.SubjectBillingRawInput, + SubjectID: input.ID, + CredentialClass: ClassBillingRawPayload, + }) + if err != nil { + // Failing closed: an input whose body cannot be sealed is recorded + // without a body rather than with a plaintext one. + input.BodyState = "not_retained" + return nil + } + input.BodyState = "stored" + input.Envelope = &Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, Fingerprint: envelope.Fingerprint, + } + return nil +} + +func (s *Service) observeIntake(ctx context.Context, provider string, result PersistResult) { + status := result.Status + if result.Conflicted { + status = IngestConflicted + } + s.intakeAccepted.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", provider), + attribute.String("status", status))) +} + +// --------------------------------------------------------------------------- +// Observations +// --------------------------------------------------------------------------- + +// SubmitClientObservation records an untrusted SDK report. +// +// The response never claims validation. `accepted_for_validation` is the +// strongest thing this endpoint can honestly say, because the store has not +// been consulted at the point the response is written. +// +// A client may not classify the Store Environment. The contract's +// clientTransactionObservation record has no such field at all, and a device +// can be made to say anything: accepting a client assertion would let a sandbox +// purchase present itself as production. Classification for a client +// observation comes only from server-side validation of the store's own +// response, so the field is forced to unclassified here regardless of what +// reached the service. +func (s *Service) SubmitClientObservation(ctx context.Context, rawKey string, 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.submitObservation(ctx, scope, observation, SourceClientObservation, AuthorityClient, AuthUnauthenticated, 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 +// more authoritative. +func (s *Service) SubmitServerObservation(ctx context.Context, rawKey string, observation Observation, correlationID string) (SubmissionResult, error) { + scope, err := s.repository.AuthenticateServerKey(ctx, rawKey) + if err != nil { + return SubmissionResult{}, ErrUnauthenticated + } + return s.submitObservation(ctx, scope, observation, SourceTrustedServerObservation, AuthorityTrustedServer, AuthVerifiedTransport, correlationID) +} + +func (s *Service) submitObservation(ctx context.Context, scope ObservationScope, observation Observation, source, authority, authentication, correlationID string) (SubmissionResult, error) { + ctx, span := s.tracer.Start(ctx, "billing.intake.observation") + defer span.End() + now := s.now() + + received := ContractTimestamp(now) + + enabled, err := s.repository.BillingEnabled(ctx, scope.ProjectID) + if err != nil { + return SubmissionResult{}, ErrUnavailable + } + if !enabled { + // Off by default. A disabled Project rejects permanently so an SDK + // queue drains instead of retrying forever. + return rejected(observation.SubmissionID, received, CodeBillingNotEnabled), nil + } + + provider, referenceDigest, ok := s.classifyReference(observation) + if !ok { + return rejected(observation.SubmissionID, received, CodeProviderReferenceMalformed), nil + } + + input := RawInput{ + ProjectID: scope.ProjectID, + OrganizationID: scope.OrganizationID, + EnvironmentID: scope.EnvironmentID, + EnvironmentMode: scope.EnvironmentMode, + ApplicationID: scope.ApplicationID, + Provider: provider, + Source: source, + SourceAuthority: authority, + ProviderEventID: observation.SubmissionID, + IdempotencyKey: ObservationKey(scope.EnvironmentID, observation.SubmissionID), + TransactionReferenceDigest: referenceDigest, + AuthenticationResult: authentication, + StoreEnvironment: normalizeStoreEnvironment(observation.StoreEnvironment), + IngestionStatus: IngestAccepted, + CorrelationID: correlationID, + ReceivedAt: now, + ExpiresAt: now.Add(s.retention), + } + if !observation.ObservedAt.IsZero() { + observedAt := observation.ObservedAt.UTC() + input.ProviderOccurredAt = &observedAt + } + + // The persisted body is a Mosaic-built record rather than the request body, + // so a client cannot decide what Mosaic stores. When a trusted server + // supplied a purchase token it is included here and sealed; it is never + // logged, never echoed, and never part of a metric attribute. + body, err := json.Marshal(map[string]string{ + "referenceKind": observation.ReferenceKind, + "reference": observation.Reference, + "orderReference": observation.OrderReference, + "purchaseToken": observation.PurchaseToken, + "storeEnvironment": input.StoreEnvironment, + }) + if err != nil { + return rejected(observation.SubmissionID, received, CodeProviderReferenceMalformed), nil + } + input.ContentDigest = ContentDigest(body) + if err := s.sealBody(&input, body); err != nil { + return SubmissionResult{}, ErrUnavailable + } + + result, err := s.repository.PersistRawInput(ctx, input, true, now) + if err != nil { + // SDKs queue and retry, so a storage failure is reported as retryable + // rather than swallowed. + return SubmissionResult{ + SubmissionID: observation.SubmissionID, ReceivedAt: received, + Status: SubmissionRetryableFailure, Code: CodeStorageUnavailable, + RetryAfterSeconds: retryableBackoffSeconds, + }, nil + } + s.observeIntake(ctx, provider, result) + switch { + case result.Conflicted: + // The same submission id arrived carrying different content. Accepting + // it would let a client overwrite an earlier observation. + return rejected(observation.SubmissionID, received, CodeObservationIDConflict), nil + case result.Status == IngestDuplicate: + // A duplicate is idempotent, not an error: the SDK queue retried and + // Mosaic already holds the submission. + return SubmissionResult{ + SubmissionID: observation.SubmissionID, ReceivedAt: received, Status: SubmissionDuplicate, + }, nil + default: + return SubmissionResult{ + SubmissionID: observation.SubmissionID, ReceivedAt: received, Status: SubmissionAccepted, + EstimatedValidationDelaySeconds: estimatedValidationDelaySeconds, + }, nil + } +} + +// retryableBackoffSeconds and estimatedValidationDelaySeconds are the hints the +// contract lets a submission response carry. Both are advisory: the SDK queue +// owns its own retry schedule and must not treat either as a guarantee. +const ( + retryableBackoffSeconds = 30 + estimatedValidationDelaySeconds = 30 +) + +// rejected builds a permanent rejection with a contract code. +func rejected(submissionID, receivedAt, code string) SubmissionResult { + return SubmissionResult{ + SubmissionID: submissionID, ReceivedAt: receivedAt, + Status: SubmissionPermanentlyRejected, Code: code, + } +} + +// RateLimited builds the retryable_failure a caller receives when the +// observation limiter sheds it. It lives here rather than in the handler so the +// contract shape has exactly one construction site. +func RateLimited(submissionID string, now time.Time, retryAfter time.Duration) SubmissionResult { + seconds := int(retryAfter.Round(time.Second) / time.Second) + if seconds < 1 { + seconds = 1 + } + if seconds > 86400 { + seconds = 86400 + } + return SubmissionResult{ + SubmissionID: submissionID, ReceivedAt: ContractTimestamp(now), + Status: SubmissionRetryableFailure, Code: CodeRateLimited, RetryAfterSeconds: seconds, + } +} + +// Reject builds a permanent rejection for a transport-level failure such as a +// malformed body or an unknown field. +func Reject(submissionID string, now time.Time, code string) SubmissionResult { + return rejected(submissionID, ContractTimestamp(now), code) +} + +// Now exposes the service clock so the transport can stamp receivedAt on +// responses it builds without reaching the service. +func (s *Service) Now() time.Time { return s.now() } + +// classifyReference derives the provider from the reference discriminator and +// computes the attribution digest. +func (s *Service) classifyReference(observation Observation) (string, []byte, bool) { + reference, ok := SafeProviderCode(observation.Reference) + if !ok { + return "", nil, false + } + switch observation.ReferenceKind { + case ReferenceAppStoreTransactionID: + // Apple transaction ids are decimal. Requiring that is what keeps a JWS + // out of this field even before the length bound applies. + if !isDecimal(reference) { + return "", nil, false + } + return ProviderAppStore, AppleTransactionKey(normalizeStoreEnvironment(observation.StoreEnvironment), reference), true + case ReferenceGooglePlayTokenDigest: + digest, ok := ValidHexDigest(reference) + if !ok { + return "", nil, false + } + return ProviderGooglePlay, digest, true + case ReferenceGooglePlayOrderID: + return ProviderGooglePlay, digestOf("mosaic-billing-google-order-v1", reference), true + default: + return "", nil, false + } +} + +func isDecimal(value string) bool { + if value == "" || len(value) > 32 { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func normalizeAppleEnvironment(value string) string { + switch value { + case appstorejws.EnvironmentProduction: + return StoreProduction + case appstorejws.EnvironmentSandbox: + return StoreSandbox + default: + return StoreUnclassified + } +} + +func normalizeStoreEnvironment(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case StoreProduction: + return StoreProduction + case StoreSandbox: + return StoreSandbox + default: + return StoreUnclassified + } +} + +// boundedCode clips a provider-supplied classification to the column bound and +// the safe charset. +func boundedCode(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if len(trimmed) > 64 { + trimmed = trimmed[:64] + } + for _, r := range trimmed { + if r < 0x20 || r > 0x7e { + return "unclassified" + } + } + return trimmed +} + +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 identifier: %w", err) + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(buffer), nil +} + +// safeFailure reduces an internal error to a stable code before it can reach +// the 5xx cause-logging path. +func safeFailure(err error, code string) error { + if err == nil { + return nil + } + var safe *SafeError + if errors.As(err, &safe) { + return safe + } + return &SafeError{Code: code, Kind: fmt.Sprintf("%T", err)} +} + +// logSafely writes an operator line with identifiers only. +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) +} + +// billingEnabled reports whether a Project may record billing data, failing +// **closed** when the setting cannot be read. +// +// "Off means nothing is recorded" is the phase's frozen optionality promise, +// and a transient database error must not be able to break it: treating an +// unreadable setting as enabled would let a disabled Project accept and store +// signed payloads, call Apple and Google, and append facts. Unknown is +// therefore treated as disabled, and the read failure is logged so the +// difference between "the operator turned it off" and "Mosaic could not tell" +// is visible to an operator rather than inferred from a gap in the ledger. +// +// The cost of the conservative choice is bounded and recoverable: notification +// intake still answers 2xx, so no provider retry budget is spent, and queued +// work is parked rather than failed. The cost of the permissive choice is +// storing bearer material for a tenant that asked Mosaic not to. +func (s *Service) billingEnabled(ctx context.Context, projectID string) bool { + 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 false + } + return enabled +} diff --git a/apps/api/internal/billing/service_credentials.go b/apps/api/internal/billing/service_credentials.go new file mode 100644 index 00000000..a83a9205 --- /dev/null +++ b/apps/api/internal/billing/service_credentials.go @@ -0,0 +1,303 @@ +package billing + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "io" + "strings" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// intakeTokenBytes is the entropy behind an Apple notification endpoint. Thirty- +// two random bytes is the same posture as an API key: the URL is the only thing +// standing between an unauthenticated POST and a tenant, so it must be +// unguessable rather than merely unpublished. +const intakeTokenBytes = 32 + +// CreateCredential stores a new Store Server Credential. +// +// The secret is validated before it is persisted — an Apple .p8 is parsed as a +// P-256 key, a Google key is parsed as a service-account JSON — so an operator +// learns immediately that they pasted the wrong file rather than discovering it +// when the first notification fails validation hours later. +func (s *Service) CreateCredential(ctx context.Context, actor Actor, input CredentialInput) (StoreServerCredential, error) { + defer zero(input.Secret) + if actor.ID == "" { + return StoreServerCredential{}, ErrUnauthenticated + } + class, err := validateSecret(input) + if err != nil { + return StoreServerCredential{}, err + } + + now := s.now() + credentialID, err := s.newID("ssc") + if err != nil { + return StoreServerCredential{}, safeFailure(err, "identifier_generation_failed") + } + organizationID, err := s.organizationFor(ctx, input.ProjectID) + if err != nil { + return StoreServerCredential{}, err + } + + envelope, err := s.sealCredential(input.Secret, organizationID, input.ProjectID, credentialID, class) + if err != nil { + return StoreServerCredential{}, err + } + + token, digest := "", []byte(nil) + if input.Provider == ProviderAppStore { + token, digest, err = s.newIntakeToken() + if err != nil { + return StoreServerCredential{}, err + } + } + + created, err := s.repository.CreateCredential(ctx, actor, withID(input, credentialID), envelope, class, digest, now) + if err != nil { + return StoreServerCredential{}, err + } + _ = s.repository.RecordCredentialEvent(ctx, input.ProjectID, created.ID, "created", "succeeded", "", actor.ID, now) + // The full endpoint URL exists exactly twice in the system's lifetime: in + // this response and in the equivalent rotate response. No read ever returns + // it, because the token it embeds is stored only as a digest. + created.NotificationEndpointURL = s.endpointURL(token) + return created, nil +} + +// RotateCredential replaces the secret and mints a new intake token. The old +// token stops working the instant the new one is stored, which is the whole +// point of rotation after a suspected compromise. +func (s *Service) RotateCredential(ctx context.Context, actor Actor, projectID, credentialID string, secret []byte) (StoreServerCredential, error) { + defer zero(secret) + if actor.ID == "" { + return StoreServerCredential{}, ErrUnauthenticated + } + existing, err := s.repository.GetCredential(ctx, actor, projectID, credentialID) + if err != nil { + return StoreServerCredential{}, err + } + if existing.Status != "active" { + return StoreServerCredential{}, ErrConflict + } + class, err := validateSecret(CredentialInput{Provider: existing.Provider, Secret: secret}) + if err != nil { + return StoreServerCredential{}, err + } + organizationID, err := s.organizationFor(ctx, projectID) + if err != nil { + return StoreServerCredential{}, err + } + envelope, err := s.sealCredential(secret, organizationID, projectID, credentialID, class) + if err != nil { + return StoreServerCredential{}, err + } + token, digest := "", []byte(nil) + if existing.Provider == ProviderAppStore { + token, digest, err = s.newIntakeToken() + if err != nil { + return StoreServerCredential{}, err + } + } + now := s.now() + rotated, err := s.repository.RotateCredential(ctx, actor, projectID, credentialID, envelope, digest, now) + if err != nil { + return StoreServerCredential{}, err + } + _ = s.repository.RecordCredentialEvent(ctx, projectID, credentialID, "rotated", "succeeded", "", actor.ID, now) + rotated.NotificationEndpointURL = s.endpointURL(token) + return rotated, nil +} + +// RevokeCredential stops the credential being used. Ingestion for the tenant +// stops; nothing already recorded is removed, because the ledger is the +// evidence trail a revocation is usually part of investigating. +func (s *Service) RevokeCredential(ctx context.Context, actor Actor, projectID, credentialID string) (StoreServerCredential, error) { + if actor.ID == "" { + return StoreServerCredential{}, ErrUnauthenticated + } + now := s.now() + revoked, err := s.repository.RevokeCredential(ctx, actor, projectID, credentialID, now) + if err != nil { + return StoreServerCredential{}, err + } + _ = s.repository.RecordCredentialEvent(ctx, projectID, credentialID, "revoked", "succeeded", "", actor.ID, now) + return revoked, nil +} + +// TestCredential proves the stored secret still authenticates against the +// store, without changing any store state. +func (s *Service) TestCredential(ctx context.Context, actor Actor, projectID, credentialID string) (StoreServerCredential, error) { + if actor.ID == "" { + return StoreServerCredential{}, ErrUnauthenticated + } + credential, err := s.repository.GetCredential(ctx, actor, projectID, credentialID) + if err != nil { + return StoreServerCredential{}, err + } + now := s.now() + health, code := "healthy", "" + + switch credential.Provider { + case ProviderAppStore: + // Get Notification History over a one-minute window is the cheapest call + // that proves the whole path: the key signs, Apple accepts the issuer, + // and the team is authorized. It reads nothing that changes. + // Team-scoped: the credential test proves the key signs and the team is + // authorized, not that one Application works. + apple, _, credErr := s.appleCredential(ctx, RawInput{ + ProjectID: projectID, CredentialID: credentialID, Provider: ProviderAppStore, + }, scopedToTeam) + if credErr != nil { + health, code = "unavailable", "credential_unusable" + break + } + _, callErr := s.apple.NotificationHistory(ctx, apple, appstoreserver.NotificationHistoryRequest{ + StartDate: now.Add(-time.Minute).UnixMilli(), EndDate: now.UnixMilli(), + }, "") + if callErr != nil { + classification := Classify(callErr, now) + health, code = "degraded", classification.Diagnostic + if !classification.Retryable { + health = "unavailable" + } + } + case ProviderGooglePlay: + account, _, _, credErr := s.googleCredential(ctx, RawInput{ + ProjectID: projectID, CredentialID: credentialID, Provider: ProviderGooglePlay, + }) + if credErr != nil { + health, code = "unavailable", "credential_unusable" + break + } + // A zero-message pull proves the service account can reach the RTDN + // subscription without consuming anything. + if _, callErr := s.google.Pull(ctx, account, credential.GooglePubSubProjectID, credential.GooglePubSubSubscription, 1); callErr != nil { + classification := Classify(callErr, now) + health, code = "degraded", classification.Diagnostic + if !classification.Retryable { + health = "unavailable" + } + } + } + + if err := s.repository.UpdateCredentialHealth(ctx, projectID, credentialID, health, code, true, now); err != nil { + return StoreServerCredential{}, err + } + outcome := "succeeded" + if health != "healthy" { + outcome = "failed" + } + _ = s.repository.RecordCredentialEvent(ctx, projectID, credentialID, "tested", outcome, code, actor.ID, now) + return s.repository.GetCredential(ctx, actor, projectID, credentialID) +} + +// ListCredentials returns the operator view. It never contains secret material. +func (s *Service) ListCredentials(ctx context.Context, actor Actor, projectID string) ([]StoreServerCredential, error) { + if actor.ID == "" { + return nil, ErrUnauthenticated + } + return s.repository.ListCredentials(ctx, actor, projectID) +} + +// GetCredential returns one credential without the endpoint URL. +func (s *Service) GetCredential(ctx context.Context, actor Actor, projectID, credentialID string) (StoreServerCredential, error) { + if actor.ID == "" { + return StoreServerCredential{}, ErrUnauthenticated + } + return s.repository.GetCredential(ctx, actor, projectID, credentialID) +} + +// Settings reads a Project's billing configuration. +func (s *Service) Settings(ctx context.Context, actor Actor, projectID string) (Settings, error) { + if actor.ID == "" { + return Settings{}, ErrUnauthenticated + } + return s.repository.Settings(ctx, actor, projectID) +} + +// SetBillingEnabled turns Mosaic Billing on for a Project. It is off by default. +func (s *Service) SetBillingEnabled(ctx context.Context, actor Actor, projectID string, enabled bool) error { + if actor.ID == "" { + return ErrUnauthenticated + } + return s.repository.SetBillingEnabled(ctx, actor, projectID, enabled, s.now()) +} + +// validateSecret parses the supplied material without persisting it, returning +// the credential class it belongs to. +func validateSecret(input CredentialInput) (string, error) { + switch input.Provider { + case ProviderAppStore: + if _, err := appstoreserver.ParsePrivateKey(input.Secret); err != nil { + return "", ErrInvalid + } + if strings.TrimSpace(input.AppleIssuerID) == "" && input.AppleIssuerID != "" { + return "", ErrInvalid + } + return ClassAppleInAppPurchaseKey, nil + case ProviderGooglePlay: + if _, err := googleplay.ParseServiceAccount(input.Secret); err != nil { + return "", ErrInvalid + } + return ClassGoogleServiceAccountKey, nil + default: + return "", ErrInvalid + } +} + +func (s *Service) sealCredential(secret []byte, organizationID, projectID, credentialID, class string) (Envelope, error) { + envelope, err := s.cipher.EncryptSubject(secret, providercredential.SubjectScope{ + OrganizationID: organizationID, + ProjectID: projectID, + SubjectKind: providercredential.SubjectStoreServerCredential, + SubjectID: credentialID, + CredentialClass: class, + }) + if err != nil { + return Envelope{}, ErrCredentialUnusable + } + return Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, Fingerprint: envelope.Fingerprint, + }, nil +} + +func (s *Service) newIntakeToken() (string, []byte, error) { + buffer := make([]byte, intakeTokenBytes) + if _, err := io.ReadFull(s.random, buffer); err != nil { + return "", nil, safeFailure(err, "intake_token_generation_failed") + } + token := base64.RawURLEncoding.EncodeToString(buffer) + digest := sha256.Sum256([]byte(token)) + return token, digest[:], nil +} + +func (s *Service) endpointURL(token string) string { + if token == "" { + return "" + } + return s.notificationBaseURL + "/v1/billing/apple/notifications/" + token +} + +func withID(input CredentialInput, id string) CredentialInput { + input.Name = strings.TrimSpace(input.Name) + input.CredentialID = id + return input +} + +// organizationFor resolves the organization that owns a Project. The +// organization is part of the envelope's additional data, so it has to be known +// before encryption rather than discovered during the insert. +func (s *Service) organizationFor(ctx context.Context, projectID string) (string, error) { + organizationID, err := s.repository.OrganizationForProject(ctx, projectID) + if err != nil { + return "", ErrNotFound + } + return organizationID, nil +} diff --git a/apps/api/internal/billing/service_operations.go b/apps/api/internal/billing/service_operations.go new file mode 100644 index 00000000..f8c65bff --- /dev/null +++ b/apps/api/internal/billing/service_operations.go @@ -0,0 +1,762 @@ +package billing + +import ( + "context" + "errors" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstoreserver" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" +) + +const ( + operationLease = 5 * time.Minute + // rtdnBatchSize bounds one pull. Small batches keep the acknowledge window + // short, which matters because a message is only acknowledged after its Raw + // Billing Input is durably committed. + rtdnBatchSize = 25 + // reconciliationPageSize bounds one reconciliation step so a run yields the + // worker regularly and can be resumed from its cursor. + reconciliationPageSize = 20 + // replayBatchSize bounds one replay job. Both this and the page size above + // are sized against operationLease rather than chosen round: each input in + // the batch makes a bounded provider call (8 s ceiling in both clients), so + // 25 × 8 s = 200 s stays inside the five-minute lease with room to spare. A + // larger batch could have its lease expire mid-run and let a second worker + // duplicate the work. + replayBatchSize = 25 +) + +// revalidationResult is what one caller-initiated revalidation produced. It is +// the input to a replay's comparison and to a reconciliation's counters. +type revalidationResult struct { + // Outcome is the validation attempt's own outcome. + Outcome string + // Digest is the lowercase-hex fact digest the revalidation recomputed, empty + // when the attempt asserted no fact. + Digest string + // Existing reports whether Digest was already on record for this input. This + // is the actual comparison: an unchanged provider answer recomputes a digest + // Mosaic already holds, and a changed one does not. + Existing bool + // Conflicted reports that the recomputed fact contradicts what was already + // recorded for this input: the input had facts before, and this attempt + // produced a digest that is not among them. + // + // The distinction from a plain discovery is the whole point. An input that + // had no facts and now has one is Mosaic learning something new. An input + // that had a fact and now produces a different one is the provider saying + // something different from what Mosaic recorded — which is the "conflicting + // state" half of the Gate 9A reconciliation criterion, and needs an + // operator's attention rather than a counter labelled "discovered". + // + // Nothing is overwritten either way: both facts remain, and the conflict is + // a diagnostic over an append-only ledger. + Conflicted bool +} + +// revalidate runs the full validation pipeline once against an input that has +// already been ingested, and reports how its result compares with what is +// already recorded. +// +// It does not go through PersistRawInput. That path is idempotent by design: a +// second write of an existing input takes the duplicate branch, which returns +// before the enqueue and therefore queues nothing. Anything built on it would +// report work it never did. Instead the validation job is created (or taken +// over) already leased to this caller, so the ordinary validation worker cannot +// claim it in between, and the attempt is committed through exactly the same +// CompleteAttempt transaction the worker uses — the same append-only attempt, +// the same fact deduplication, the same ledger entries, the same quarantine +// closure on success. +func (s *Service) revalidate(ctx context.Context, workerID string, input RawInput) (revalidationResult, error) { + // The baseline is read before the attempt runs, so a fact this very attempt + // appends cannot be mistaken for one that was already there. + baseline, err := s.repository.FactDigestsForInput(ctx, input.ProjectID, input.ID) + if err != nil { + return revalidationResult{}, safeFailure(err, "billing_fact_baseline_failed") + } + + now := s.now() + job, err := s.repository.LeaseValidationJobFor(ctx, workerID, input, now, now.Add(validationLease)) + if errors.Is(err, ErrValidationBusy) { + // Another worker holds a live lease on this input. Running anyway would + // put two validations on the same input concurrently, and the loser of + // the attempt-number race would discard everything it produced. The + // caller leaves the input for the next pass instead. + return revalidationResult{}, ErrValidationBusy + } + if err != nil { + return revalidationResult{}, safeFailure(err, "billing_revalidation_lease_failed") + } + + started := s.now() + outcome := s.runValidation(ctx, job, started) + s.validationLatency.Record(ctx, float64(outcome.Attempt.LatencyMs), metric.WithAttributes( + attribute.String("provider", job.Provider))) + s.validationOutcome.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", job.Provider), + attribute.String("outcome", outcome.Attempt.Outcome), + attribute.String("trigger", "revalidation"))) + if err := s.repository.CompleteAttempt(ctx, job, outcome, s.now()); err != nil { + return revalidationResult{}, safeFailure(err, "billing_attempt_write_failed") + } + + result := revalidationResult{Outcome: outcome.Attempt.Outcome} + if outcome.Fact != nil { + result.Digest = hexOf(outcome.Fact.FactDigest) + for _, known := range baseline { + if known == result.Digest { + result.Existing = true + break + } + } + // A new digest where facts already existed is a contradiction, not a + // discovery. Where no facts existed it is simply new information. + result.Conflicted = !result.Existing && len(baseline) > 0 + } + return result, nil +} + +// --------------------------------------------------------------------------- +// Google RTDN pull consumer +// --------------------------------------------------------------------------- + +// ProcessNextRTDN pulls one batch of Real-time Developer Notifications for each +// active Google credential. +// +// The order is deliberate and is the whole reliability argument: pull, persist, +// then acknowledge. A crash between persist and acknowledge causes Google to +// redeliver, and the idempotency key turns the redelivery into a duplicate that +// writes nothing. A crash between acknowledge and persist would lose a +// notification outright, which is why acknowledgement never runs first. +func (s *Service) ProcessNextRTDN(ctx context.Context, workerID string) (bool, error) { + if s.google == nil { + return false, nil + } + credentials, err := s.repository.ActiveCredentials(ctx, ProviderGooglePlay) + if err != nil { + return false, safeFailure(err, "billing_credential_scan_failed") + } + processedAny := false + for _, identity := range credentials { + // A disabled Project records nothing. Skipping before the pull also + // avoids acknowledging messages Mosaic would then refuse to store, + // which would lose them permanently. + if !s.billingEnabled(ctx, identity.ProjectID) { + continue + } + processed, err := s.pullOne(ctx, identity) + if err != nil { + // One tenant's misconfiguration must not stop every other tenant's + // notifications, so the loop continues and the failure is recorded + // against that credential's health. + _ = s.repository.UpdateCredentialHealth(ctx, identity.ProjectID, identity.CredentialID, + "degraded", "rtdn_pull_failed", false, s.now()) + continue + } + processedAny = processedAny || processed + } + return processedAny, nil +} + +func (s *Service) pullOne(ctx context.Context, identity IntakeIdentity) (bool, error) { + ctx, span := s.tracer.Start(ctx, "billing.intake.google") + defer span.End() + + credential, _, _, _, _, err := s.repository.CredentialSecretFor(ctx, identity.ProjectID, identity.CredentialID) + if err != nil { + return false, err + } + account, _, _, err := s.googleCredential(ctx, RawInput{ + ProjectID: identity.ProjectID, CredentialID: identity.CredentialID, Provider: ProviderGooglePlay, + }) + if err != nil { + return false, err + } + messages, err := s.google.Pull(ctx, account, credential.GooglePubSubProjectID, credential.GooglePubSubSubscription, rtdnBatchSize) + if err != nil { + return false, err + } + if len(messages) == 0 { + return false, nil + } + + now := s.now() + subscription := credential.GooglePubSubProjectID + "/" + credential.GooglePubSubSubscription + acknowledged := make([]string, 0, len(messages)) + + for _, received := range messages { + notification, raw, decodeErr := googleplay.DecodeNotification(received.Message) + if decodeErr != nil { + // A message that is not a developer notification cannot be attributed + // to an Application. It is acknowledged so it stops being redelivered + // forever, and the counter is the operator's signal. + s.intakeRejected.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderGooglePlay), + attribute.String("reason", "malformed_notification"))) + acknowledged = append(acknowledged, received.AckID) + continue + } + + // The verified packageName must be inside this credential's Application + // scope. Without that check a notification for any package reaching this + // subscription would be attributed to this tenant. + applicationID, _, appErr := s.repository.ApplicationForIdentifier(ctx, identity.CredentialID, notification.PackageName) + mismatch := appErr != nil || applicationID == "" + + input := RawInput{ + ProjectID: identity.ProjectID, + OrganizationID: identity.OrganizationID, + EnvironmentID: identity.EnvironmentID, + EnvironmentMode: identity.EnvironmentMode, + ApplicationID: applicationID, + CredentialID: identity.CredentialID, + Provider: ProviderGooglePlay, + Source: SourceGoogleRTDN, + SourceAuthority: AuthorityStoreNotification, + ProviderEventID: received.Message.MessageID, + ContentDigest: ContentDigest(raw), + AuthenticationResult: AuthVerifiedTransport, + StoreEnvironment: identity.StoreEnvironment, + IngestionStatus: IngestAccepted, + CorrelationID: "rtdn:" + received.Message.MessageID, + ReceivedAt: now, + ExpiresAt: now.Add(s.retention), + } + input.IdempotencyKey = GoogleRTDNKey(subscription, received.Message.MessageID, input.ContentDigest) + input.NotificationKind, input.TransactionReferenceDigest = googleNotificationFacts(notification) + if when, ok := parseRFC3339(received.Message.PublishTime); ok { + input.ProviderOccurredAt = &when + } + if mismatch { + input.IngestionStatus = IngestQuarantined + } + if err := s.sealBody(&input, raw); err != nil { + return len(acknowledged) > 0, err + } + + result, persistErr := s.repository.PersistRawInput(ctx, input, !mismatch && notification.TestNotification == nil, now) + if persistErr != nil { + // Not acknowledged: Google will redeliver, which is exactly what a + // storage failure should cause. + break + } + s.observeIntake(ctx, ProviderGooglePlay, result) + acknowledged = append(acknowledged, received.AckID) + } + + if len(acknowledged) > 0 { + if err := s.google.Acknowledge(ctx, account, credential.GooglePubSubProjectID, credential.GooglePubSubSubscription, acknowledged); err != nil { + // The inputs are already durable; a failed acknowledge only means + // redelivery, and redelivery deduplicates. + return true, nil + } + } + return len(acknowledged) > 0, nil +} + +// googleNotificationFacts extracts the safe classification and the attribution +// digest. The purchase token itself never leaves this function. +func googleNotificationFacts(notification googleplay.DeveloperNotification) (string, []byte) { + switch { + case notification.SubscriptionNotification != nil: + return "subscription_" + itoa(notification.SubscriptionNotification.NotificationType), + TokenDigest(notification.SubscriptionNotification.PurchaseToken) + case notification.OneTimeProductNotification != nil: + return "one_time_" + itoa(notification.OneTimeProductNotification.NotificationType), + TokenDigest(notification.OneTimeProductNotification.PurchaseToken) + case notification.VoidedPurchaseNotification != nil: + return "voided_purchase", TokenDigest(notification.VoidedPurchaseNotification.PurchaseToken) + case notification.TestNotification != nil: + return "test", nil + default: + return "unclassified", nil + } +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + digits := "" + negative := value < 0 + if negative { + value = -value + } + for value > 0 { + digits = string(rune('0'+value%10)) + digits + value /= 10 + } + if negative { + return "-" + digits + } + return digits +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +// ProcessNextReconciliation runs one bounded step of one reconciliation run. +// +// A step is bounded rather than a whole window so the run stays restart-safe: +// the cursor is committed after each page, and a worker that dies mid-run +// resumes from the last committed cursor instead of re-scanning from the start. +func (s *Service) ProcessNextReconciliation(ctx context.Context, workerID string) (bool, error) { + now := s.now() + run, leased, err := s.repository.LeaseReconciliationRun(ctx, workerID, now, now.Add(operationLease)) + if err != nil { + return false, safeFailure(err, "billing_reconciliation_lease_failed") + } + if !leased { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: run.ID, JobKind: "billing_reconciliation", + ProjectID: run.ProjectID, EnvironmentID: run.EnvironmentID, ResourceID: run.CredentialID, + }) + // A disabled Project runs no reconciliation: it would call the provider and + // append facts for a Project that asked to record nothing. + if !s.billingEnabled(ctx, run.ProjectID) { + return true, s.repository.CompleteReconciliationRun(ctx, run, "failed", "billing_disabled", s.now()) + } + + ctx, span := s.tracer.Start(ctx, "billing.reconcile.run") + defer span.End() + + switch run.Strategy { + case "apple_notification_history": + return true, s.reconcileAppleNotifications(ctx, run) + case "google_token_requery": + return true, s.reconcileGoogleTokens(ctx, run) + default: + return true, s.repository.CompleteReconciliationRun(ctx, run, "failed", "unsupported_strategy", s.now()) + } +} + +// reconcileAppleNotifications recovers notifications Apple could not deliver. +// +// Apple retries a failed V2 notification five times and only in production, so +// a Mosaic outage longer than that window loses notifications permanently +// unless they are pulled back from notification history. This is that pull. +// Everything it discovers re-enters the normal pipeline, so the same +// idempotency key that deduplicates a live delivery deduplicates a recovered +// one. +func (s *Service) reconcileAppleNotifications(ctx context.Context, run ReconciliationRun) error { + // Team-scoped: Get Notification History answers for the whole team, and the + // discovered notifications name their own Applications. + apple, _, err := s.appleCredential(ctx, RawInput{ + ProjectID: run.ProjectID, CredentialID: run.CredentialID, Provider: ProviderAppStore, + }, scopedToTeam) + if err != nil { + return s.repository.CompleteReconciliationRun(ctx, run, "failed", "credential_unusable", s.now()) + } + + page, err := s.apple.NotificationHistory(ctx, apple, appstoreserver.NotificationHistoryRequest{ + StartDate: run.WindowStart.UnixMilli(), + EndDate: run.WindowEnd.UnixMilli(), + OnlyFailures: true, + }, run.cursorToken()) + s.providerRequests.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderAppStore), + attribute.String("endpoint", "notification_history"), + attribute.Bool("failed", err != nil))) + if err != nil { + classification := Classify(err, s.now()) + if classification.Retryable { + // Leave the run queued: the cursor is unchanged, so the retry resumes + // exactly where this attempt started. + return s.repository.UpdateReconciliationProgress(ctx, run, run.cursorToken(), run.Cursor, s.now()) + } + return s.repository.CompleteReconciliationRun(ctx, run, "failed", classification.Diagnostic, s.now()) + } + + // The Environment's mode is read from the Environment, not derived from the + // Store Environment. Deriving it produced only "production" or + // "development", so every discovery in a staging Environment failed the + // composite FK onto environments(id, project_id, mode), incremented + // FailureCount silently, and left the run reporting `partial` with no + // diagnostic — the recovery path failing in exactly the outage it exists to + // repair. The intake path always read it from the credential row; this one + // now reads it from the same source of truth. + environmentMode, organizationID, scopeErr := s.repository.EnvironmentScope(ctx, run.ProjectID, run.EnvironmentID) + if scopeErr != nil { + return s.repository.CompleteReconciliationRun(ctx, run, "failed", "environment_unresolvable", s.now()) + } + + now := s.now() + for _, item := range page.NotificationHistory { + run.ExaminedCount++ + notification, decodeErr := s.verifier.DecodeNotification(item.SignedPayload) + if decodeErr != nil { + run.FailureCount++ + continue + } + applicationID := "" + storeEnvironment := StoreUnclassified + if notification.Data != nil { + applicationID, _, _ = s.repository.ApplicationForIdentifier(ctx, run.CredentialID, notification.Data.BundleID) + storeEnvironment = normalizeAppleEnvironment(notification.Data.Environment) + } + body := []byte(`{"signedPayload":"` + item.SignedPayload + `"}`) + input := RawInput{ + ProjectID: run.ProjectID, EnvironmentID: run.EnvironmentID, + ApplicationID: applicationID, CredentialID: run.CredentialID, + Provider: ProviderAppStore, Source: SourceAppleNotificationHistory, + SourceAuthority: AuthorityStoreReconciliation, + ProviderEventID: notification.NotificationUUID, + // The same key a live delivery would have produced: a recovered + // notification collapses onto the live one rather than duplicating it. + IdempotencyKey: AppleNotificationKey(notification.NotificationUUID), + ContentDigest: ContentDigest(body), + AuthenticationResult: AuthVerifiedSignature, + StoreEnvironment: storeEnvironment, + NotificationKind: boundedCode(notification.NotificationType), + NotificationSubtype: boundedCode(notification.Subtype), + IngestionStatus: IngestAccepted, + CorrelationID: "reconcile:" + run.ID, + ReceivedAt: now, + ExpiresAt: now.Add(s.retention), + } + input.OrganizationID = organizationID + input.EnvironmentMode = environmentMode + if err := s.sealBody(&input, body); err != nil { + run.FailureCount++ + continue + } + result, persistErr := s.repository.PersistRawInput(ctx, input, true, now) + switch { + case persistErr != nil: + run.FailureCount++ + case result.Conflicted: + // The same notification UUID arrived carrying different content than + // the copy already on record. That is contradiction, not discovery. + run.ConflictCount++ + case result.Status == IngestDuplicate: + run.DuplicateCount++ + default: + run.DiscoveredCount++ + } + } + + if page.HasMore && page.PaginationToken != "" { + return s.repository.UpdateReconciliationProgress(ctx, run, page.PaginationToken, run.Cursor, s.now()) + } + return s.repository.CompleteReconciliationRun(ctx, run, reconciliationStatus(run), "", s.now()) +} + +// reconciliationStatus reports partial when anything went wrong. A conflict is +// not a failure of the run — the run did its job by finding it — but it must +// not read as a clean sweep either. +func reconciliationStatus(run ReconciliationRun) string { + if run.FailureCount > 0 || run.ConflictCount > 0 { + return "partial" + } + return "completed" +} + +// reconcileGoogleTokens re-queries known purchase tokens. +// +// Google offers no notification-history equivalent, so reconciliation is +// forward polling rather than replay: the tokens Mosaic already knows about are +// re-read from the Play API, which detects state Mosaic missed while it was +// unavailable. +func (s *Service) reconcileGoogleTokens(ctx context.Context, run ReconciliationRun) error { + // The candidate set is bounded by the run's window and narrowed twice. The + // provider filter keeps Apple notifications — which this strategy cannot + // re-query — out of the counts. The source filter keeps observations out: + // an observation carries a token digest, and a digest cannot be reversed + // into the token the Play API needs, so including them would make every run + // report `partial` and leave an alarm that never clears. + // One bounded page per pass, resumed from the committed cursor. A single + // unbounded scan would either stall the worker on a large window or — as it + // previously did — examine one batch and report the whole window complete. + inputs, next, err := s.repository.ReplayInputs(ctx, ReplayJob{ + ProjectID: run.ProjectID, EnvironmentID: run.EnvironmentID, + WindowStart: &run.WindowStart, WindowEnd: &run.WindowEnd, + }, InputFilter{ + Provider: ProviderGooglePlay, + Sources: []string{SourceGoogleRTDN, SourceGoogleTokenRequery}, + }, run.Cursor, reconciliationPageSize) + if err != nil { + return s.repository.CompleteReconciliationRun(ctx, run, "failed", "candidate_scan_failed", s.now()) + } + for _, input := range inputs { + run.ExaminedCount++ + // Each candidate is genuinely re-read from the Play API. Google offers no + // notification-history equivalent, so reconciliation here is forward + // polling: the authoritative state is fetched again, and a state Mosaic + // missed while it was unavailable shows up as a new fact digest. + result, revalidateErr := s.revalidate(ctx, "reconcile:"+run.ID, input) + switch { + case errors.Is(revalidateErr, ErrValidationBusy): + // Another worker holds the lease. The cursor does not advance past + // this input, so the next pass picks it up rather than skipping it. + run.ExaminedCount-- + next = run.Cursor + return s.repository.UpdateReconciliationProgress(ctx, run, run.cursorToken(), next, s.now()) + case revalidateErr != nil: + run.FailureCount++ + case result.Outcome != OutcomeValidated && result.Outcome != OutcomeRecordedNoFact: + run.FailureCount++ + case result.Conflicted: + // The provider's current answer contradicts a fact already on + // record for this transaction. That is the "conflicting state" half + // of the Gate 9A criterion, and it is not the same thing as + // learning something new. Both facts stand — the ledger is + // append-only and nothing is rewritten — so the quarantine is the + // operator-visible diagnostic over the contradiction rather than a + // resolution of it. + run.ConflictCount++ + if quarantineErr := s.repository.OpenQuarantine(ctx, run.ProjectID, run.EnvironmentID, QuarantineWrite{ + RawInputID: input.ID, ApplicationID: input.ApplicationID, Provider: input.Provider, + ReasonCode: QuarantineReplayConflict, Severity: "warning", + Scopes: []string{"reconciliation"}, + DiagnosticCode: "reconciliation_contradicts_recorded_fact", + OccurredAt: s.now(), + }); quarantineErr != nil { + run.FailureCount++ + } + case result.Digest != "" && !result.Existing: + run.DiscoveredCount++ + default: + run.DuplicateCount++ + } + } + + // A short page means the window is exhausted. Anything else commits the + // cursor and comes back, so `completed` is only ever reported over a scan + // that actually reached the end. + if len(inputs) == reconciliationPageSize { + return s.repository.UpdateReconciliationProgress(ctx, run, run.cursorToken(), next, s.now()) + } + return s.repository.CompleteReconciliationRun(ctx, run, reconciliationStatus(run), "", s.now()) +} + +// cursorToken exposes the persisted pagination cursor. +func (r ReconciliationRun) cursorToken() string { return r.CursorToken } + +// --------------------------------------------------------------------------- +// Replay +// --------------------------------------------------------------------------- + +// ProcessNextReplay re-runs accepted inputs. +// +// Replay never creates a Raw Billing Input and never edits an attempt or a +// fact. It appends new Validation Attempts against the existing inputs; an +// unchanged outcome recomputes the same fact digest and writes nothing, a +// changed outcome appends a new fact beside the old one, and the two are shown +// side by side rather than one replacing the other. +func (s *Service) ProcessNextReplay(ctx context.Context, workerID string) (bool, error) { + now := s.now() + job, leased, err := s.repository.LeaseReplayJob(ctx, workerID, now, now.Add(operationLease)) + if err != nil { + return false, safeFailure(err, "billing_replay_lease_failed") + } + if !leased { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: "billing_replay", + ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, ResourceID: job.RawInputID, + }) + if !s.billingEnabled(ctx, job.ProjectID) { + return true, s.repository.CompleteReplayJob(ctx, job, "", "billing_disabled", s.now()) + } + + ctx, span := s.tracer.Start(ctx, "billing.replay.run") + defer span.End() + + // The zero filter: replaying a window deliberately covers every input in it, + // unlike a provider-specific reconciliation. One bounded page per pass, + // resumed from the committed cursor. + inputs, next, err := s.repository.ReplayInputs(ctx, job, InputFilter{}, job.Cursor, replayBatchSize) + if err != nil { + return true, s.repository.CompleteReplayJob(ctx, job, "", "input_scan_failed", s.now()) + } + for _, input := range inputs { + job.ExaminedCount++ + // The replay runs the validation pipeline; it does not reimplement any of + // it, so determinism still lives in exactly one place. + result, revalidateErr := s.revalidate(ctx, "replay:"+job.ID, input) + switch { + case errors.Is(revalidateErr, ErrValidationBusy): + // Do not advance past an input another worker is validating. + job.ExaminedCount-- + return true, s.repository.UpdateReplayProgress(ctx, job, job.Cursor, s.now()) + case revalidateErr != nil: + job.ConflictCount++ + case result.Outcome != OutcomeValidated && result.Outcome != OutcomeRecordedNoFact: + // A quarantine, a permanent failure, or a provider outage means the + // replay could not confirm the earlier answer. Reporting that as + // "unchanged" would be a claim the run did not earn. + job.ConflictCount++ + case result.Digest == "" || result.Existing: + job.UnchangedCount++ + default: + job.NewFactCount++ + } + } + + // A full page means there is more window to walk. Committing the cursor and + // returning the job to the queue is what makes a four-hundred-input replay + // actually cover four hundred inputs instead of the first twenty-five. + if len(inputs) == replayBatchSize { + return true, s.repository.UpdateReplayProgress(ctx, job, next, s.now()) + } + + comparison := "identical" + switch { + case job.ConflictCount > 0: + comparison = "conflicting" + case job.NewFactCount > 0: + comparison = "new_facts" + } + return true, s.repository.CompleteReplayJob(ctx, job, comparison, "", s.now()) +} + +// --------------------------------------------------------------------------- +// Retention +// --------------------------------------------------------------------------- + +// ProcessRetention removes raw bodies past their retention window. +// +// This is the only path that deletes a Raw Billing Input body. Normalized facts +// are retained indefinitely, so the ledger stays complete after the sensitive +// payload behind it is gone; replay after expiry runs from facts and is +// labelled as such. +func (s *Service) ProcessRetention(ctx context.Context, workerID string) (bool, error) { + removed, err := s.repository.ExpireRawInputBodies(ctx, s.now(), 500) + if err != nil { + return false, safeFailure(err, "billing_retention_failed") + } + return removed > 0, nil +} + +// --------------------------------------------------------------------------- +// Operator reads and recovery actions +// --------------------------------------------------------------------------- + +func (s *Service) ListFacts(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[TransactionFact], error) { + if actor.ID == "" { + return Page[TransactionFact]{}, ErrUnauthenticated + } + return s.repository.ListFacts(ctx, actor, projectID, environmentID, options) +} + +func (s *Service) ListAttempts(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[ValidationAttempt], error) { + if actor.ID == "" { + return Page[ValidationAttempt]{}, ErrUnauthenticated + } + return s.repository.ListAttempts(ctx, actor, projectID, environmentID, options) +} + +func (s *Service) ListLedger(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[LedgerEntry], error) { + if actor.ID == "" { + return Page[LedgerEntry]{}, ErrUnauthenticated + } + return s.repository.ListLedger(ctx, actor, projectID, environmentID, options) +} + +func (s *Service) ListQuarantine(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[QuarantineRecord], error) { + if actor.ID == "" { + return Page[QuarantineRecord]{}, ErrUnauthenticated + } + return s.repository.ListQuarantine(ctx, actor, projectID, environmentID, options) +} + +func (s *Service) Quarantine(ctx context.Context, actor Actor, projectID, recordID string) (QuarantineRecord, error) { + if actor.ID == "" { + return QuarantineRecord{}, ErrUnauthenticated + } + return s.repository.Quarantine(ctx, actor, projectID, recordID) +} + +// RetryQuarantine re-queues a quarantined input for validation. +// +// This is the only recovery action that can lead to a Transaction Fact, and it +// leads there only by asking the store again. There is deliberately no action +// that marks a quarantined input valid: an operator can repair a mapping, or +// re-run validation, but cannot assert an outcome the store never confirmed. +func (s *Service) RetryQuarantine(ctx context.Context, actor Actor, projectID, recordID string) (QuarantineRecord, error) { + if actor.ID == "" { + return QuarantineRecord{}, ErrUnauthenticated + } + return s.repository.RequeueValidation(ctx, actor, projectID, recordID, s.now()) +} + +// CloseQuarantineSuperseded closes a record that a later record replaced. It +// produces no fact and asserts nothing about authenticity. +func (s *Service) CloseQuarantineSuperseded(ctx context.Context, actor Actor, projectID, recordID, supersededBy string) (QuarantineRecord, error) { + if actor.ID == "" { + return QuarantineRecord{}, ErrUnauthenticated + } + return s.repository.CloseQuarantineSuperseded(ctx, actor, projectID, recordID, supersededBy, s.now()) +} + +// CreateReconciliation queues an operator-triggered reconciliation. +func (s *Service) CreateReconciliation(ctx context.Context, actor Actor, run ReconciliationRun) (ReconciliationRun, error) { + if actor.ID == "" { + return ReconciliationRun{}, ErrUnauthenticated + } + if !run.WindowEnd.After(run.WindowStart) { + return ReconciliationRun{}, ErrInvalid + } + // Apple retains 180 days of production notification history and 30 days of + // sandbox history, so a window wider than that cannot be satisfied and is + // rejected rather than silently truncated. + if run.WindowEnd.Sub(run.WindowStart) > 180*24*time.Hour { + return ReconciliationRun{}, ErrInvalid + } + id, err := s.newID("brr") + if err != nil { + return ReconciliationRun{}, safeFailure(err, "identifier_generation_failed") + } + run.ID = id + run.Trigger = "manual" + return s.repository.CreateReconciliationRun(ctx, actor, run, s.now()) +} + +func (s *Service) ListReconciliationRuns(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[ReconciliationRun], error) { + if actor.ID == "" { + return Page[ReconciliationRun]{}, ErrUnauthenticated + } + return s.repository.ListReconciliationRuns(ctx, actor, projectID, environmentID, options) +} + +// CreateReplay queues a replay or revalidation. +func (s *Service) CreateReplay(ctx context.Context, actor Actor, job ReplayJob) (ReplayJob, error) { + if actor.ID == "" { + return ReplayJob{}, ErrUnauthenticated + } + if job.RawInputID == "" && (job.WindowStart == nil || job.WindowEnd == nil) { + return ReplayJob{}, ErrInvalid + } + id, err := s.newID("brp") + if err != nil { + return ReplayJob{}, safeFailure(err, "identifier_generation_failed") + } + job.ID = id + if job.ValidatorVersion <= 0 { + job.ValidatorVersion = ValidatorVersion + } + return s.repository.CreateReplayJob(ctx, actor, job, s.now()) +} + +func (s *Service) ListReplayJobs(ctx context.Context, actor Actor, projectID, environmentID string, options ListOptions) (Page[ReplayJob], error) { + if actor.ID == "" { + return Page[ReplayJob]{}, ErrUnauthenticated + } + return s.repository.ListReplayJobs(ctx, actor, projectID, environmentID, options) +} + +func (s *Service) Health(ctx context.Context, actor Actor, projectID, environmentID string) (Health, error) { + if actor.ID == "" { + return Health{}, ErrUnauthenticated + } + return s.repository.Health(ctx, actor, projectID, environmentID) +} diff --git a/apps/api/internal/billing/service_worker.go b/apps/api/internal/billing/service_worker.go new file mode 100644 index 00000000..c1028dec --- /dev/null +++ b/apps/api/internal/billing/service_worker.go @@ -0,0 +1,1068 @@ +package billing + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "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/googleplay" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// validationLease bounds how long one worker may hold a validation job. +const validationLease = 2 * time.Minute + +// ProcessNextValidation leases and runs one validation job. It matches the +// (processed, error) contract every other Mosaic job family uses so the worker +// loop treats billing exactly like analytics and Experiment scheduling. +func (s *Service) ProcessNextValidation(ctx context.Context, workerID string) (bool, error) { + now := s.now() + job, leased, err := s.repository.LeaseValidationJob(ctx, workerID, now, now.Add(validationLease)) + if err != nil { + return false, safeFailure(err, "billing_lease_failed") + } + if !leased { + return false, nil + } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: "billing_validation", + ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, ResourceID: job.RawInputID, + }) + + // Defense in depth: a disabled Project makes no provider calls and records + // no facts. The job is parked rather than failed — disabling is reversible, + // and a failed job would need an operator action to recover work that only + // ever needed to wait. + if !s.billingEnabled(ctx, job.ProjectID) { + return true, s.repository.ParkValidationJob(ctx, job, "billing_disabled", s.now()) + } + + ctx, span := s.tracer.Start(ctx, "billing.validate."+job.Provider) + defer span.End() + + started := s.now() + outcome := s.runValidation(ctx, job, started) + s.validationLatency.Record(ctx, float64(outcome.Attempt.LatencyMs), metric.WithAttributes( + attribute.String("provider", job.Provider))) + s.validationOutcome.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", job.Provider), + attribute.String("outcome", outcome.Attempt.Outcome))) + + if err := s.repository.CompleteAttempt(ctx, job, outcome, s.now()); err != nil { + return true, safeFailure(err, "billing_attempt_write_failed") + } + return true, nil +} + +// 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. +func (s *Service) runValidation(ctx context.Context, job ValidationJob, started time.Time) AttemptOutcome { + attemptNumber, err := s.repository.NextAttemptNumber(ctx, job.RawInputID) + if err != nil || attemptNumber < 1 { + attemptNumber = job.AttemptCount + 1 + } + attemptID, _ := s.newID("bva") + + input, err := s.repository.RawInput(ctx, job.ProjectID, job.RawInputID) + if err != nil { + return s.failedAttempt(job, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "raw_input_unavailable"), StoreUnclassified, "") + } + + body, bodyAvailable := s.openBody(input) + + switch { + case input.Provider == ProviderAppStore: + return s.validateApple(ctx, job, input, body, bodyAvailable, attemptID, attemptNumber, started) + case input.Provider == ProviderGooglePlay: + return s.validateGoogle(ctx, job, input, body, bodyAvailable, attemptID, attemptNumber, started) + default: + return s.failedAttempt(job, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "unsupported_provider"), input.StoreEnvironment, input.CredentialID) + } +} + +// openBody decrypts a raw body. A body that has aged out of retention is not an +// error: replay after expiry runs from normalized facts and is labelled as +// such, which is why the caller is told availability rather than handed a +// failure. +func (s *Service) openBody(input RawInput) ([]byte, bool) { + if input.BodyState != "stored" || input.Envelope == nil || s.cipher == nil { + return nil, false + } + plaintext, err := s.cipher.DecryptSubject(providercredential.Envelope{ + Version: input.Envelope.Version, Algorithm: input.Envelope.Algorithm, KeyID: input.Envelope.KeyID, + Nonce: input.Envelope.Nonce, Ciphertext: input.Envelope.Ciphertext, + CredentialClass: ClassBillingRawPayload, Fingerprint: input.Envelope.Fingerprint, + }, providercredential.SubjectScope{ + OrganizationID: input.OrganizationID, + ProjectID: input.ProjectID, + SubjectKind: providercredential.SubjectBillingRawInput, + SubjectID: input.ID, + CredentialClass: ClassBillingRawPayload, + }) + if err != nil { + return nil, false + } + return plaintext, true +} + +// --------------------------------------------------------------------------- +// Apple validation +// --------------------------------------------------------------------------- + +func (s *Service) validateApple(ctx context.Context, job ValidationJob, input RawInput, body []byte, bodyAvailable bool, attemptID string, attemptNumber int, started time.Time) AttemptOutcome { + credential, credentialID, err := s.appleCredential(ctx, input, scopedToInput) + // The resolved credential is stamped on the input before any early return, + // so an attempt recorded for a failure still names the credential the + // pipeline was trying to use. + input.CredentialID = credentialID + if err != nil { + reason, diagnostic := credentialFailure(err) + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryConfiguration, diagnostic), reason, "error") + } + + transactionID := "" + var transaction appstorejws.TransactionPayload + var renewal *appstorejws.RenewalPayload + + // A notification carries a signed transaction; an observation carries only a + // reference. Either way the App Store Server API is the authority and the + // notification is a trigger, so the signed payload is used to learn *which* + // transaction to ask about rather than as the answer itself. + notificationSource := input.Source == SourceAppleNotification || input.Source == SourceAppleNotificationHistory + if bodyAvailable && notificationSource { + var envelope struct { + SignedPayload string `json:"signedPayload"` + } + if json.Unmarshal(body, &envelope) == nil && envelope.SignedPayload != "" { + notification, decodeErr := s.verifier.DecodeNotification(envelope.SignedPayload) + if decodeErr != nil { + s.signatureFailure.Add(ctx, 1, metric.WithAttributes(attribute.String("provider", ProviderAppStore))) + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategorySignature, string(appstorejws.ReasonOf(decodeErr))), QuarantineSignatureInvalid, "security") + } + if notification.NotificationType == "TEST" { + // A test notification proves the endpoint works and models + // nothing. Recording it without a fact keeps the ledger + // complete without inventing a transaction. + return s.recordedNoFactAttempt(job, input, attemptID, attemptNumber, started, "apple_test_notification") + } + if notification.Data != nil && notification.Data.SignedTransactionInfo != "" { + decoded, txErr := s.verifier.DecodeTransaction(notification.Data.SignedTransactionInfo) + if txErr != nil { + s.signatureFailure.Add(ctx, 1, metric.WithAttributes(attribute.String("provider", ProviderAppStore))) + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategorySignature, string(appstorejws.ReasonOf(txErr))), QuarantineSignatureInvalid, "security") + } + transactionID = decoded.TransactionID + } + if notification.Data != nil && notification.Data.SignedRenewalInfo != "" { + if decoded, renewalErr := s.verifier.DecodeRenewal(notification.Data.SignedRenewalInfo); renewalErr == nil { + renewal = &decoded + } + } + } + } + if transactionID == "" && bodyAvailable { + var observation struct { + Reference string `json:"reference"` + } + if json.Unmarshal(body, &observation) == nil { + transactionID = observation.Reference + } + } + if transactionID == "" { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "no_transaction_reference"), QuarantineMalformedReference, "error") + } + + // The authority call. + signed, err := s.apple.TransactionInfo(ctx, credential, transactionID) + s.providerRequests.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderAppStore), + attribute.String("endpoint", "transaction_info"), + attribute.Bool("failed", err != nil))) + if err != nil { + return s.classifiedFailure(job, input, attemptID, attemptNumber, started, err) + } + transaction, err = s.verifier.DecodeTransaction(signed) + if err != nil { + // A response that does not verify is a far more serious signal than a + // notification that does not verify: it means the transport or the host + // is not who it claims to be. + s.signatureFailure.Add(ctx, 1, metric.WithAttributes(attribute.String("provider", ProviderAppStore))) + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategorySignature, string(appstorejws.ReasonOf(err))), QuarantineSignatureInvalid, "security") + } + + // Bind the verified payload to the tenant that received it. + applicationID, platform, appErr := s.repository.ApplicationForIdentifier(ctx, input.CredentialID, transaction.BundleID) + if appErr != nil || applicationID == "" { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryResolution, "bundle_not_in_credential_scope"), QuarantineApplicationMismatch, "error") + } + storeEnvironment := normalizeAppleEnvironment(transaction.Environment) + if storeEnvironment == StoreUnclassified { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "unclassified_store_environment"), QuarantineStoreEnvironmentMismatch, "error") + } + if !storeEnvironmentMatchesMode(storeEnvironment, input.EnvironmentMode) { + // A sandbox transaction in a production Environment (or the reverse) is + // exactly the mixing the schema forbids; quarantining here means the + // insert never has to be attempted. + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "store_environment_mismatch"), QuarantineStoreEnvironmentMismatch, "error") + } + + transactionType, supported := appleTransactionType(transaction.Type) + if !supported { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "unsupported_transaction_type"), QuarantineUnsupportedTransaction, "warning") + } + + fact := TransactionFact{ + ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, + EnvironmentMode: input.EnvironmentMode, + ApplicationID: applicationID, + Provider: ProviderAppStore, + StoreEnvironment: storeEnvironment, + ProviderTransactionID: transaction.TransactionID, + ProviderOriginalTransactionID: transaction.OriginalTransactionID, + TransactionType: transactionType, + FactKind: appleFactKind(input.NotificationKind, transaction, renewal), + IsTestTransaction: storeEnvironment == StoreSandbox, + ProviderProductIdentifier: transaction.ProductID, + ValidatorVersion: ValidatorVersion, + FactVersion: 1, + SourceRawInputID: input.ID, + ValidationAttemptID: attemptID, + } + if transaction.OriginalTransactionID != "" { + fact.PurchaseChainDigest = AppleTransactionKey(storeEnvironment, transaction.OriginalTransactionID) + } + if when, ok := appstorejws.Millis(transaction.PurchaseDate); ok { + fact.OccurredAt = when + fact.PeriodStartAt = &when + } + if when, ok := appstorejws.Millis(transaction.ExpiresDate); ok { + fact.PeriodEndAt = &when + } + if when, ok := appstorejws.Millis(transaction.RevocationDate); ok { + fact.RevokedAt = &when + if transaction.RevocationReason != nil && *transaction.RevocationReason == 1 { + fact.RefundedAt = &when + } + } + if renewal != nil { + expected := renewal.AutoRenewStatus == 1 + fact.RenewalExpected = &expected + } + 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) +} + +// appleTransactionType maps Apple's product type onto the two types Phase 9A +// models. Consumables and non-renewing subscriptions are deliberately +// unsupported: modelling them would require quantity and consumption semantics +// this phase excludes, and silently coercing them into another type would put a +// wrong statement into an append-only ledger. +func appleTransactionType(value string) (string, bool) { + switch value { + case appstorejws.ProductTypeAutoRenewable: + return TypeAutoRenewableSubscription, true + case appstorejws.ProductTypeNonConsumable: + return TypeNonConsumable, true + default: + return "", false + } +} + +// appleFactKind classifies what happened. The notification type is the best +// signal when present; the transaction alone falls back to purchase semantics. +func appleFactKind(notificationType string, transaction appstorejws.TransactionPayload, renewal *appstorejws.RenewalPayload) string { + switch notificationType { + case "SUBSCRIBED": + return KindInitialPurchase + case "DID_RENEW": + return KindRenewal + case "EXPIRED": + return KindExpiration + case "REFUND": + return KindRefund + case "REVOKE": + return KindRevocation + case "ONE_TIME_CHARGE": + return KindOneTimePurchase + case "OFFER_REDEEMED": + return KindOfferRedeemed + case "DID_CHANGE_RENEWAL_PREF": + return KindPlanChange + case "DID_CHANGE_RENEWAL_STATUS": + if renewal != nil && renewal.AutoRenewStatus == 1 { + return KindAutoRenewEnabled + } + return KindAutoRenewDisabled + case "DID_FAIL_TO_RENEW": + return KindBillingRetryStart + case "GRACE_PERIOD_EXPIRED": + return KindExpiration + } + switch transaction.TransactionReason { + case "RENEWAL": + return KindRenewal + default: + if transaction.Type == appstorejws.ProductTypeNonConsumable { + return KindOneTimePurchase + } + return KindInitialPurchase + } +} + +// credentialIDFor resolves which Store Server Credential an input validates +// against. +// +// A notification carries its own credential, because the intake token or the +// Pub/Sub subscription identified it. An observation does not: its tenancy comes +// from an API key, which proves organization, Project, Environment, and +// Application — but says nothing about which store connection is configured. +// Requiring a credential on the input made every observation fail before its +// reference was read, so the credential is resolved here from the scope instead. +// Migration 00022's UNIQUE (project_id, provider, environment_id) is what makes +// that resolution unambiguous rather than a guess. +func (s *Service) credentialIDFor(ctx context.Context, input RawInput) (string, error) { + if input.CredentialID != "" { + return input.CredentialID, nil + } + identity, err := s.repository.CredentialForEnvironment(ctx, input.ProjectID, input.Provider, input.EnvironmentID) + if err != nil { + // Reported as missing rather than unusable: there is nothing to rotate. + return "", ErrCredentialMissing + } + return identity.CredentialID, nil +} + +// appleCredential returns the App Store Server API credential and the id of the +// Store Server Credential it came from, so the caller can stamp provenance on +// the attempt even when the input arrived without one. +// applicationScope says how strictly the per-request Apple `bid` must be bound. +// +// Apple requires a `bid` claim on every JWT, but not every call is about one +// Application. Get Transaction Info answers about a specific transaction and +// must carry that transaction's own bundle id; Get Notification History is +// team-scoped and any bundle id inside the credential's scope is a truthful +// claim. Conflating the two either sends the wrong `bid` for a specific +// transaction (the original M-1 defect) or refuses a team-scoped call that has +// no Application to name. +type applicationScope int + +const ( + // scopedToInput requires the input's own Application. Used for anything + // that answers about a specific transaction. + scopedToInput applicationScope = iota + // scopedToTeam accepts any Application in the credential's scope. Used for + // team-wide calls such as notification history and the credential test. + scopedToTeam +) + +func (s *Service) appleCredential(ctx context.Context, input RawInput, scope applicationScope) (appstoreserver.Credential, string, error) { + if s.apple == nil { + return appstoreserver.Credential{}, "", ErrCredentialUnusable + } + credentialID, err := s.credentialIDFor(ctx, input) + if err != nil { + return appstoreserver.Credential{}, "", err + } + input.CredentialID = credentialID + credential, envelope, class, organizationID, bundleID, err := s.repository.CredentialSecretFor(ctx, input.ProjectID, input.CredentialID) + if err != nil || credential.Status != "active" { + return appstoreserver.Credential{}, credentialID, ErrCredentialUnusable + } + plaintext, err := s.cipher.DecryptSubject(providercredential.Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, + CredentialClass: class, Fingerprint: envelope.Fingerprint, + }, providercredential.SubjectScope{ + OrganizationID: organizationID, + ProjectID: input.ProjectID, + SubjectKind: providercredential.SubjectStoreServerCredential, + SubjectID: input.CredentialID, + CredentialClass: class, + }) + if err != nil { + return appstoreserver.Credential{}, credentialID, ErrCredentialUnusable + } + defer zero(plaintext) + key, err := appstoreserver.ParsePrivateKey(plaintext) + if err != nil { + return appstoreserver.Credential{}, credentialID, ErrCredentialUnusable + } + // The `bid` must name the Application this input belongs to. The fallback + // from CredentialSecretFor is the credential's first scoped Application, + // which is correct only for a single-Application credential; for a team with + // two apps on one key it would send the wrong bundle id and Apple would + // answer 401. + // + // There is deliberately no fallback to the issuer id. An issuer UUID is not + // a bundle id under any circumstance, so sending one can only produce a + // request Apple rejects — and because a 401 is classified retryable, that + // rejection would be retried eight times before dead-lettering with a + // diagnostic pointing at the wrong cause. Failing closed here reports the + // real problem immediately. + // For an input-scoped call the `bid` must name this input's Application, and + // nothing else is an acceptable substitute. The value CredentialSecretFor + // returns is the credential's alphabetically-first scoped Application, which + // is correct only for a single-Application credential; falling back to it on + // a resolution error reintroduces the original defect on exactly the + // multi-Application credentials the model exists to support, and the 401 it + // eventually produces points the operator at credential rotation rather than + // at the missing Application scope. + // + // So the resolution error is propagated rather than absorbed. A transient + // read failure retries; a genuinely unscoped Application quarantines with a + // diagnostic that names the real problem. + if scope == scopedToInput { + if input.ApplicationID == "" { + return appstoreserver.Credential{}, credentialID, ErrApplicationNotScoped + } + resolved, resolveErr := s.repository.ProviderApplicationIdentifier(ctx, input.CredentialID, input.ApplicationID) + if resolveErr != nil || resolved == "" { + return appstoreserver.Credential{}, credentialID, ErrApplicationNotScoped + } + bundleID = resolved + } + // A team-scoped call keeps whichever scoped Application CredentialSecretFor + // supplied: the request is not about one Application, and any bundle id + // inside the credential's scope is a truthful claim. An empty one still + // fails closed, because a credential with no scoped Application cannot make + // any Apple call at all. + if bundleID == "" { + return appstoreserver.Credential{}, credentialID, ErrApplicationNotScoped + } + return appstoreserver.Credential{ + IssuerID: credential.AppleIssuerID, KeyID: credential.AppleKeyID, PrivateKey: key, + BundleID: bundleID, Sandbox: credential.StoreEnvironment == StoreSandbox, + }, credentialID, nil +} + +// --------------------------------------------------------------------------- +// Google validation +// --------------------------------------------------------------------------- + +func (s *Service) validateGoogle(ctx context.Context, job ValidationJob, input RawInput, body []byte, bodyAvailable bool, attemptID string, attemptNumber int, started time.Time) AttemptOutcome { + account, credential, scopedPackageName, err := s.googleCredential(ctx, input) + if credential.ID != "" { + input.CredentialID = credential.ID + } + if err != nil { + reason, diagnostic := credentialFailure(err) + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryConfiguration, diagnostic), reason, "error") + } + if !bodyAvailable { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "raw_body_unavailable"), QuarantineMalformedReference, "warning") + } + + packageName, purchaseToken, productID, orderID, subscription, ok := decodeGoogleWork(body) + if !ok { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "malformed_google_input"), QuarantineMalformedReference, "error") + } + if packageName == "" { + // Only an RTDN carries a packageName; an observation does not. + packageName = scopedPackageName + } + if packageName == "" { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryConfiguration, "no_package_in_credential_scope"), + QuarantineApplicationMismatch, "error") + } + + // An observation may carry only an order id. orders.get is the documented + // way to turn one into a purchase token, and it is the reason a client that + // knows nothing but an order id is still server-actionable. + if purchaseToken == "" && 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 { + return s.classifiedFailure(job, input, attemptID, attemptNumber, started, orderErr) + } + purchaseToken = order.PurchaseToken + if productID == "" && len(order.LineItems) == 1 { + productID = order.LineItems[0].ProductID + } + } + if purchaseToken == "" { + // A client observation carries only a digest by design, and a digest + // cannot be reversed into a token. Such an input waits for the RTDN that + // carries the real token rather than failing permanently. + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "purchase_token_unavailable"), QuarantineMalformedReference, "warning") + } + + applicationID, platform, appErr := s.repository.ApplicationForIdentifier(ctx, input.CredentialID, packageName) + if appErr != nil || applicationID == "" { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryResolution, "package_not_in_credential_scope"), QuarantineApplicationMismatch, "error") + } + + fact := TransactionFact{ + ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, + EnvironmentMode: input.EnvironmentMode, + ApplicationID: applicationID, + Provider: ProviderGooglePlay, + StoreEnvironment: credential.StoreEnvironment, + PurchaseChainDigest: TokenDigest(purchaseToken), + ValidatorVersion: ValidatorVersion, + FactVersion: 1, + SourceRawInputID: input.ID, + ValidationAttemptID: attemptID, + OccurredAt: started, + } + + if subscription { + purchase, err := s.google.GetSubscription(ctx, account, packageName, purchaseToken) + s.providerRequests.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderGooglePlay), + attribute.String("endpoint", "subscription_get"), + attribute.Bool("failed", err != nil))) + if err != nil { + return s.classifiedFailure(job, input, attemptID, attemptNumber, started, err) + } + if len(purchase.LineItems) == 0 { + 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 + } + 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 + } + } else { + if productID == "" { + return s.quarantineAttempt(job, input, attemptID, attemptNumber, started, + Permanent(CategoryInvalid, "product_identifier_unavailable"), QuarantineMalformedReference, "error") + } + purchase, err := s.google.GetProduct(ctx, account, packageName, productID, purchaseToken) + s.providerRequests.Add(ctx, 1, metric.WithAttributes( + attribute.String("provider", ProviderGooglePlay), + attribute.String("endpoint", "product_get"), + attribute.Bool("failed", err != nil))) + if err != nil { + return s.classifiedFailure(job, input, attemptID, attemptNumber, started, err) + } + if purchase.PurchaseState != 0 { + // 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 + } + } + + if fact.ProviderTransactionID == "" { + // Google does not always supply an order id (promotional purchases have + // none), and the token digest is the documented stable identity, so it + // stands in rather than leaving the column empty. + fact.ProviderTransactionID = "token:" + hexOf(fact.PurchaseChainDigest) + } + if !storeEnvironmentMatchesMode(fact.StoreEnvironment, input.EnvironmentMode) { + 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) +} + +// 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) { + var notification googleplay.DeveloperNotification + if err := json.Unmarshal(body, ¬ification); err == nil && notification.PackageName != "" { + packageName = notification.PackageName + switch { + case notification.SubscriptionNotification != nil: + return packageName, notification.SubscriptionNotification.PurchaseToken, + notification.SubscriptionNotification.SubscriptionID, "", true, true + case notification.OneTimeProductNotification != nil: + return packageName, notification.OneTimeProductNotification.PurchaseToken, + notification.OneTimeProductNotification.SKU, "", false, true + case notification.VoidedPurchaseNotification != nil: + return packageName, notification.VoidedPurchaseNotification.PurchaseToken, "", + notification.VoidedPurchaseNotification.OrderID, + notification.VoidedPurchaseNotification.ProductType == 1, true + case notification.TestNotification != nil: + return packageName, "", "", "", false, true + } + } + var observation struct { + Reference string `json:"reference"` + ReferenceKind string `json:"referenceKind"` + OrderReference string `json:"orderReference"` + PurchaseToken string `json:"purchaseToken"` + } + if err := json.Unmarshal(body, &observation); err != nil { + return "", "", "", "", false, false + } + return "", observation.PurchaseToken, "", observation.OrderReference, true, true +} + +func googleSubscriptionKind(state string) string { + switch state { + case "SUBSCRIPTION_STATE_ACTIVE": + return KindRenewal + case "SUBSCRIPTION_STATE_CANCELED": + return KindCancellationScheduled + case "SUBSCRIPTION_STATE_EXPIRED": + return KindExpiration + case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD": + return KindGracePeriodStart + case "SUBSCRIPTION_STATE_ON_HOLD": + return KindBillingRetryStart + case "SUBSCRIPTION_STATE_PAUSED": + return KindPaused + case "SUBSCRIPTION_STATE_PENDING": + return KindInitialPurchase + default: + return KindInitialPurchase + } +} + +// googleCredential returns the service account, the credential record, and the +// package name of the credential's first scoped Application. +// +// The package name is returned because an observation's body carries none — only +// an RTDN does — and the Play API requires one on every call. Falling back to +// the credential's scoped Application is the same convention the Apple path +// already uses for `bid`. The previous fallback was the Pub/Sub project id, +// which is not a package name and could never match an Application scope. +func (s *Service) googleCredential(ctx context.Context, input RawInput) (*googleplay.ServiceAccount, StoreServerCredential, string, error) { + if s.google == nil { + return nil, StoreServerCredential{}, "", ErrCredentialUnusable + } + credentialID, err := s.credentialIDFor(ctx, input) + if err != nil { + return nil, StoreServerCredential{}, "", err + } + input.CredentialID = credentialID + credential, envelope, class, organizationID, packageName, err := s.repository.CredentialSecretFor(ctx, input.ProjectID, input.CredentialID) + if err != nil || credential.Status != "active" { + return nil, StoreServerCredential{}, "", ErrCredentialUnusable + } + plaintext, err := s.cipher.DecryptSubject(providercredential.Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, + CredentialClass: class, Fingerprint: envelope.Fingerprint, + }, providercredential.SubjectScope{ + OrganizationID: organizationID, + ProjectID: input.ProjectID, + SubjectKind: providercredential.SubjectStoreServerCredential, + SubjectID: input.CredentialID, + CredentialClass: class, + }) + if err != nil { + return nil, credential, packageName, ErrCredentialUnusable + } + defer zero(plaintext) + account, err := googleplay.ParseServiceAccount(plaintext) + if err != nil { + return nil, credential, packageName, ErrCredentialUnusable + } + return account, credential, packageName, nil +} + +// --------------------------------------------------------------------------- +// Resolution and outcome assembly +// --------------------------------------------------------------------------- + +// resolveAndBuild runs Product resolution and assembles the attempt outcome. +// +// An unresolved Product still produces a fact, with resolution_state +// 'unresolved' and no Mosaic Product. Dropping it instead would make the ledger +// incomplete exactly where an operator most needs it: the store confirmed a +// real purchase of something Mosaic does not recognise, and that is evidence, +// not noise. The input is quarantined in parallel so the operator is asked to +// create the mapping. +func (s *Service) resolveAndBuild(ctx context.Context, job ValidationJob, input RawInput, fact TransactionFact, platform, attemptID string, attemptNumber int, started time.Time, storeEnvironment string) AttemptOutcome { + candidates, err := s.repository.MappingCandidates(ctx, input.EnvironmentID, fact.ApplicationID, platform, fact.Provider, fact.ProviderProductIdentifier) + if err != nil { + return s.failedAttempt(job, attemptID, attemptNumber, started, + Classification{Category: CategoryTransient, Retryable: true, Diagnostic: "mapping_lookup_failed"}, + storeEnvironment, input.CredentialID) + } + ids := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + ids = append(ids, candidate.ID) + } + successors, err := s.repository.MappingSuccessors(ctx, input.ProjectID, ids) + if err != nil { + successors = map[string]MappingCandidate{} + } + + resolution := Resolve(ResolutionInput{ + Provider: fact.Provider, + ProviderProductIdentifier: fact.ProviderProductIdentifier, + ProviderBasePlanIdentifier: fact.ProviderBasePlanIdentifier, + ProviderOfferIdentifier: fact.ProviderOfferIdentifier, + OccurredAt: fact.OccurredAt, + TransactionType: fact.TransactionType, + Candidates: candidates, + Successors: successors, + }) + + resolutionID, _ := s.newID("bpr") + record := &ResolutionRecord{ + ID: resolutionID, + ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, + ApplicationID: fact.ApplicationID, + ValidationAttemptID: attemptID, + RawInputID: input.ID, + Provider: fact.Provider, + ProviderProductIdentifier: fact.ProviderProductIdentifier, + ProviderBasePlanIdentifier: fact.ProviderBasePlanIdentifier, + ProviderOfferIdentifier: fact.ProviderOfferIdentifier, + Outcome: resolution.Outcome, + CandidateCount: resolution.CandidateCount, + DiagnosticCode: resolution.DiagnosticCode, + OccurredAt: fact.OccurredAt, + ResolvedAt: s.now(), + } + + if resolution.Outcome == ResolutionResolved { + record.ResolutionState = resolution.State + record.MosaicProductID = resolution.MosaicProductID + record.ProviderProductMappingID = resolution.MappingID + record.MatchedMappingID = resolution.MatchedMappingID + version := resolution.MappingVersion + record.MappingVersion = &version + + fact.ResolutionState = resolution.State + fact.MosaicProductID = resolution.MosaicProductID + fact.ProviderProductMappingID = resolution.MappingID + fact.ResolvedMappingVersion = &version + } else { + fact.ResolutionState = StateUnresolved + } + + factID, _ := s.newID("btf") + 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, + Outcome: OutcomeValidated, Retryable: false, + StoreEnvironment: storeEnvironment, + LatencyMs: int(completed.Sub(started).Milliseconds()), + CorrelationID: input.CorrelationID, + }, + Resolution: record, + Fact: &fact, + JobStatus: "completed", + } + + if reason, quarantines := QuarantineReasonFor(resolution.Outcome); quarantines { + outcome.Attempt.Outcome = OutcomeQuarantined + outcome.Attempt.FailureCategory = CategoryResolution + outcome.Attempt.DiagnosticCode = resolution.DiagnosticCode + outcome.Quarantine = &QuarantineWrite{ + RawInputID: input.ID, ApplicationID: fact.ApplicationID, Provider: fact.Provider, + ReasonCode: reason, Severity: "warning", + Scopes: []string{"provider_product_mapping"}, + DiagnosticCode: resolution.DiagnosticCode, OccurredAt: completed, + } + } + outcome.Ledger = s.ledgerFor(input, outcome) + return outcome +} + +func (s *Service) ledgerFor(input RawInput, outcome AttemptOutcome) []LedgerEntry { + now := s.now() + entries := make([]LedgerEntry, 0, 4) + add := func(entryType string, detail map[string]string) { + id, _ := s.newID("ble") + entries = append(entries, LedgerEntry{ + ID: id, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, + EntryType: entryType, RawInputID: input.ID, + ValidationAttemptID: outcome.Attempt.ID, + CorrelationID: input.CorrelationID, OccurredAt: now, Detail: detail, + }) + } + add(LedgerValidationStarted, nil) + switch outcome.Attempt.Outcome { + case OutcomeValidated: + add(LedgerValidationSucceeded, nil) + case OutcomeQuarantined: + add(LedgerValidationFailed, map[string]string{"diagnosticCode": outcome.Attempt.DiagnosticCode}) + default: + add(LedgerValidationFailed, map[string]string{"diagnosticCode": outcome.Attempt.DiagnosticCode}) + } + if outcome.Resolution != nil { + entryType := LedgerProductResolved + if outcome.Resolution.Outcome != ResolutionResolved { + entryType = LedgerProductResolutionFailed + } + add(entryType, map[string]string{"outcome": outcome.Resolution.Outcome}) + } + if outcome.Fact != nil { + add(LedgerFactRecorded, nil) + } + if outcome.Quarantine != nil { + add(LedgerInputQuarantined, map[string]string{"reasonCode": outcome.Quarantine.ReasonCode}) + } + return entries +} + +// --------------------------------------------------------------------------- +// Attempt shapes +// --------------------------------------------------------------------------- + +func (s *Service) classifiedFailure(job ValidationJob, input RawInput, attemptID string, attemptNumber int, started time.Time, err error) AttemptOutcome { + now := s.now() + classification := Classify(err, 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: now, + FailureCategory: classification.Category, + DiagnosticCode: classification.Diagnostic, + ProviderCode: classification.ProviderCode, + ProviderHTTPStatus: classification.HTTPStatus, + StoreEnvironment: input.StoreEnvironment, + LatencyMs: int(now.Sub(started).Milliseconds()), + CorrelationID: input.CorrelationID, + }, + } + exhausted := classification.ExhaustedFor(attemptNumber, job.MaxAttempts) + switch { + case classification.Retryable && !exhausted: + outcome.Attempt.Outcome = OutcomeRetryableFailure + outcome.Attempt.Retryable = true + outcome.NextAttemptAtSet(NextAttemptAt(now, attemptNumber, classification, s.jitter)) + outcome.JobStatus = "queued" + case classification.Retryable && exhausted: + // A retryable failure that has run out of attempts is a dead letter, not + // a silent drop: it becomes a quarantine record an operator can retry. + // An authentication failure reaches here after two attempts rather than + // eight, and quarantines under a reason that names the credential, so + // the operator is pointed at the thing they actually have to fix. + outcome.Attempt.Outcome = OutcomePermanentlyFailed + outcome.JobStatus = "failed" + reason := QuarantineValidationExhausted + scopes := []string(nil) + if classification.Category == CategoryAuth { + // Name the credential, not the input: a rejected assertion is a + // credential problem and the operator's next action is to check it. + reason = QuarantineCredentialRevoked + scopes = []string{"store_server_credential"} + } + outcome.Quarantine = &QuarantineWrite{ + RawInputID: input.ID, Provider: input.Provider, + ReasonCode: reason, Severity: "error", Scopes: scopes, + DiagnosticCode: classification.Diagnostic, OccurredAt: now, + } + default: + outcome.Attempt.Outcome = OutcomePermanentlyFailed + outcome.JobStatus = "failed" + outcome.Quarantine = &QuarantineWrite{ + RawInputID: input.ID, Provider: input.Provider, + ReasonCode: QuarantineProviderPermanentlyFailed, Severity: "error", + DiagnosticCode: classification.Diagnostic, OccurredAt: now, + } + } + outcome.Ledger = s.ledgerFor(input, outcome) + return outcome +} + +func (s *Service) quarantineAttempt(job ValidationJob, input RawInput, attemptID string, attemptNumber int, started time.Time, classification Classification, reason, severity string) AttemptOutcome { + now := 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: now, + Outcome: OutcomeQuarantined, Retryable: false, + FailureCategory: classification.Category, DiagnosticCode: classification.Diagnostic, + StoreEnvironment: input.StoreEnvironment, + LatencyMs: int(now.Sub(started).Milliseconds()), + CorrelationID: input.CorrelationID, + }, + Quarantine: &QuarantineWrite{ + RawInputID: input.ID, ApplicationID: input.ApplicationID, Provider: input.Provider, + ReasonCode: reason, Severity: severity, + DiagnosticCode: classification.Diagnostic, OccurredAt: now, + }, + JobStatus: "failed", + } + outcome.Ledger = s.ledgerFor(input, outcome) + return outcome +} + +func (s *Service) recordedNoFactAttempt(job ValidationJob, input RawInput, attemptID string, attemptNumber int, started time.Time, diagnostic string) AttemptOutcome { + now := 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: now, + Outcome: OutcomeRecordedNoFact, Retryable: false, + DiagnosticCode: diagnostic, + StoreEnvironment: input.StoreEnvironment, + LatencyMs: int(now.Sub(started).Milliseconds()), + CorrelationID: input.CorrelationID, + }, + JobStatus: "completed", + } + outcome.Ledger = s.ledgerFor(input, outcome) + return outcome +} + +func (s *Service) failedAttempt(job ValidationJob, attemptID string, attemptNumber int, started time.Time, classification Classification, storeEnvironment, credentialID string) AttemptOutcome { + now := s.now() + return AttemptOutcome{ + Attempt: ValidationAttempt{ + ID: attemptID, ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, + RawInputID: job.RawInputID, CredentialID: credentialID, + AttemptNumber: attemptNumber, ValidatorVersion: ValidatorVersion, + StartedAt: started, CompletedAt: now, + Outcome: OutcomePermanentlyFailed, Retryable: false, + FailureCategory: classification.Category, DiagnosticCode: classification.Diagnostic, + StoreEnvironment: storeEnvironment, + LatencyMs: int(now.Sub(started).Milliseconds()), + CorrelationID: "worker", + }, + JobStatus: "failed", + } +} + +// NextAttemptAtSet assigns the retry instant. It exists as a method so the +// zero value keeps its meaning ("no retry scheduled") at the call sites above. +func (o *AttemptOutcome) NextAttemptAtSet(at time.Time) { o.NextAvailableAt = at } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// credentialFailure separates "there is no connection" from "the connection is +// broken". They are different operator actions — connect a store versus rotate a +// secret — so they get different quarantine reasons and different diagnostics +// rather than being flattened into one misleading code. +func credentialFailure(err error) (reason, diagnostic string) { + switch { + case errors.Is(err, ErrCredentialMissing): + return QuarantineMissingCredential, "no_credential_for_environment" + case errors.Is(err, ErrApplicationNotScoped): + // A different operator action from a bad credential: scope the + // Application to the credential rather than replace the key. + return QuarantineApplicationMismatch, "application_not_scoped_to_credential" + default: + return QuarantineCredentialUnavailable, "credential_unusable" + } +} + +// storeEnvironmentMatchesMode enforces the sandbox/production separation the +// schema also enforces, so a mismatch becomes a quarantine record rather than a +// constraint violation. +func storeEnvironmentMatchesMode(storeEnvironment, environmentMode string) bool { + if environmentMode == "production" { + return storeEnvironment == StoreProduction + } + return storeEnvironment == StoreSandbox +} + +func parseRFC3339(value string) (time.Time, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return time.Time{}, false + } + parsed, err := time.Parse(time.RFC3339, trimmed) + if err != nil { + return time.Time{}, false + } + return parsed.UTC(), true +} + +func hexOf(value []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, len(value)*2) + for i, b := range value { + out[i*2] = digits[b>>4] + out[i*2+1] = digits[b&0x0f] + } + return string(out) +} + +// zero clears decrypted material as soon as it is no longer needed. +func zero(value []byte) { + for i := range value { + value[i] = 0 + } +} + +var _ = errors.Is diff --git a/apps/api/internal/platform/appstorejws/appstorejws.go b/apps/api/internal/platform/appstorejws/appstorejws.go new file mode 100644 index 00000000..da5e164a --- /dev/null +++ b/apps/api/internal/platform/appstorejws/appstorejws.go @@ -0,0 +1,334 @@ +// Package appstorejws verifies the JSON Web Signatures Apple attaches to App +// Store Server Notifications V2 and App Store Server API responses. +// +// This is Phase 9A's primary security boundary. Everything downstream — the +// Transaction Fact ledger, Product resolution, the operator dashboard — treats +// a verified payload as something Apple actually said, so a defect here is a +// forged-transaction defect. The package is therefore deliberately small, +// pure, and pessimistic: +// +// - The signing algorithm is pinned to ES256. `none`, HS*, and RS* are +// rejected before any key material is examined, because algorithm confusion +// is the standard way a JWS verifier is turned into a rubber stamp. +// - The trust anchor is the Apple Root CA - G3 certificate compiled into the +// binary. The system trust pool is never consulted and the root is never +// fetched at runtime, so a compromised or permissive host trust store +// cannot be used to mint a chain Mosaic will accept. +// - Certificate validity is checked at the payload's own signing time rather +// than at time.Now(), so a genuine historical notification replayed from +// Apple's notification history still verifies while a truly expired chain +// does not. +package appstorejws + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + _ "embed" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strings" + "time" +) + +// appleRootCAG3 is the trust anchor for every Apple JWS Mosaic verifies. It is +// the self-signed "Apple Root CA - G3" certificate published at +// https://www.apple.com/certificateauthority/ (SHA-256 fingerprint +// 63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79). +// +//go:embed roots/apple-root-ca-g3.pem +var appleRootCAG3 []byte + +// appleWWDROID is the Apple-assigned extension OID the App Store Server +// intermediate certificate carries. Requiring it means a leaf chained to the +// Apple root through some other Apple intermediate — a device certificate, for +// example — cannot be presented as a store signature. +var appleWWDROID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1} + +var ( + // ErrUnverified is returned for every verification failure. The specific + // reason is carried on the error for telemetry but the sentinel is + // deliberately single: callers must not branch on how a forgery failed. + ErrUnverified = errors.New("apple JWS payload could not be verified") +) + +// Reason is a stable, safe classification of a verification failure. It is +// suitable for metrics and operator display and never contains payload content. +type Reason string + +const ( + ReasonMalformed Reason = "malformed_jws" + ReasonAlgorithmRejected Reason = "algorithm_rejected" + ReasonChainMissing Reason = "certificate_chain_missing" + ReasonChainUntrusted Reason = "certificate_chain_untrusted" + ReasonIntermediateWrong Reason = "intermediate_not_app_store" + ReasonSignatureInvalid Reason = "signature_invalid" + ReasonSignedDateMissing Reason = "signed_date_missing" + ReasonSignedDateInFuture Reason = "signed_date_in_future" +) + +// VerificationError carries the safe reason behind a rejection. +type VerificationError struct { + Reason Reason +} + +func (e *VerificationError) Error() string { + return fmt.Sprintf("%s: %s", ErrUnverified.Error(), e.Reason) +} + +func (e *VerificationError) Is(target error) bool { return target == ErrUnverified } + +func reject(reason Reason) error { return &VerificationError{Reason: reason} } + +// ReasonOf extracts the classification from a verification error, or an empty +// Reason when err did not come from this package. +func ReasonOf(err error) Reason { + var verification *VerificationError + if errors.As(err, &verification) { + return verification.Reason + } + return "" +} + +// Verifier verifies Apple JWS payloads against the pinned root. +type Verifier struct { + root *x509.Certificate + // clockSkew bounds how far into the future a signedDate may sit before the + // payload is rejected as replayed-from-a-bad-clock. + clockSkew time.Duration + now func() time.Time +} + +// Option customizes a Verifier. Both options exist for tests and for operators +// with unusual clock discipline; neither can weaken the trust anchor. +type Option func(*Verifier) + +func WithClockSkew(skew time.Duration) Option { + return func(v *Verifier) { + if skew > 0 { + v.clockSkew = skew + } + } +} + +func WithClock(now func() time.Time) Option { + return func(v *Verifier) { + if now != nil { + v.now = now + } + } +} + +// WithRoot replaces the trust anchor. It exists so tests can verify a +// synthetic chain without weakening production, and is never called from +// production wiring. +func WithRoot(root *x509.Certificate) Option { + return func(v *Verifier) { + if root != nil { + v.root = root + } + } +} + +// NewVerifier builds a verifier over the embedded Apple root. It fails at +// construction if the embedded certificate is unusable, so a broken build is +// caught at startup rather than on the first notification. +func NewVerifier(options ...Option) (*Verifier, error) { + block, _ := pem.Decode(appleRootCAG3) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("embedded Apple root CA is not a PEM certificate") + } + root, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse embedded Apple root CA: %w", err) + } + verifier := &Verifier{root: root, clockSkew: 5 * time.Minute, now: func() time.Time { return time.Now().UTC() }} + for _, option := range options { + option(verifier) + } + return verifier, nil +} + +// Header is the decoded JWS protected header. +type Header struct { + Algorithm string `json:"alg"` + X5C []string `json:"x5c"` +} + +// Verify checks a compact JWS and returns the decoded payload bytes. +// +// signedDate is read from the payload itself (Apple stamps every signed object +// with one) and is the instant the certificate chain is evaluated at. +func (v *Verifier) Verify(compact string) ([]byte, time.Time, error) { + headerBytes, payloadBytes, signature, signingInput, err := split(compact) + if err != nil { + return nil, time.Time{}, err + } + + var header Header + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, time.Time{}, reject(ReasonMalformed) + } + // Pinned before anything else touches key material. + if header.Algorithm != "ES256" { + return nil, time.Time{}, reject(ReasonAlgorithmRejected) + } + // leaf, intermediate, root. + if len(header.X5C) < 3 { + return nil, time.Time{}, reject(ReasonChainMissing) + } + + signedDate, err := signedDateOf(payloadBytes) + if err != nil { + return nil, time.Time{}, err + } + if signedDate.After(v.now().Add(v.clockSkew)) { + return nil, time.Time{}, reject(ReasonSignedDateInFuture) + } + + leaf, err := v.verifyChain(header.X5C, signedDate) + if err != nil { + return nil, time.Time{}, err + } + + publicKey, ok := leaf.PublicKey.(*ecdsa.PublicKey) + if !ok || publicKey.Curve != elliptic.P256() { + return nil, time.Time{}, reject(ReasonAlgorithmRejected) + } + // ES256 signatures are the raw R||S pair, not the ASN.1 DER form + // ecdsa.VerifyASN1 expects, so the halves are converted explicitly. A + // length other than 64 is malformed rather than merely invalid. + if len(signature) != 64 { + return nil, time.Time{}, reject(ReasonSignatureInvalid) + } + digest := sha256.Sum256(signingInput) + r := new(big.Int).SetBytes(signature[:32]) + s := new(big.Int).SetBytes(signature[32:]) + if !ecdsa.Verify(publicKey, digest[:], r, s) { + return nil, time.Time{}, reject(ReasonSignatureInvalid) + } + return payloadBytes, signedDate, nil +} + +// verifyChain validates leaf -> intermediate -> pinned root at instant. +func (v *Verifier) verifyChain(encoded []string, instant time.Time) (*x509.Certificate, error) { + certificates := make([]*x509.Certificate, 0, len(encoded)) + for _, value := range encoded { + der, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, reject(ReasonMalformed) + } + certificate, err := x509.ParseCertificate(der) + if err != nil { + return nil, reject(ReasonMalformed) + } + certificates = append(certificates, certificate) + } + + // The chain Apple presents must terminate at the certificate compiled into + // this binary. Comparing the raw DER rather than the subject means a + // same-named root from a different issuer is not accepted. + presentedRoot := certificates[len(certificates)-1] + if !presentedRoot.Equal(v.root) { + return nil, reject(ReasonChainUntrusted) + } + + roots := x509.NewCertPool() + roots.AddCert(v.root) + intermediates := x509.NewCertPool() + for _, certificate := range certificates[1 : len(certificates)-1] { + intermediates.AddCert(certificate) + } + + leaf := certificates[0] + chains, err := leaf.Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + CurrentTime: instant, + // Apple's App Store leaf certificates carry no extended key usage that + // x509 recognises, so no EKU is required; the App Store extension check + // below is what constrains the chain to store signing. + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }) + if err != nil || len(chains) == 0 { + return nil, reject(ReasonChainUntrusted) + } + + // The root is excluded from the search. Apple's root does not carry the + // extension, so including it changed nothing today — but the rule this + // enforces is "an Apple-issued *store* certificate signed this", and a trust + // anchor is not evidence of that. Scoping the search to the leaf and its + // intermediates keeps the check meaning what it says even if a future root + // gained the OID. + if !hasAppStoreExtension(chains[0][:len(chains[0])-1]) { + return nil, reject(ReasonIntermediateWrong) + } + return leaf, nil +} + +// hasAppStoreExtension reports whether any certificate in the supplied slice +// carries Apple's App Store Server extension OID. Callers pass the chain with +// the trust anchor removed. +func hasAppStoreExtension(chain []*x509.Certificate) bool { + for _, certificate := range chain { + for _, extension := range certificate.Extensions { + if extension.Id.Equal(appleWWDROID) { + return true + } + } + for _, extension := range certificate.UnhandledCriticalExtensions { + if extension.Equal(appleWWDROID) { + return true + } + } + } + return false +} + +// signedDateOf reads the millisecond `signedDate` Apple stamps on every signed +// object. A payload without one cannot be anchored in time and is rejected +// rather than verified against the current clock. +func signedDateOf(payload []byte) (time.Time, error) { + var envelope struct { + SignedDate *int64 `json:"signedDate"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + return time.Time{}, reject(ReasonMalformed) + } + if envelope.SignedDate == nil || *envelope.SignedDate <= 0 { + return time.Time{}, reject(ReasonSignedDateMissing) + } + return time.UnixMilli(*envelope.SignedDate).UTC(), nil +} + +// split decomposes a compact JWS without allocating a parser dependency. +func split(compact string) (header, payload, signature, signingInput []byte, err error) { + compact = strings.TrimSpace(compact) + first := strings.IndexByte(compact, '.') + last := strings.LastIndexByte(compact, '.') + if first <= 0 || last <= first || last == len(compact)-1 { + return nil, nil, nil, nil, reject(ReasonMalformed) + } + if strings.Count(compact, ".") != 2 { + return nil, nil, nil, nil, reject(ReasonMalformed) + } + header, err = base64.RawURLEncoding.DecodeString(compact[:first]) + if err != nil { + return nil, nil, nil, nil, reject(ReasonMalformed) + } + payload, err = base64.RawURLEncoding.DecodeString(compact[first+1 : last]) + if err != nil { + return nil, nil, nil, nil, reject(ReasonMalformed) + } + signature, err = base64.RawURLEncoding.DecodeString(compact[last+1:]) + if err != nil { + return nil, nil, nil, nil, reject(ReasonMalformed) + } + return header, payload, signature, []byte(compact[:last]), nil +} diff --git a/apps/api/internal/platform/appstorejws/appstorejws_test.go b/apps/api/internal/platform/appstorejws/appstorejws_test.go new file mode 100644 index 00000000..b5a9410d --- /dev/null +++ b/apps/api/internal/platform/appstorejws/appstorejws_test.go @@ -0,0 +1,310 @@ +package appstorejws + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "math/big" + "testing" + "time" +) + +// The tests below protect the single highest-severity failure in Phase 9A: a +// forged notification producing a Transaction Fact. Every case is a way a real +// verifier gets weakened — algorithm confusion, an attacker-supplied trust +// anchor, a chain that omits the App Store extension, a tampered payload — and +// each one must be rejected before any field of the payload is believed. +// +// A synthetic three-certificate chain is used rather than a recorded Apple +// payload because Apple's real leaf certificates expire, which would make a +// recorded fixture fail on a date rather than on a defect. + +type testChain struct { + root *x509.Certificate + rootKey *ecdsa.PrivateKey + intermediate *x509.Certificate + leaf *x509.Certificate + leafKey *ecdsa.PrivateKey + encoded []string +} + +func newTestChain(t *testing.T, withAppStoreExtension bool) testChain { + t.Helper() + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test Root CA"}, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) + if err != nil { + t.Fatal(err) + } + root, err := x509.ParseCertificate(rootDER) + if err != nil { + t.Fatal(err) + } + + intermediateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + intermediateTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test Intermediate CA"}, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + if withAppStoreExtension { + intermediateTemplate.ExtraExtensions = []pkix.Extension{{Id: appleWWDROID, Value: []byte{0x05, 0x00}}} + } + intermediateDER, err := x509.CreateCertificate(rand.Reader, intermediateTemplate, root, &intermediateKey.PublicKey, rootKey) + if err != nil { + t.Fatal(err) + } + intermediate, err := x509.ParseCertificate(intermediateDER) + if err != nil { + t.Fatal(err) + } + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "Test Leaf"}, + NotBefore: time.Now().Add(-24 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(leafDER) + if err != nil { + t.Fatal(err) + } + + return testChain{ + root: root, rootKey: rootKey, intermediate: intermediate, + leaf: leaf, leafKey: leafKey, + encoded: []string{ + base64.StdEncoding.EncodeToString(leafDER), + base64.StdEncoding.EncodeToString(intermediateDER), + base64.StdEncoding.EncodeToString(rootDER), + }, + } +} + +// signJWS builds a compact JWS the way Apple does: ES256 over base64url header +// and payload, with the raw R||S signature form. +func signJWS(t *testing.T, chain testChain, algorithm string, payload map[string]any) string { + t.Helper() + header := map[string]any{"alg": algorithm, "x5c": chain.encoded} + headerBytes, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + + base64.RawURLEncoding.EncodeToString(payloadBytes) + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, chain.leafKey, digest[:]) + if err != nil { + t.Fatal(err) + } + signature := make([]byte, 64) + rBytes, sBytes := r.Bytes(), s.Bytes() + copy(signature[32-len(rBytes):32], rBytes) + copy(signature[64-len(sBytes):], sBytes) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func testPayload() map[string]any { + return map[string]any{ + "notificationType": "SUBSCRIBED", + "notificationUUID": "fixture-notification-uuid", + "version": "2.0", + "signedDate": time.Now().Add(-time.Minute).UnixMilli(), + } +} + +func verifierFor(t *testing.T, chain testChain) *Verifier { + t.Helper() + verifier, err := NewVerifier(WithRoot(chain.root)) + if err != nil { + t.Fatal(err) + } + return verifier +} + +// A well-formed chain terminating at the pinned root must verify; without this +// the rejection tests below would pass vacuously. +func TestVerifyAcceptsChainToPinnedRoot(t *testing.T) { + chain := newTestChain(t, true) + compact := signJWS(t, chain, "ES256", testPayload()) + + notification, err := verifierFor(t, chain).DecodeNotification(compact) + if err != nil { + t.Fatalf("expected a valid chain to verify, got %v", err) + } + if notification.NotificationUUID != "fixture-notification-uuid" { + t.Fatalf("unexpected notification uuid %q", notification.NotificationUUID) + } +} + +// Algorithm confusion is the standard way a JWS verifier is turned into a +// rubber stamp: `none` skips verification entirely, and an RS256 header can +// trick a verifier into treating a public key as an HMAC secret. Both must be +// rejected before any key material is examined. +func TestVerifyRejectsAlgorithmConfusion(t *testing.T) { + chain := newTestChain(t, true) + for _, algorithm := range []string{"none", "RS256", "HS256", "ES384", ""} { + compact := signJWS(t, chain, algorithm, testPayload()) + _, _, err := verifierFor(t, chain).Verify(compact) + if err == nil { + t.Fatalf("alg %q was accepted", algorithm) + } + if reason := ReasonOf(err); reason != ReasonAlgorithmRejected { + t.Fatalf("alg %q rejected for %q, want %q", algorithm, reason, ReasonAlgorithmRejected) + } + } +} + +// The chain the caller presents must terminate at the certificate compiled into +// the binary. An attacker who can mint a self-consistent chain would otherwise +// only need Mosaic to trust whatever root they attached. +func TestVerifyRejectsChainToAnotherRoot(t *testing.T) { + trusted := newTestChain(t, true) + attacker := newTestChain(t, true) + compact := signJWS(t, attacker, "ES256", testPayload()) + + _, _, err := verifierFor(t, trusted).Verify(compact) + if err == nil { + t.Fatal("a chain to an untrusted root was accepted") + } + if reason := ReasonOf(err); reason != ReasonChainUntrusted { + t.Fatalf("rejected for %q, want %q", reason, ReasonChainUntrusted) + } +} + +// Chaining to the Apple root is not sufficient: Apple issues many certificates +// under it. Requiring the App Store extension is what constrains the chain to +// store signing rather than, for example, a device certificate. +func TestVerifyRejectsChainWithoutAppStoreExtension(t *testing.T) { + chain := newTestChain(t, false) + compact := signJWS(t, chain, "ES256", testPayload()) + + _, _, err := verifierFor(t, chain).Verify(compact) + if err == nil { + t.Fatal("a chain without the App Store extension was accepted") + } + if reason := ReasonOf(err); reason != ReasonIntermediateWrong { + t.Fatalf("rejected for %q, want %q", reason, ReasonIntermediateWrong) + } +} + +// A payload edited after signing must fail. This is the case that proves the +// signature is actually checked against the payload rather than merely parsed. +func TestVerifyRejectsTamperedPayload(t *testing.T) { + chain := newTestChain(t, true) + compact := signJWS(t, chain, "ES256", testPayload()) + + forged := map[string]any{ + "notificationType": "REFUND", + "notificationUUID": "fixture-forged-uuid", + "version": "2.0", + "signedDate": time.Now().UnixMilli(), + } + forgedBytes, err := json.Marshal(forged) + if err != nil { + t.Fatal(err) + } + // Swap the payload segment, keep the original header and signature. + header, _, signature := split3(t, compact) + tampered := header + "." + base64.RawURLEncoding.EncodeToString(forgedBytes) + "." + signature + + if _, _, err := verifierFor(t, chain).Verify(tampered); err == nil { + t.Fatal("a tampered payload was accepted") + } +} + +// Certificate validity is evaluated at the payload's own signing time, not at +// time.Now(). A signedDate in the future is therefore the one time-related case +// that must still be refused: it is how a replayed payload would be given an +// artificially long life. +func TestVerifyRejectsFutureSignedDate(t *testing.T) { + chain := newTestChain(t, true) + payload := testPayload() + payload["signedDate"] = time.Now().Add(2 * time.Hour).UnixMilli() + compact := signJWS(t, chain, "ES256", payload) + + _, _, err := verifierFor(t, chain).Verify(compact) + if err == nil { + t.Fatal("a payload signed in the future was accepted") + } + if reason := ReasonOf(err); reason != ReasonSignedDateInFuture { + t.Fatalf("rejected for %q, want %q", reason, ReasonSignedDateInFuture) + } +} + +// The embedded certificate must actually be Apple's root. A build that shipped +// a placeholder or a truncated file would otherwise fail only in production, on +// the first real notification. +func TestEmbeddedRootIsAppleRootCAG3(t *testing.T) { + verifier, err := NewVerifier() + if err != nil { + t.Fatalf("embedded Apple root is unusable: %v", err) + } + if got := verifier.root.Subject.CommonName; got != "Apple Root CA - G3" { + t.Fatalf("embedded root common name is %q, want %q", got, "Apple Root CA - G3") + } + if !verifier.root.IsCA { + t.Fatal("embedded root is not a CA certificate") + } + // Self-signed: the root must verify under its own key. + if err := verifier.root.CheckSignatureFrom(verifier.root); err != nil { + t.Fatalf("embedded root is not self-signed: %v", err) + } +} + +func split3(t *testing.T, compact string) (string, string, string) { + t.Helper() + first := -1 + last := -1 + for index := range compact { + if compact[index] == '.' { + if first == -1 { + first = index + } + last = index + } + } + if first < 0 || last <= first { + t.Fatal("compact JWS is malformed") + } + return compact[:first], compact[first+1 : last], compact[last+1:] +} + +var _ = asn1.ObjectIdentifier{} diff --git a/apps/api/internal/platform/appstorejws/payloads.go b/apps/api/internal/platform/appstorejws/payloads.go new file mode 100644 index 00000000..c2ed8cc6 --- /dev/null +++ b/apps/api/internal/platform/appstorejws/payloads.go @@ -0,0 +1,151 @@ +package appstorejws + +import ( + "encoding/json" + "time" +) + +// The structures below are the subset of Apple's decoded payloads Phase 9A +// actually reads. Fields Mosaic deliberately does not persist are deliberately +// absent from these structs rather than parsed and dropped: +// +// - `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. +// +// Anything not named here stays inside the encrypted raw input, recoverable by +// a future phase that has a gate for it. + +// NotificationPayload is the decoded App Store Server Notification V2 body. +type NotificationPayload struct { + NotificationType string `json:"notificationType"` + Subtype string `json:"subtype"` + NotificationUUID string `json:"notificationUUID"` + Version string `json:"version"` + SignedDate int64 `json:"signedDate"` + Data *NotificationData `json:"data"` + Summary json.RawMessage `json:"summary"` + ExternalPurchase json.RawMessage `json:"externalPurchaseToken"` + AppMetadata json.RawMessage `json:"appMetadata"` +} + +// NotificationData is the `data` member of a V2 notification. +type NotificationData struct { + AppAppleID int64 `json:"appAppleId"` + BundleID string `json:"bundleId"` + BundleVersion string `json:"bundleVersion"` + Environment string `json:"environment"` + SignedTransactionInfo string `json:"signedTransactionInfo"` + SignedRenewalInfo string `json:"signedRenewalInfo"` + Status int `json:"status"` +} + +// TransactionPayload is the decoded JWSTransactionDecodedPayload. +type TransactionPayload struct { + TransactionID string `json:"transactionId"` + OriginalTransactionID string `json:"originalTransactionId"` + WebOrderLineItemID string `json:"webOrderLineItemId"` + BundleID string `json:"bundleId"` + ProductID string `json:"productId"` + SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"` + PurchaseDate int64 `json:"purchaseDate"` + OriginalPurchaseDate int64 `json:"originalPurchaseDate"` + ExpiresDate int64 `json:"expiresDate"` + Quantity int `json:"quantity"` + Type string `json:"type"` + TransactionReason string `json:"transactionReason"` + InAppOwnershipType string `json:"inAppOwnershipType"` + SignedDate int64 `json:"signedDate"` + Environment string `json:"environment"` + OfferType int `json:"offerType"` + OfferIdentifier string `json:"offerIdentifier"` + IsUpgraded bool `json:"isUpgraded"` + RevocationDate int64 `json:"revocationDate"` + RevocationReason *int `json:"revocationReason"` + Storefront string `json:"storefront"` +} + +// RenewalPayload is the subset of JWSRenewalInfoDecodedPayload Phase 9A reads. +type RenewalPayload struct { + OriginalTransactionID string `json:"originalTransactionId"` + AutoRenewStatus int `json:"autoRenewStatus"` + AutoRenewProductID string `json:"autoRenewProductId"` + ExpirationIntent int `json:"expirationIntent"` + IsInBillingRetry bool `json:"isInBillingRetryPeriod"` + GracePeriodExpiresAt int64 `json:"gracePeriodExpiresDate"` + SignedDate int64 `json:"signedDate"` + Environment string `json:"environment"` + ProductID string `json:"productId"` + RenewalDate int64 `json:"renewalDate"` +} + +// Apple product types as they appear in `type`. +const ( + ProductTypeAutoRenewable = "Auto-Renewable Subscription" + ProductTypeNonConsumable = "Non-Consumable" + ProductTypeConsumable = "Consumable" + ProductTypeNonRenewing = "Non-Renewing Subscription" +) + +// Store environments as they appear in `environment`. +const ( + EnvironmentSandbox = "Sandbox" + EnvironmentProduction = "Production" +) + +// Millis converts an Apple millisecond timestamp to a UTC time, reporting +// whether the value was present. Apple omits optional timestamps rather than +// sending zero, so a zero value is genuinely "absent". +func Millis(value int64) (time.Time, bool) { + if value <= 0 { + return time.Time{}, false + } + return time.UnixMilli(value).UTC(), true +} + +// DecodeNotification verifies and decodes a signedPayload. +func (v *Verifier) DecodeNotification(compact string) (NotificationPayload, error) { + raw, _, err := v.Verify(compact) + if err != nil { + return NotificationPayload{}, err + } + var payload NotificationPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return NotificationPayload{}, reject(ReasonMalformed) + } + if payload.NotificationUUID == "" || payload.NotificationType == "" { + return NotificationPayload{}, reject(ReasonMalformed) + } + return payload, nil +} + +// DecodeTransaction verifies and decodes a signedTransactionInfo. +func (v *Verifier) DecodeTransaction(compact string) (TransactionPayload, error) { + raw, _, err := v.Verify(compact) + if err != nil { + return TransactionPayload{}, err + } + var payload TransactionPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return TransactionPayload{}, reject(ReasonMalformed) + } + if payload.TransactionID == "" || payload.ProductID == "" || payload.BundleID == "" { + return TransactionPayload{}, reject(ReasonMalformed) + } + return payload, nil +} + +// DecodeRenewal verifies and decodes a signedRenewalInfo. +func (v *Verifier) DecodeRenewal(compact string) (RenewalPayload, error) { + raw, _, err := v.Verify(compact) + if err != nil { + return RenewalPayload{}, err + } + var payload RenewalPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return RenewalPayload{}, reject(ReasonMalformed) + } + return payload, nil +} diff --git a/apps/api/internal/platform/appstorejws/roots/apple-root-ca-g3.pem b/apps/api/internal/platform/appstorejws/roots/apple-root-ca-g3.pem new file mode 100644 index 00000000..561fc80d --- /dev/null +++ b/apps/api/internal/platform/appstorejws/roots/apple-root-ca-g3.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE----- diff --git a/apps/api/internal/platform/appstoreserver/client.go b/apps/api/internal/platform/appstoreserver/client.go new file mode 100644 index 00000000..995afb17 --- /dev/null +++ b/apps/api/internal/platform/appstoreserver/client.go @@ -0,0 +1,432 @@ +// Package appstoreserver is Mosaic's read-only client for the App Store Server +// API (version 1.13). +// +// The client is deliberately read-only: it looks transactions and notification +// history up and never calls an endpoint that changes store state. Finish +// Transaction, Extend a Subscription Renewal Date, Set App Account Token, and +// Send Consumption Info are all absent by design, because every one of them is +// an act of granting or altering entitlement and Phase 9A grants nothing. +package appstoreserver + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +const ( + // ProductionBaseURL and SandboxBaseURL are the hosts current Apple + // documentation names. The legacy `*.itunes.apple.com` hosts are not used. + ProductionBaseURL = "https://api.storekit.apple.com" + SandboxBaseURL = "https://api.storekit-sandbox.apple.com" + + // jwtAudience and jwtLifetime come from Apple's JWT requirements. Apple caps + // `exp` at `iat + 3600s`; 20 minutes leaves generous room for clock skew + // while keeping a leaked token short-lived. + jwtAudience = "appstoreconnect-v1" + jwtLifetime = 20 * time.Minute + + defaultBodyLimit = int64(2 << 20) +) + +// Credential is the decrypted material for one Apple team. The private key is +// held only for the duration of a call; callers zero the source buffer. +type Credential struct { + IssuerID string + KeyID string + PrivateKey *ecdsa.PrivateKey + // BundleID scopes each request to one Application. Apple requires `bid` on + // every JWT, so a Project with several Applications reuses one team key and + // varies this per request. + BundleID string + // Sandbox selects the base URL. Apple decides the environment purely by + // which host is called. + Sandbox bool +} + +// ParsePrivateKey reads an Apple In-App Purchase key (.p8, PKCS#8 EC P-256). +func ParsePrivateKey(pemBytes []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("Apple private key is not PEM encoded") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, errors.New("Apple private key is not a PKCS#8 key") + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("Apple private key is not an EC key") + } + if key.Curve.Params().BitSize != 256 { + return nil, errors.New("Apple private key is not a P-256 key") + } + return key, nil +} + +type Config struct { + ProductionBaseURL string + SandboxBaseURL string + RequestTimeout time.Duration + ConnectTimeout time.Duration + MaxResponseBytes int64 +} + +type Client struct { + production *url.URL + sandbox *url.URL + httpClient *http.Client + maxResponseBytes int64 + tracer trace.Tracer + now func() time.Time +} + +func New(config Config) (*Client, error) { + production, err := parseBase(config.ProductionBaseURL, ProductionBaseURL) + if err != nil { + return nil, err + } + sandbox, err := parseBase(config.SandboxBaseURL, SandboxBaseURL) + if err != nil { + return nil, err + } + if config.RequestTimeout <= 0 { + config.RequestTimeout = 8 * time.Second + } + if config.ConnectTimeout <= 0 { + config.ConnectTimeout = 3 * time.Second + } + if config.MaxResponseBytes <= 0 { + config.MaxResponseBytes = defaultBodyLimit + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = (&net.Dialer{Timeout: config.ConnectTimeout, KeepAlive: 30 * time.Second}).DialContext + transport.ResponseHeaderTimeout = config.RequestTimeout + transport.TLSHandshakeTimeout = config.ConnectTimeout + return &Client{ + production: production, + sandbox: sandbox, + httpClient: &http.Client{ + Transport: transport, + Timeout: config.RequestTimeout, + // Redirects are never followed: an upstream redirect would be an + // unauthenticated instruction to send a signed JWT somewhere else. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + maxResponseBytes: config.MaxResponseBytes, + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/appstoreserver"), + now: func() time.Time { return time.Now().UTC() }, + }, nil +} + +func parseBase(value, fallback string) (*url.URL, error) { + trimmed := strings.TrimRight(strings.TrimSpace(value), "/") + if trimmed == "" { + trimmed = fallback + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Host == "" || parsed.User != nil || + (parsed.Scheme != "https" && parsed.Scheme != "http") { + return nil, fmt.Errorf("App Store Server base URL %q must be an absolute HTTP(S) URL without credentials", value) + } + return parsed, nil +} + +// Error is a classified App Store Server API failure. It never carries a +// response body: only the HTTP status, Apple's numeric error code, and the +// retry instant Apple supplied. +type Error struct { + HTTPStatus int + // AppleCode is Apple's `errorCode` integer, rendered as a string so it can + // be persisted in the safe-provider-code column without a numeric type. + AppleCode string + // RetryAt is the absolute instant Apple told us to retry at, if any. + RetryAt time.Time + Op string + cause error +} + +func (e *Error) Error() string { + if e.AppleCode != "" { + return fmt.Sprintf("App Store Server %s failed with status %d (code %s)", e.Op, e.HTTPStatus, e.AppleCode) + } + return fmt.Sprintf("App Store Server %s failed with status %d", e.Op, e.HTTPStatus) +} + +func (e *Error) Unwrap() error { return e.cause } + +// ParseRetryAfter interprets Apple's Retry-After header. +// +// Apple's App Store Server API documents Retry-After on 429 as an **absolute +// UNIX timestamp in milliseconds**, not the RFC 7231 delta-seconds every other +// API in this repository sends. Reusing the delta-seconds parser here would +// read a value like 1800000000000 as a delta and schedule the retry roughly +// fifty-seven thousand years out, which presents as a permanently stalled +// queue with no error. The two forms are therefore parsed by two separate +// functions and this one is never used for a non-Apple response. +// +// A value that is not a plausible absolute millisecond timestamp is treated as +// absent rather than guessed at, so the caller falls back to its own backoff. +func ParseRetryAfter(header string, now time.Time) (time.Time, bool) { + trimmed := strings.TrimSpace(header) + if trimmed == "" { + return time.Time{}, false + } + millis, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil || millis <= 0 { + return time.Time{}, false + } + retryAt := time.UnixMilli(millis).UTC() + // Guard both ends. A value in the past is spent, and a value implausibly + // far out is a misread rather than an instruction worth honouring. + if !retryAt.After(now) || retryAt.After(now.Add(24*time.Hour)) { + return time.Time{}, false + } + return retryAt, true +} + +// TransactionInfo performs Get Transaction Info. The response carries a single +// signed transaction, which the caller verifies through appstorejws before +// trusting any field. +func (c *Client) TransactionInfo(ctx context.Context, credential Credential, transactionID string) (string, error) { + var response struct { + SignedTransactionInfo string `json:"signedTransactionInfo"` + } + // The `/inApps` path is case-sensitive per Apple's documentation. + err := c.do(ctx, credential, http.MethodGet, + "/inApps/v1/transactions/"+url.PathEscape(transactionID), nil, &response, "transaction_info") + if err != nil { + return "", err + } + if response.SignedTransactionInfo == "" { + return "", &Error{HTTPStatus: http.StatusOK, Op: "transaction_info", cause: errors.New("response carried no signed transaction")} + } + return response.SignedTransactionInfo, nil +} + +// HistoryPage is one page of Get Transaction History (v2). +type HistoryPage struct { + Revision string `json:"revision"` + HasMore bool `json:"hasMore"` + BundleID string `json:"bundleId"` + Environment string `json:"environment"` + SignedTransactions []string `json:"signedTransactions"` +} + +// TransactionHistory performs Get Transaction History. Apple returns at most +// twenty transactions per page and requires every subsequent call to repeat the +// original query parameters verbatim, so the caller passes the revision back +// unchanged. +func (c *Client) TransactionHistory(ctx context.Context, credential Credential, transactionID, revision string) (HistoryPage, error) { + path := "/inApps/v2/history/" + url.PathEscape(transactionID) + if revision != "" { + path += "?revision=" + url.QueryEscape(revision) + } + var page HistoryPage + if err := c.do(ctx, credential, http.MethodGet, path, nil, &page, "transaction_history"); err != nil { + return HistoryPage{}, err + } + return page, nil +} + +// NotificationHistoryRequest is the Get Notification History body. startDate and +// endDate are required by Apple and are milliseconds. +type NotificationHistoryRequest struct { + StartDate int64 `json:"startDate"` + EndDate int64 `json:"endDate"` + OnlyFailures bool `json:"onlyFailures,omitempty"` + TransactionID string `json:"transactionId,omitempty"` +} + +// NotificationHistoryPage is one page of notification history. Each item +// carries the same signedPayload the live endpoint would have received, so +// recovered notifications rejoin the pipeline through the identical code path. +type NotificationHistoryPage struct { + NotificationHistory []struct { + SignedPayload string `json:"signedPayload"` + SendAttempts []struct { + AttemptDate int64 `json:"attemptDate"` + SendAttemptResult string `json:"sendAttemptResult"` + } `json:"sendAttempts"` + } `json:"notificationHistory"` + HasMore bool `json:"hasMore"` + PaginationToken string `json:"paginationToken"` +} + +// NotificationHistory performs Get Notification History. Apple retains 180 days +// of production history and 30 days of sandbox history, which bounds every +// reconciliation window Mosaic can ask for. +func (c *Client) NotificationHistory(ctx context.Context, credential Credential, request NotificationHistoryRequest, paginationToken string) (NotificationHistoryPage, error) { + path := "/inApps/v1/notifications/history" + if paginationToken != "" { + path += "?paginationToken=" + url.QueryEscape(paginationToken) + } + body, err := json.Marshal(request) + if err != nil { + return NotificationHistoryPage{}, err + } + var page NotificationHistoryPage + if err := c.do(ctx, credential, http.MethodPost, path, body, &page, "notification_history"); err != nil { + return NotificationHistoryPage{}, err + } + return page, nil +} + +func (c *Client) do(ctx context.Context, credential Credential, method, path string, body []byte, out any, operation string) error { + base := c.production + environment := "production" + if credential.Sandbox { + base = c.sandbox + environment = "sandbox" + } + ctx, span := c.tracer.Start(ctx, "billing.provider.apple."+operation, trace.WithAttributes( + attribute.String("mosaic.billing.provider", "app_store"), + attribute.String("mosaic.billing.store_environment", environment), + )) + defer span.End() + + token, err := c.signJWT(credential) + if err != nil { + span.SetStatus(codes.Error, "credential_unusable") + return &Error{Op: operation, cause: err} + } + + endpoint := *base + target, err := url.Parse(path) + if err != nil { + return &Error{Op: operation, cause: err} + } + // url.Parse puts the decoded form in Path and the escaped form in RawPath. + // Copying only Path and letting URL.String() re-encode silently drops the + // escaping every url.PathEscape call above added, so a %2F would become a + // real path separator. Both halves are carried so the escaping survives. + endpoint.Path += target.Path + endpoint.RawPath = escapedPath(base) + escapedPath(target) + endpoint.RawQuery = target.RawQuery + + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) + if err != nil { + return &Error{Op: operation, cause: err} + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := c.httpClient.Do(request) + if err != nil { + span.SetStatus(codes.Error, "transport_failure") + // The transport error is wrapped but never surfaced to a response body: + // it can name internal hosts and proxies. + return &Error{Op: operation, cause: err} + } + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, c.maxResponseBytes)) + _ = response.Body.Close() + }() + span.SetAttributes(attribute.Int("http.response.status_code", response.StatusCode)) + + payload, err := io.ReadAll(io.LimitReader(response.Body, c.maxResponseBytes)) + if err != nil { + return &Error{HTTPStatus: response.StatusCode, Op: operation, cause: err} + } + + if response.StatusCode != http.StatusOK { + apiError := &Error{HTTPStatus: response.StatusCode, Op: operation} + var decoded struct { + ErrorCode int64 `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` + } + // The message is decoded only so it can be discarded deliberately: + // Apple's errorMessage is free text and is never logged or persisted. + if json.Unmarshal(payload, &decoded) == nil && decoded.ErrorCode != 0 { + apiError.AppleCode = strconv.FormatInt(decoded.ErrorCode, 10) + } + if retryAt, ok := ParseRetryAfter(response.Header.Get("Retry-After"), c.now()); ok { + apiError.RetryAt = retryAt + } + span.SetStatus(codes.Error, "provider_error") + span.SetAttributes(attribute.String("mosaic.billing.provider_code", apiError.AppleCode)) + return apiError + } + if out != nil { + if err := json.Unmarshal(payload, out); err != nil { + return &Error{HTTPStatus: response.StatusCode, Op: operation, cause: errors.New("provider response was not valid JSON")} + } + } + return nil +} + +// signJWT mints a fresh ES256 assertion per request. Tokens are never cached: +// they are cheap to produce and a cached token outlives the credential +// revocation that should have invalidated it. +func (c *Client) signJWT(credential Credential) (string, error) { + if credential.PrivateKey == nil || credential.IssuerID == "" || credential.KeyID == "" || credential.BundleID == "" { + return "", errors.New("Apple credential is incomplete") + } + issuedAt := c.now() + header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": credential.KeyID, "typ": "JWT"}) + if err != nil { + return "", err + } + claims, err := json.Marshal(map[string]any{ + "iss": credential.IssuerID, + "iat": issuedAt.Unix(), + "exp": issuedAt.Add(jwtLifetime).Unix(), + "aud": jwtAudience, + "bid": credential.BundleID, + }) + if err != nil { + return "", err + } + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(claims) + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, credential.PrivateKey, digest[:]) + if err != nil { + return "", err + } + signature := make([]byte, 64) + copyPadded(signature[:32], r) + copyPadded(signature[32:], s) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +func copyPadded(destination []byte, value *big.Int) { + bytesValue := value.Bytes() + copy(destination[len(destination)-len(bytesValue):], bytesValue) +} + +// escapedPath returns the percent-encoded path of u, falling back to the +// decoded form when the two are identical (url.URL leaves RawPath empty then). +func escapedPath(u *url.URL) string { + if u.RawPath != "" { + return u.RawPath + } + return u.Path +} diff --git a/apps/api/internal/platform/billingpostgres/billing_integration_test.go b/apps/api/internal/platform/billingpostgres/billing_integration_test.go new file mode 100644 index 00000000..3245cd6d --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/billing_integration_test.go @@ -0,0 +1,549 @@ +package billingpostgres + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// These integration tests cover the guarantees that live in the schema rather +// than in Go: the append-only triggers, the idempotency constraints, and the +// sandbox/production separation. A unit test with a fake repository would pass +// while every one of them was broken, because the thing under test is the +// database itself. + +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 billing input needs: an organization, a +// Project, a production Environment, and an iOS Application. +func seed(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) (projectID, environmentID, applicationID string) { + t.Helper() + now := time.Now().UTC() + organizationID := "org_billing_" + suffix + projectID = "proj_billing_" + suffix + environmentID = "env_billing_" + suffix + applicationID = "app_billing_" + 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, "Billing Test", now}}, + {`INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES ($1,$2,$3,'Billing','active',$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{projectID, organizationID, "billing-" + 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 applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'iOS','ios',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{applicationID, projectID, "com.fixture.app." + suffix, now}}, + // Mosaic Billing is opt-in and genuinely gates intake and every worker, + // so a seeded tenant that is exercising the pipeline must have opted in. + // Tests that care about the off state turn it off explicitly. + {`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{projectID, now}}, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("seed tenant: %v", err) + } + } + t.Cleanup(func() { + cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cleanup(t, cleanupContext, pool, projectID) + }) + return projectID, environmentID, applicationID +} + +// cleanup removes billing rows in dependency order. The append-only triggers +// permit DELETE only where retention needs it, so the ledger tables are cleared +// with a session-level trigger disable rather than by weakening the schema. +func cleanup(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID string) { + t.Helper() + statements := []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`, + `DELETE FROM billing_ledger_entries WHERE project_id=$1`, + `DELETE FROM billing_replay_jobs WHERE project_id=$1`, + `DELETE FROM billing_reconciliation_runs WHERE project_id=$1`, + `DELETE FROM billing_quarantine_actions WHERE project_id=$1`, + `DELETE FROM billing_quarantine_records WHERE project_id=$1`, + `DELETE FROM billing_transaction_facts WHERE project_id=$1`, + `DELETE FROM billing_product_resolutions WHERE project_id=$1`, + `DELETE FROM billing_validation_jobs WHERE project_id=$1`, + `DELETE FROM billing_validation_attempts WHERE project_id=$1`, + `DELETE FROM billing_raw_inputs WHERE project_id=$1`, + `DELETE FROM store_server_credential_applications WHERE project_id=$1`, + `DELETE FROM store_server_credentials WHERE project_id=$1`, + `DELETE FROM billing_project_settings WHERE project_id=$1`, + `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`, + } + for _, statement := range statements { + if strings.HasPrefix(statement, "DELETE") { + _, _ = pool.Exec(ctx, statement, projectID) + continue + } + _, _ = pool.Exec(ctx, statement) + } +} + +func sampleInput(projectID, environmentID, applicationID, key string) billing.RawInput { + now := time.Now().UTC() + return billing.RawInput{ + ProjectID: projectID, + EnvironmentID: environmentID, + EnvironmentMode: "production", + ApplicationID: applicationID, + Provider: billing.ProviderAppStore, + Source: billing.SourceAppleNotification, + SourceAuthority: billing.AuthorityStoreNotification, + ProviderEventID: key, + IdempotencyKey: billing.AppleNotificationKey(key), + ContentDigest: billing.ContentDigest([]byte(`{"signedPayload":"fixture"}`)), + BodyState: "not_retained", + AuthenticationResult: billing.AuthVerifiedSignature, + StoreEnvironment: billing.StoreProduction, + NotificationKind: "SUBSCRIBED", + IngestionStatus: billing.IngestAccepted, + CorrelationID: "test-" + key, + ReceivedAt: now, + ExpiresAt: now.Add(90 * 24 * time.Hour), + } +} + +// A store retrying a delivery must never produce a second input or a second +// queued job. Apple redelivers on any non-2xx and Pub/Sub redelivers +// aggressively, so duplicate delivery is the normal case rather than an edge. +func TestDuplicateNotificationProducesOneInputAndOneJob(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "dup") + now := time.Now().UTC() + + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-duplicate") + first, err := repository.PersistRawInput(ctx, input, true, now) + if err != nil { + t.Fatalf("first delivery: %v", err) + } + if first.Status != billing.IngestAccepted { + t.Fatalf("first delivery status %q, want accepted", first.Status) + } + + // Identical redelivery: same key, same content. + redelivery := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-duplicate") + second, err := repository.PersistRawInput(ctx, redelivery, true, now) + if err != nil { + t.Fatalf("redelivery: %v", err) + } + if second.Status != billing.IngestDuplicate { + t.Fatalf("redelivery status %q, want duplicate", second.Status) + } + if second.RawInputID != first.RawInputID { + t.Fatal("redelivery created a second raw input") + } + + var inputs, jobs int + if err := pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM billing_raw_inputs WHERE project_id=$1), + (SELECT count(*) FROM billing_validation_jobs WHERE project_id=$1)`, projectID). + Scan(&inputs, &jobs); err != nil { + t.Fatal(err) + } + if inputs != 1 || jobs != 1 { + t.Fatalf("after redelivery: %d inputs and %d jobs, want 1 and 1", inputs, jobs) + } +} + +// The same provider event id arriving with different content is either a +// provider defect or a forgery attempt. It must never overwrite the original, +// and it must surface as a security-severity quarantine rather than being +// absorbed as a duplicate. +func TestConflictingContentUnderSameKeyQuarantines(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "conflict") + now := time.Now().UTC() + + original := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-conflict") + if _, err := repository.PersistRawInput(ctx, original, true, now); err != nil { + t.Fatal(err) + } + + forged := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-conflict") + forged.ContentDigest = billing.ContentDigest([]byte(`{"signedPayload":"different"}`)) + result, err := repository.PersistRawInput(ctx, forged, true, now) + if err != nil { + t.Fatal(err) + } + if !result.Conflicted || result.Status != billing.IngestConflicted { + t.Fatalf("conflicting content reported as %q (conflicted=%v)", result.Status, result.Conflicted) + } + + var storedDigest []byte + if err := pool.QueryRow(ctx, `SELECT content_digest FROM billing_raw_inputs WHERE id=$1`, + result.RawInputID).Scan(&storedDigest); err != nil { + t.Fatal(err) + } + if string(storedDigest) != string(original.ContentDigest) { + t.Fatal("the original content digest was overwritten by the conflicting delivery") + } + + var reason, severity string + if err := pool.QueryRow(ctx, + `SELECT reason_code, severity FROM billing_quarantine_records WHERE raw_input_id=$1`, + result.RawInputID).Scan(&reason, &severity); err != nil { + t.Fatalf("no quarantine record for a content conflict: %v", err) + } + if reason != billing.QuarantineInputContentConflict || severity != "security" { + t.Fatalf("quarantined as %q/%q, want input_content_conflict/security", reason, severity) + } +} + +// The append-only guarantee is a database trigger, so only a database test can +// prove it. Without it, application code could silently rewrite the ledger and +// replay would stop being reproducible. +func TestLedgerTablesRejectMutation(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "append") + now := time.Now().UTC() + + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-append") + result, err := repository.PersistRawInput(ctx, input, false, now) + if err != nil { + t.Fatal(err) + } + + attemptID := "bva_fixture_append" + if _, err := pool.Exec(ctx, + `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,1,$5,$5,'validated',false,'production',12,'test')`, + attemptID, projectID, environmentID, result.RawInputID, now); err != nil { + t.Fatal(err) + } + + // A validation attempt is evidence of what the pipeline did; editing or + // deleting one would let a failed attempt be erased after the fact. + for name, statement := range map[string]string{ + "update attempt": `UPDATE billing_validation_attempts SET outcome='quarantined' WHERE id=$1`, + "delete attempt": `DELETE FROM billing_validation_attempts WHERE id=$1`, + } { + if _, err := pool.Exec(ctx, statement, attemptID); err == nil { + t.Fatalf("%s succeeded on an append-only table", name) + } else if !strings.Contains(err.Error(), "55000") { + t.Fatalf("%s failed with %v, want SQLSTATE 55000", name, err) + } + } + + // A raw input's meaning is immutable, but the encryption envelope must stay + // rewritable so `keyring rotate` can reseal a retained body. + if _, err := pool.Exec(ctx, + `UPDATE billing_raw_inputs SET notification_kind='REFUND' WHERE id=$1`, result.RawInputID); err == nil { + t.Fatal("a raw billing input was edited outside key rotation") + } else if !strings.Contains(err.Error(), "55000") { + t.Fatalf("raw input edit failed with %v, want SQLSTATE 55000", err) + } + if _, err := pool.Exec(ctx, + `UPDATE billing_raw_inputs SET key_id='rotated-key', envelope_rotated_at=$2 WHERE id=$1`, + result.RawInputID, now); err != nil { + t.Fatalf("key rotation was blocked by the append-only trigger: %v", err) + } +} + +// Sandbox and production must not mix. Enforcing it in the schema means an +// application defect produces a constraint violation rather than a sandbox +// purchase quietly counted as production revenue. +func TestSandboxFactCannotLandInProductionEnvironment(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "mixing") + now := time.Now().UTC() + + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-mixing") + result, err := repository.PersistRawInput(ctx, input, false, now) + if err != nil { + t.Fatal(err) + } + attemptID := "bva_fixture_mixing" + if _, err := pool.Exec(ctx, + `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,1,$5,$5,'validated',false,'production',12,'test')`, + attemptID, projectID, environmentID, result.RawInputID, now); err != nil { + t.Fatal(err) + } + + _, err = pool.Exec(ctx, + `INSERT INTO billing_transaction_facts( + id, project_id, environment_id, environment_mode, application_id, provider, store_environment, + provider_transaction_id, 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','sandbox','2000000000000001', + 'auto_renewable_subscription','initial_purchase',$5,'fixture.pro','unresolved',1,1,$6,$7,$8,$5)`, + "btf_fixture_mixing", projectID, environmentID, applicationID, now, + result.RawInputID, attemptID, make([]byte, 32)) + if err == nil { + t.Fatal("a sandbox fact was accepted into a production Environment") + } + if !strings.Contains(err.Error(), "environment_alignment") { + t.Fatalf("rejected by %v, want the store-environment alignment check", err) + } +} + +// Fact identity is UNIQUE (environment_id, fact_digest). This is what makes +// replay a structural no-op: the second write of an identical fact must be +// absorbed rather than duplicating the ledger. +func TestIdenticalFactIsDeduplicatedByDigest(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "factdedup") + now := time.Now().UTC() + + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-factdedup") + result, err := repository.PersistRawInput(ctx, input, false, now) + if err != nil { + t.Fatal(err) + } + digest := make([]byte, 32) + for index := range digest { + digest[index] = byte(index) + } + + insertFact := func(factID, attemptID string) error { + if _, err := pool.Exec(ctx, + `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,$5,1,$6,$6,'validated',false,'production',12,'test')`, + attemptID, projectID, environmentID, result.RawInputID, len(attemptID), now); err != nil { + return err + } + tag, err := pool.Exec(ctx, + `INSERT INTO billing_transaction_facts( + id, project_id, environment_id, environment_mode, application_id, provider, store_environment, + provider_transaction_id, 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','2000000000000002', + 'auto_renewable_subscription','renewal',$5,'fixture.pro','unresolved',1,1,$6,$7,$8,$5) + ON CONFLICT (environment_id, fact_digest) DO NOTHING`, + factID, projectID, environmentID, applicationID, now, result.RawInputID, attemptID, digest) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return nil + } + return nil + } + + if err := insertFact("btf_first", "bva_a"); err != nil { + t.Fatal(err) + } + // A replay recomputes the same digest and must write nothing new. + if err := insertFact("btf_second", "bva_bb"); err != nil { + t.Fatal(err) + } + + var facts int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_transaction_facts WHERE project_id=$1`, projectID).Scan(&facts); err != nil { + t.Fatal(err) + } + if facts != 1 { + t.Fatalf("%d facts recorded, want 1 — replay duplicated the ledger", facts) + } + // Both attempts survive: replay preserves history rather than replacing it. + var attempts int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_validation_attempts WHERE raw_input_id=$1`, result.RawInputID).Scan(&attempts); err != nil { + t.Fatal(err) + } + if attempts != 2 { + t.Fatalf("%d attempts preserved, want 2", attempts) + } +} + +// Reading another tenant's billing data must fail on membership, not on a +// filter an application defect could omit. Member-level access is refused too: +// the ledger carries store evidence and the credential lifecycle controls +// production notification delivery. +func TestBillingReadsRequireOwnerOrAdminMembership(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, _ := seed(t, ctx, pool, "authz") + now := time.Now().UTC() + + var organizationID string + if err := pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, projectID). + Scan(&organizationID); err != nil { + t.Fatal(err) + } + for actorID, role := range map[string]string{ + "actor_owner_authz": "owner", + "actor_member_authz": "member", + } { + if _, err := pool.Exec(ctx, + `INSERT INTO organization_members(organization_id,actor_id,role,created_at,updated_at) + VALUES ($1,$2,$3,$4,$4) ON CONFLICT (organization_id,actor_id) DO UPDATE SET role=EXCLUDED.role`, + organizationID, actorID, role, now); 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 LIKE 'actor_%_authz'`, organizationID) + }) + + if _, err := repository.ListFacts(ctx, billing.Actor{ID: "actor_owner_authz"}, projectID, environmentID, billing.ListOptions{}); err != nil { + t.Fatalf("an owner was refused: %v", err) + } + if _, err := repository.ListFacts(ctx, billing.Actor{ID: "actor_member_authz"}, projectID, environmentID, billing.ListOptions{}); err != billing.ErrForbidden { + t.Fatalf("a member read the billing ledger: %v", err) + } + // A non-member must not learn whether the Project exists. + if _, err := repository.ListFacts(ctx, billing.Actor{ID: "actor_stranger"}, projectID, environmentID, billing.ListOptions{}); err != billing.ErrNotFound { + t.Fatalf("a non-member got %v, want not found", err) + } + if _, err := repository.ListFacts(ctx, billing.Actor{}, projectID, environmentID, billing.ListOptions{}); err != billing.ErrUnauthenticated { + t.Fatal("an unauthenticated caller was not refused") + } +} + +// The quarantine surface must keep sandbox and production visibly apart, and it +// must never present an unknown environment as an absent one: a missing value +// on an operator screen reads as production to a careless eye. The record does +// not carry its own copy of the column — it is always about exactly one input, +// and duplicating it would create a second place for the two to disagree — so +// this pins that the join actually happens and that the fallback is explicit. +func TestQuarantineRecordsCarryStoreEnvironment(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "quarenv") + now := time.Now().UTC() + + 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_quarenv','owner',$2,$2) + ON CONFLICT (organization_id,actor_id) DO UPDATE SET role=EXCLUDED.role`, + organizationID, now); 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_quarenv'`, organizationID) + }) + + // A production-classified input, quarantined at intake. + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-quarenv") + input.IngestionStatus = billing.IngestQuarantined + if _, err := repository.PersistRawInput(ctx, input, false, now); err != nil { + t.Fatal(err) + } + + // An input whose environment could not be classified before it quarantined. + unclassified := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-quarenv-unclassified") + unclassified.StoreEnvironment = billing.StoreUnclassified + unclassified.AuthenticationResult = billing.AuthFailed + unclassified.IngestionStatus = billing.IngestQuarantined + if _, err := repository.PersistRawInput(ctx, unclassified, false, now); err != nil { + t.Fatal(err) + } + + actor := billing.Actor{ID: "actor_owner_quarenv"} + page, err := repository.ListQuarantine(ctx, actor, projectID, environmentID, billing.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 2 { + t.Fatalf("%d quarantine records, want 2", len(page.Items)) + } + seen := map[string]string{} + for _, record := range page.Items { + if record.StoreEnvironment == "" { + t.Fatalf("quarantine record %s reports an empty store environment", record.ID) + } + seen[record.RawInputID] = record.StoreEnvironment + + // The detail read must agree with the list read. + detail, err := repository.Quarantine(ctx, actor, projectID, record.ID) + if err != nil { + t.Fatal(err) + } + if detail.StoreEnvironment != record.StoreEnvironment { + t.Fatalf("detail reports %q but the list reports %q", + detail.StoreEnvironment, record.StoreEnvironment) + } + } + for rawInputID, environment := range seen { + if environment != billing.StoreProduction && environment != billing.StoreUnclassified { + t.Fatalf("raw input %s reported store environment %q", rawInputID, environment) + } + } + if len(seen) != 2 { + t.Fatalf("expected two distinct inputs, got %d", len(seen)) + } +} diff --git a/apps/api/internal/platform/billingpostgres/billing_revalidation_test.go b/apps/api/internal/platform/billingpostgres/billing_revalidation_test.go new file mode 100644 index 00000000..b889bddf --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/billing_revalidation_test.go @@ -0,0 +1,794 @@ +package billingpostgres + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/appstorejws" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/googleplay" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// googleServiceAccountFixture builds a service-account key file in Google's +// documented shape around a locally generated RSA key. It is never sent +// anywhere: the worker only parses it, so this exercises the real +// ParseServiceAccount path without any real credential. +func googleServiceAccountFixture(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(map[string]string{ + "type": "service_account", + "project_id": "fixture-project", + "private_key_id": "fixture-key", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), + "client_email": "fixture@example.iam.gserviceaccount.com", + "token_uri": "https://oauth2.googleapis.com/token", + }) + if err != nil { + t.Fatal(err) + } + return encoded +} + +// The three tests in this file pin the three Stage 4 demonstration defects. +// +// All three are integration tests against PostgreSQL, and deliberately so: each +// defect lived in the interaction between the application service and SQL, not +// inside either alone. A unit test with a fake repository would have passed +// while every one of them was broken, because the fake would have enqueued the +// job the real ON CONFLICT branch silently skipped, matched the provider +// predicate the real query omitted, and returned the credential the real scope +// lookup never performed. +// +// They are also service-level rather than repository-level. The repository +// methods they exercise are new, so a repository test would only assert that +// code written today does what it was written to do. What actually regressed was +// whether ProcessNextReplay and reconcileGoogleTokens *use* them, and whether an +// observation reaches the provider at all — none of which is visible below the +// service. +// +// Google is used throughout because Google's authenticity is transport-level: +// no signature is involved, so these tests need no synthetic Apple chain and +// stay focused on the defect rather than on certificate plumbing. + +// fakeGoogle is a recording stand-in for the Play Developer API. It counts calls +// so a test can assert that a lookup actually happened, which is precisely what +// defect 2 got wrong: the run reported success without making one. +type fakeGoogle struct { + subscriptions map[string]googleplay.SubscriptionPurchase + orders map[string]googleplay.Order + subscriptionCalls int + orderCalls int +} + +func (f *fakeGoogle) GetSubscription(_ context.Context, _ *googleplay.ServiceAccount, _, purchaseToken string) (googleplay.SubscriptionPurchase, error) { + f.subscriptionCalls++ + purchase, ok := f.subscriptions[purchaseToken] + if !ok { + return googleplay.SubscriptionPurchase{}, billing.ErrNotFound + } + return purchase, nil +} + +func (f *fakeGoogle) GetProduct(context.Context, *googleplay.ServiceAccount, string, string, string) (googleplay.ProductPurchase, error) { + return googleplay.ProductPurchase{}, billing.ErrNotFound +} + +func (f *fakeGoogle) GetOrder(_ context.Context, _ *googleplay.ServiceAccount, _, orderID string) (googleplay.Order, error) { + f.orderCalls++ + order, ok := f.orders[orderID] + if !ok { + return googleplay.Order{}, billing.ErrNotFound + } + return order, nil +} + +func (f *fakeGoogle) Pull(context.Context, *googleplay.ServiceAccount, string, string, int) ([]googleplay.ReceivedMessage, error) { + return nil, nil +} + +func (f *fakeGoogle) Acknowledge(context.Context, *googleplay.ServiceAccount, string, string, []string) error { + return nil +} + +// subscriptionPurchase builds the Play response shape, parameterised by the one +// field the tests vary. Expiry is what moves the fact digest, which is how a +// "changed provider answer" is simulated without inventing a new code path. +func subscriptionPurchase(productID, orderID string, start, expiry time.Time) googleplay.SubscriptionPurchase { + var purchase googleplay.SubscriptionPurchase + encoded, _ := json.Marshal(map[string]any{ + "kind": "androidpublisher#subscriptionPurchaseV2", + "regionCode": "US", + "startTime": start.Format(time.RFC3339), + "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE", + "latestOrderId": orderID, + "lineItems": []map[string]any{{ + "productId": productID, + "expiryTime": expiry.Format(time.RFC3339), + "offerDetails": map[string]any{"basePlanId": "monthly"}, + "autoRenewingPlan": map[string]any{"autoRenewEnabled": true}, + }}, + }) + _ = json.Unmarshal(encoded, &purchase) + return purchase +} + +// revalidationFixture is the shared tenant: an organization, Project, production +// Environment, Android Application, Mosaic Product, active mapping, and a Google +// Store Server Credential scoped to the Application. +type revalidationFixture struct { + pool *pgxpool.Pool + service *billing.Service + google *fakeGoogle + + projectID string + environmentID string + applicationID string + credentialID string + cipher providercredential.SubjectCipher + organization string +} + +const ( + fixturePackageName = "com.fixture.demo.android" + fixtureProviderProduct = "fixture.sub.monthly" + fixturePurchaseToken = "fixture-purchase-token-0001" + fixtureOrderID = "GPA.FIXTURE-0000-0000-0001" +) + +func newRevalidationFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) *revalidationFixture { + t.Helper() + projectID, environmentID, _ := seed(t, ctx, pool, suffix) + now := time.Now().UTC() + + var organizationID string + if err := pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, projectID). + Scan(&organizationID); err != nil { + t.Fatal(err) + } + + applicationID := "app_android_" + suffix + productID := "prd_" + suffix + mappingID := "ppm_" + suffix + credentialID := "ssc_" + suffix + + for _, statement := range []struct { + query string + args []any + }{ + {`INSERT INTO applications(id,project_id,name,platform,identifier,created_at,updated_at) + VALUES ($1,$2,'Android','android',$3,$4,$4) ON CONFLICT (id) DO NOTHING`, + []any{applicationID, projectID, fixturePackageName + "." + suffix, now}}, + {`INSERT INTO products(id,project_id,key,internal_name,type,status,metadata_source,readiness_ready,created_at,updated_at) + VALUES ($1,$2,$3,'Fixture Monthly','subscription','connected','mock',true,$4,$4) + ON CONFLICT (id) DO NOTHING`, []any{productID, projectID, "fixture-" + suffix, 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,'google_play',$5,'active',$6,'android','available','current',$7,$7) + ON CONFLICT (id) DO NOTHING`, + []any{mappingID, projectID, productID, applicationID, fixtureProviderProduct, environmentID, now}}, + } { + if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil { + t.Fatalf("fixture: %v", err) + } + } + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + cipher, err := providercredential.NewAESGCMCipher( + `{"version":1,"activeKeyId":"test","keys":{"test":"`+ + base64.RawURLEncoding.EncodeToString(key)+`"}}`, rand.Reader) + if err != nil { + t.Fatalf("cipher: %v", err) + } + + // The owner membership these tests need to queue a replay or a + // reconciliation through the service. Authorization is real here: the + // service's SQL checks it. + for _, actorID := range []string{"actor_owner_replay", "actor_owner_recon"} { + if _, err := pool.Exec(ctx, + `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`, + organizationID, actorID, now); err != nil { + t.Fatal(err) + } + } + + google := &fakeGoogle{ + subscriptions: map[string]googleplay.SubscriptionPurchase{}, + orders: map[string]googleplay.Order{}, + } + // The real verifier with the real embedded Apple root. These tests never + // present a JWS, so no trust-root substitution is needed or wanted. + verifier, err := appstorejws.NewVerifier() + if err != nil { + t.Fatal(err) + } + service := billing.NewService(New(pool), cipher, verifier, billing.WithProviders(nil, google)) + + fixture := &revalidationFixture{ + pool: pool, service: service, google: google, + projectID: projectID, environmentID: environmentID, applicationID: applicationID, + credentialID: credentialID, cipher: cipher, organization: organizationID, + } + fixture.createCredential(t, ctx) + return fixture +} + +// createCredential writes a Google credential the way the service would, sealing +// a service-account key under the real envelope so the worker can open it. +func (f *revalidationFixture) createCredential(t *testing.T, ctx context.Context) { + t.Helper() + now := time.Now().UTC() + secret := googleServiceAccountFixture(t) + envelope, err := f.cipher.EncryptSubject(secret, providercredential.SubjectScope{ + OrganizationID: f.organization, + ProjectID: f.projectID, + SubjectKind: providercredential.SubjectStoreServerCredential, + SubjectID: f.credentialID, + CredentialClass: billing.ClassGoogleServiceAccountKey, + }) + if err != nil { + t.Fatalf("seal credential: %v", err) + } + if _, err := f.pool.Exec(ctx, + `INSERT INTO store_server_credentials( + id, project_id, organization_id, environment_id, environment_mode, provider, store_environment, + name, status, health_status, credential_class, envelope_version, algorithm, key_id, nonce, + ciphertext, fingerprint, google_client_email, google_pubsub_project_id, + google_pubsub_subscription_id, created_by_actor_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,'production','google_play','production','Fixture','active','untested', + $5,$6,$7,$8,$9,$10,$11,'fixture@example.iam.gserviceaccount.com','fixture-project', + 'fixture-sub','actor_fixture',$12,$12)`, + f.credentialID, f.projectID, f.organization, f.environmentID, + billing.ClassGoogleServiceAccountKey, envelope.Version, envelope.Algorithm, envelope.KeyID, + envelope.Nonce, envelope.Ciphertext, envelope.Fingerprint, now); err != nil { + t.Fatalf("insert credential: %v", err) + } + if _, err := f.pool.Exec(ctx, + `INSERT INTO store_server_credential_applications( + credential_id, project_id, application_id, platform, provider_application_identifier, created_at) + VALUES ($1,$2,$3,'android',$4,$5)`, + f.credentialID, f.projectID, f.applicationID, fixturePackageName+"."+suffixOf(f.credentialID), now); err != nil { + t.Fatalf("scope credential: %v", err) + } +} + +func suffixOf(credentialID string) string { return credentialID[len("ssc_"):] } + +// ingest writes a Raw Billing Input with a sealed body, the way intake does. +// credentialID is empty for an observation, which is the whole point of the +// third test. +func (f *revalidationFixture) ingest(t *testing.T, ctx context.Context, id, source, authority, credentialID string, body []byte, enqueue bool) billing.RawInput { + t.Helper() + now := time.Now().UTC() + input := billing.RawInput{ + ID: id, + ProjectID: f.projectID, + OrganizationID: f.organization, + EnvironmentID: f.environmentID, + EnvironmentMode: "production", + CredentialID: credentialID, + Provider: billing.ProviderGooglePlay, + Source: source, + SourceAuthority: authority, + ProviderEventID: id, + IdempotencyKey: billing.ContentDigest([]byte(id)), + ContentDigest: billing.ContentDigest(body), + AuthenticationResult: billing.AuthVerifiedTransport, + StoreEnvironment: billing.StoreProduction, + IngestionStatus: billing.IngestAccepted, + CorrelationID: "fixture", + ReceivedAt: now, + ExpiresAt: now.Add(90 * 24 * time.Hour), + } + envelope, err := f.cipher.EncryptSubject(body, providercredential.SubjectScope{ + OrganizationID: f.organization, + ProjectID: f.projectID, + SubjectKind: providercredential.SubjectBillingRawInput, + SubjectID: id, + CredentialClass: billing.ClassBillingRawPayload, + }) + if err != nil { + t.Fatalf("seal body: %v", err) + } + input.BodyState = "stored" + input.Envelope = &billing.Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, Fingerprint: envelope.Fingerprint, + } + if _, err := New(f.pool).PersistRawInput(ctx, input, enqueue, now); err != nil { + t.Fatalf("persist input: %v", err) + } + return input +} + +func (f *revalidationFixture) attemptCount(t *testing.T, ctx context.Context, rawInputID string) int { + t.Helper() + var count int + if err := f.pool.QueryRow(ctx, + `SELECT count(*) FROM billing_validation_attempts WHERE raw_input_id=$1`, rawInputID).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func (f *revalidationFixture) factCount(t *testing.T, ctx context.Context, rawInputID string) int { + t.Helper() + var count int + if err := f.pool.QueryRow(ctx, + `SELECT count(*) FROM billing_transaction_facts WHERE source_raw_input_id=$1`, rawInputID).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func rtdnBody(packageName, subscriptionID, purchaseToken string) []byte { + body, _ := json.Marshal(map[string]any{ + "version": "1.0", "packageName": packageName, "eventTimeMillis": "1700000000000", + "subscriptionNotification": map[string]any{ + "version": "1.0", "notificationType": 4, + "purchaseToken": purchaseToken, "subscriptionId": subscriptionID, + }, + }) + return body +} + +// --------------------------------------------------------------------------- +// Defect 1 +// --------------------------------------------------------------------------- + +// Replay must append a real Validation Attempt and report a comparison it +// actually performed. +// +// The defect this catches: ProcessNextReplay re-enqueued through +// PersistRawInput, whose duplicate branch returns before the enqueue, so nothing +// was ever revalidated — while the job still reported comparison_result +// 'identical'. That is worse than doing nothing, because an operator reads it as +// "the ledger was re-verified". The second half of the test changes the provider +// answer, which is the only way to prove the 'identical' verdict was computed +// rather than assumed. +func TestReplayAppendsAttemptAndComparesAgainstRecordedFacts(t *testing.T) { + pool, ctx := testPool(t) + fixture := newRevalidationFixture(t, ctx, pool, "replay") + + start := time.Now().Add(-72 * time.Hour).UTC().Truncate(time.Second) + expiry := start.Add(30 * 24 * time.Hour) + fixture.google.subscriptions[fixturePurchaseToken] = + subscriptionPurchase(fixtureProviderProduct, fixtureOrderID, start, expiry) + + input := fixture.ingest(t, ctx, "bri_replay_fixture", + billing.SourceGoogleRTDN, billing.AuthorityStoreNotification, fixture.credentialID, + rtdnBody(fixturePackageName+"."+suffixOf(fixture.credentialID), fixtureProviderProduct, fixturePurchaseToken), true) + + if _, err := fixture.service.ProcessNextValidation(ctx, "worker"); err != nil { + t.Fatalf("initial validation: %v", err) + } + if got := fixture.attemptCount(t, ctx, input.ID); got != 1 { + t.Fatalf("%d attempts after first validation, want 1", got) + } + if got := fixture.factCount(t, ctx, input.ID); got != 1 { + t.Fatalf("%d facts after first validation, want 1", got) + } + + queueReplay := func(kind string) { + t.Helper() + if _, err := fixture.service.CreateReplay(ctx, billing.Actor{ID: "actor_owner_replay"}, billing.ReplayJob{ + ProjectID: fixture.projectID, EnvironmentID: fixture.environmentID, + Kind: kind, RawInputID: input.ID, + }); err != nil { + t.Fatalf("queue replay: %v", err) + } + if _, err := fixture.service.ProcessNextReplay(ctx, "worker"); err != nil { + t.Fatalf("run replay: %v", err) + } + } + + // Unchanged provider answer. + queueReplay("revalidation") + if got := fixture.attemptCount(t, ctx, input.ID); got != 2 { + t.Fatalf("%d attempts after replay, want 2 — the replay appended no attempt", got) + } + if got := fixture.factCount(t, ctx, input.ID); got != 1 { + t.Fatalf("%d facts after an unchanged replay, want 1 — the ledger was duplicated", got) + } + var comparison string + var unchanged, newFacts int64 + if err := pool.QueryRow(ctx, + `SELECT comparison_result, unchanged_count, new_fact_count FROM billing_replay_jobs + WHERE project_id=$1 ORDER BY created_at DESC LIMIT 1`, fixture.projectID). + Scan(&comparison, &unchanged, &newFacts); err != nil { + t.Fatal(err) + } + if comparison != "identical" || unchanged != 1 || newFacts != 0 { + t.Fatalf("unchanged replay reported %q unchanged=%d new=%d", comparison, unchanged, newFacts) + } + + // Changed provider answer: a different expiry moves the fact digest, so the + // replay must report new_facts rather than identical. + fixture.google.subscriptions[fixturePurchaseToken] = + subscriptionPurchase(fixtureProviderProduct, fixtureOrderID, start, expiry.Add(24*time.Hour)) + queueReplay("revalidation") + if got := fixture.attemptCount(t, ctx, input.ID); got != 3 { + t.Fatalf("%d attempts after the second replay, want 3", got) + } + if got := fixture.factCount(t, ctx, input.ID); got != 2 { + t.Fatalf("%d facts after a changed replay, want 2 — the new answer was not appended", got) + } + if err := pool.QueryRow(ctx, + `SELECT comparison_result, unchanged_count, new_fact_count FROM billing_replay_jobs + WHERE project_id=$1 ORDER BY created_at DESC LIMIT 1`, fixture.projectID). + Scan(&comparison, &unchanged, &newFacts); err != nil { + t.Fatal(err) + } + if comparison != "new_facts" || newFacts != 1 { + t.Fatalf("changed replay reported %q new=%d, want new_facts/1 — the comparison is not real", comparison, newFacts) + } +} + +// --------------------------------------------------------------------------- +// Defect 2 +// --------------------------------------------------------------------------- + +// google_token_requery reconciliation must actually call the Play API, and must +// examine only Google inputs. +// +// The defect this catches has two halves, and the test asserts both because +// fixing one without the other still produces a run that lies: the run reported +// success without making a single provider call, and it counted every Apple +// input in the same Environment and window as "examined" when none of them has a +// purchase token it could have re-queried. +func TestGoogleReconciliationRequeriesOnlyGoogleInputs(t *testing.T) { + pool, ctx := testPool(t) + fixture := newRevalidationFixture(t, ctx, pool, "recon") + + start := time.Now().Add(-48 * time.Hour).UTC().Truncate(time.Second) + fixture.google.subscriptions[fixturePurchaseToken] = + subscriptionPurchase(fixtureProviderProduct, fixtureOrderID, start, start.Add(30*24*time.Hour)) + + googleInput := fixture.ingest(t, ctx, "bri_recon_google", + billing.SourceGoogleRTDN, billing.AuthorityStoreNotification, fixture.credentialID, + rtdnBody(fixturePackageName+"."+suffixOf(fixture.credentialID), fixtureProviderProduct, fixturePurchaseToken), true) + if _, err := fixture.service.ProcessNextValidation(ctx, "worker"); err != nil { + t.Fatal(err) + } + + // An Apple input in the same Environment and window. A Google reconciliation + // must not touch it. + appleInput := sampleInput(fixture.projectID, fixture.environmentID, "", "recon-apple") + appleInput.ID = "bri_recon_apple" + appleInput.BodyState = "stored" + appleBody := []byte(`{"signedPayload":"not-reachable-by-google-reconciliation"}`) + appleEnvelope, err := fixture.cipher.EncryptSubject(appleBody, providercredential.SubjectScope{ + OrganizationID: fixture.organization, ProjectID: fixture.projectID, + SubjectKind: providercredential.SubjectBillingRawInput, SubjectID: appleInput.ID, + CredentialClass: billing.ClassBillingRawPayload, + }) + if err != nil { + t.Fatal(err) + } + appleInput.Envelope = &billing.Envelope{ + Version: appleEnvelope.Version, Algorithm: appleEnvelope.Algorithm, KeyID: appleEnvelope.KeyID, + Nonce: appleEnvelope.Nonce, Ciphertext: appleEnvelope.Ciphertext, Fingerprint: appleEnvelope.Fingerprint, + } + if _, err := New(pool).PersistRawInput(ctx, appleInput, false, time.Now().UTC()); err != nil { + t.Fatal(err) + } + + // A Google observation in the same window. It carries a token digest, which + // cannot be reversed into the token the Play API needs, so a token re-query + // must skip it rather than counting a guaranteed failure on every run. + observationBody, _ := json.Marshal(map[string]string{ + "referenceKind": billing.ReferenceGooglePlayTokenDigest, + "reference": "0000000000000000000000000000000000000000000000000000000000000000", + }) + observationInput := fixture.ingest(t, ctx, "bri_recon_observation", + billing.SourceClientObservation, billing.AuthorityClient, "", observationBody, false) + + callsBefore := fixture.google.subscriptionCalls + appleAttemptsBefore := fixture.attemptCount(t, ctx, appleInput.ID) + observationAttemptsBefore := fixture.attemptCount(t, ctx, observationInput.ID) + + if _, err := fixture.service.CreateReconciliation(ctx, billing.Actor{ID: "actor_owner_recon"}, + billing.ReconciliationRun{ + ProjectID: fixture.projectID, EnvironmentID: fixture.environmentID, + CredentialID: fixture.credentialID, Provider: billing.ProviderGooglePlay, + Strategy: "google_token_requery", + WindowStart: time.Now().Add(-96 * time.Hour).UTC(), + WindowEnd: time.Now().Add(time.Hour).UTC(), + }); err != nil { + t.Fatalf("queue reconciliation: %v", err) + } + if _, err := fixture.service.ProcessNextReconciliation(ctx, "worker"); err != nil { + t.Fatalf("run reconciliation: %v", err) + } + + if got := fixture.google.subscriptionCalls - callsBefore; got != 1 { + t.Fatalf("%d Play API lookups during reconciliation, want 1 — the run reported work it did not do", got) + } + if got := fixture.attemptCount(t, ctx, googleInput.ID); got != 2 { + t.Fatalf("%d attempts on the Google input, want 2 — reconciliation appended none", got) + } + if got := fixture.attemptCount(t, ctx, appleInput.ID); got != appleAttemptsBefore { + t.Fatalf("the Apple input gained %d attempts from a Google reconciliation", + got-appleAttemptsBefore) + } + if got := fixture.attemptCount(t, ctx, observationInput.ID); got != observationAttemptsBefore { + t.Fatalf("a digest-only observation gained %d attempts from a token re-query it can never satisfy", + got-observationAttemptsBefore) + } + + var examined, duplicate, discovered, failure int64 + var status string + if err := pool.QueryRow(ctx, + `SELECT status, examined_count, duplicate_count, discovered_count, failure_count + FROM billing_reconciliation_runs WHERE project_id=$1 AND strategy='google_token_requery'`, + fixture.projectID).Scan(&status, &examined, &duplicate, &discovered, &failure); err != nil { + t.Fatal(err) + } + if examined != 1 { + t.Fatalf("examined_count=%d, want 1 — the provider filter is missing and Apple inputs were counted", examined) + } + if status != "completed" || duplicate != 1 || discovered != 0 || failure != 0 { + t.Fatalf("run reported status=%q duplicate=%d discovered=%d failure=%d", + status, duplicate, discovered, failure) + } +} + +// --------------------------------------------------------------------------- +// Defect 3 +// --------------------------------------------------------------------------- + +// An observation carries no credential of its own, and must still be validated +// against the credential its Environment scope has. +// +// The defect this catches made every observation quarantine as +// credential_unusable before its reference was ever read, which silently made +// the entire SDK-facing surface inert while the endpoint kept answering +// accepted_for_validation. The second half asserts the negative case reports the +// right thing: an Environment with no connection at all must say so, because +// "unusable" sends an operator to rotate a credential that does not exist. +func TestObservationValidatesAgainstEnvironmentScopedCredential(t *testing.T) { + pool, ctx := testPool(t) + fixture := newRevalidationFixture(t, ctx, pool, "obscred") + + start := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + fixture.google.subscriptions[fixturePurchaseToken] = + subscriptionPurchase(fixtureProviderProduct, fixtureOrderID, start, start.Add(30*24*time.Hour)) + fixture.google.orders[fixtureOrderID] = googleplay.Order{ + OrderID: fixtureOrderID, PurchaseToken: fixturePurchaseToken, State: "PROCESSED", + } + + // The body the service builds for an observation: a reference only, no + // credential, no package name. + body, _ := json.Marshal(map[string]string{ + "referenceKind": billing.ReferenceGooglePlayOrderID, + "reference": fixtureOrderID, + "orderReference": fixtureOrderID, + "purchaseToken": "", + "storeEnvironment": billing.StoreUnclassified, + }) + input := fixture.ingest(t, ctx, "bri_observation_fixture", + billing.SourceClientObservation, billing.AuthorityClient, "", body, true) + + if _, err := fixture.service.ProcessNextValidation(ctx, "worker"); err != nil { + t.Fatalf("validate observation: %v", err) + } + + var outcome, diagnostic, credentialID string + if err := pool.QueryRow(ctx, + `SELECT outcome, COALESCE(diagnostic_code,''), COALESCE(credential_id,'') + FROM billing_validation_attempts WHERE raw_input_id=$1`, input.ID). + Scan(&outcome, &diagnostic, &credentialID); err != nil { + t.Fatal(err) + } + if outcome != billing.OutcomeValidated { + t.Fatalf("observation attempt outcome %q (%s), want validated — the credential gate still blocks observations", + outcome, diagnostic) + } + if credentialID != fixture.credentialID { + t.Fatalf("attempt recorded credential %q, want %q — provenance was not stamped", + credentialID, fixture.credentialID) + } + if got := fixture.factCount(t, ctx, input.ID); got != 1 { + t.Fatalf("%d facts from a validated observation, want 1", got) + } + if fixture.google.orderCalls != 1 { + t.Fatalf("%d orders.get calls, want 1 — the order reference was never resolved", fixture.google.orderCalls) + } + + // The negative case: an Environment with no credential must say exactly that. + bare := newRevalidationFixture(t, ctx, pool, "nocred") + if _, err := pool.Exec(ctx, + `DELETE FROM store_server_credential_applications WHERE credential_id=$1`, bare.credentialID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `DELETE FROM store_server_credentials WHERE id=$1`, bare.credentialID); err != nil { + t.Fatal(err) + } + bareInput := bare.ingest(t, ctx, "bri_observation_nocred", + billing.SourceClientObservation, billing.AuthorityClient, "", body, true) + if _, err := bare.service.ProcessNextValidation(ctx, "worker"); err != nil { + t.Fatal(err) + } + var reason, bareDiagnostic string + if err := pool.QueryRow(ctx, + `SELECT q.reason_code, COALESCE(q.diagnostic_code,'') + FROM billing_quarantine_records q WHERE q.raw_input_id=$1`, bareInput.ID). + Scan(&reason, &bareDiagnostic); err != nil { + t.Fatalf("no quarantine record for an observation with no credential: %v", err) + } + if reason != billing.QuarantineMissingCredential { + t.Fatalf("quarantined as %q/%q, want %q — a missing connection is not a broken secret", + reason, bareDiagnostic, billing.QuarantineMissingCredential) + } +} + +// T-6 — the trusted-server Google observation must reach a fact using the token +// it carried. +// +// This is the end-to-end half of BL-1, and it is the only server-actionable +// Google observation path: a client observation carries a digest, a digest +// cannot be reversed, and it correctly quarantines as `purchase_token_unavailable` +// until an RTDN arrives. The token travels handler -> Observation.PurchaseToken +// -> sealed raw body under the key "purchaseToken" -> decodeGoogleWork -> +// GetSubscription. +// +// Every link in that chain is a rename away from silently restoring BL-1 — every +// Google trusted observation dead-ending again — with a green suite, because +// nothing else asserts the token survives the round trip through encryption. +func TestTrustedServerObservationValidatesUsingItsPurchaseToken(t *testing.T) { + pool, ctx := testPool(t) + fixture := newRevalidationFixture(t, ctx, pool, "trustedtok") + + start := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + fixture.google.subscriptions[fixturePurchaseToken] = + subscriptionPurchase(fixtureProviderProduct, fixtureOrderID, start, start.Add(30*24*time.Hour)) + + // Exactly the body the service seals for a trusted-server observation: the + // reference is the token's own digest, and the token itself rides alongside. + digest := hexDigestOf(fixturePurchaseToken) + body, _ := json.Marshal(map[string]string{ + "referenceKind": billing.ReferenceGooglePlayTokenDigest, + "reference": digest, + "orderReference": "", + "purchaseToken": fixturePurchaseToken, + "storeEnvironment": billing.StoreUnclassified, + }) + input := fixture.ingest(t, ctx, "bri_trusted_token", + billing.SourceTrustedServerObservation, billing.AuthorityTrustedServer, "", body, true) + + if _, err := fixture.service.ProcessNextValidation(ctx, "worker"); err != nil { + t.Fatalf("validate trusted-server observation: %v", err) + } + + var outcome, diagnostic string + if err := pool.QueryRow(ctx, + `SELECT outcome, COALESCE(diagnostic_code,'') FROM billing_validation_attempts WHERE raw_input_id=$1`, + input.ID).Scan(&outcome, &diagnostic); err != nil { + t.Fatal(err) + } + if outcome != billing.OutcomeValidated { + t.Fatalf("trusted-server observation outcome %q (%s), want validated — the purchase token did not "+ + "survive the round trip through the sealed body, so BL-1 has regressed", outcome, diagnostic) + } + if got := fixture.factCount(t, ctx, input.ID); got != 1 { + t.Fatalf("%d facts from a validated trusted-server observation, want 1", got) + } + // The token had to be used: no order id was supplied, so orders.get cannot + // have stood in for it. + if fixture.google.orderCalls != 0 { + t.Fatalf("%d orders.get calls; the observation carried a token and needed none", fixture.google.orderCalls) + } + + // The fact must carry the token's digest as the purchase chain, and the raw + // token must not appear anywhere in the ledger or the fact row. + var chainDigest []byte + if err := pool.QueryRow(ctx, + `SELECT purchase_chain_digest FROM billing_transaction_facts WHERE source_raw_input_id=$1`, + input.ID).Scan(&chainDigest); err != nil { + t.Fatal(err) + } + if hexOfBytes(chainDigest) != digest { + t.Fatalf("fact chain digest %s, want %s", hexOfBytes(chainDigest), digest) + } + var leaked int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_ledger_entries WHERE project_id=$1 AND detail::text LIKE '%'||$2||'%'`, + fixture.projectID, fixturePurchaseToken).Scan(&leaked); err != nil { + t.Fatal(err) + } + if leaked != 0 { + t.Fatalf("the raw purchase token appears in %d ledger entries; it must never leave the sealed body", leaked) + } +} + +// T-3 — a wrong-Application input must quarantine. +// +// Tenant and Application isolation is the highest-value property of the intake +// design, and plan §13 names it: "wrong-application and wrong-environment inputs +// quarantine". The environment half is covered by +// TestSandboxFactCannotLandInProductionEnvironment; this is the application +// half. A regression in ApplicationForIdentifier or its wiring would attribute +// another Application's transaction to this credential's Application, silently. +func TestInputForAnUnscopedApplicationQuarantines(t *testing.T) { + pool, ctx := testPool(t) + fixture := newRevalidationFixture(t, ctx, pool, "wrongapp") + + start := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + fixture.google.subscriptions[fixturePurchaseToken] = + subscriptionPurchase(fixtureProviderProduct, fixtureOrderID, start, start.Add(30*24*time.Hour)) + + // The credential no longer scopes any Application, so the package name the + // notification carries cannot be attributed. + if _, err := pool.Exec(ctx, + `DELETE FROM store_server_credential_applications WHERE credential_id=$1`, fixture.credentialID); err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]string{ + "referenceKind": billing.ReferenceGooglePlayTokenDigest, + "reference": hexDigestOf(fixturePurchaseToken), + "purchaseToken": fixturePurchaseToken, + "storeEnvironment": billing.StoreUnclassified, + }) + input := fixture.ingest(t, ctx, "bri_wrong_application", + billing.SourceTrustedServerObservation, billing.AuthorityTrustedServer, "", body, true) + + if _, err := fixture.service.ProcessNextValidation(ctx, "worker"); err != nil { + t.Fatal(err) + } + + var outcome string + if err := pool.QueryRow(ctx, + `SELECT outcome FROM billing_validation_attempts WHERE raw_input_id=$1`, input.ID).Scan(&outcome); err != nil { + t.Fatal(err) + } + if outcome == billing.OutcomeValidated { + t.Fatal("an input whose Application is not scoped to the credential produced a validated attempt; " + + "that is a cross-Application attribution") + } + var reason string + if err := pool.QueryRow(ctx, + `SELECT reason_code FROM billing_quarantine_records WHERE raw_input_id=$1`, input.ID).Scan(&reason); err != nil { + t.Fatalf("no quarantine record for an unscoped Application: %v", err) + } + if reason != billing.QuarantineApplicationMismatch && reason != billing.QuarantineMissingCredential { + t.Fatalf("quarantined as %q; an unscoped Application must be reported as an attribution problem", reason) + } + if got := fixture.factCount(t, ctx, input.ID); got != 0 { + t.Fatalf("%d facts recorded for an unattributable input, want 0", got) + } +} + +func hexDigestOf(token string) string { return hexOfBytes(billing.TokenDigest(token)) } + +func hexOfBytes(value []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, len(value)*2) + for i, b := range value { + out[i*2] = digits[b>>4] + out[i*2+1] = digits[b&0x0f] + } + return string(out) +} diff --git a/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go b/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go new file mode 100644 index 00000000..a8975365 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/fixpass_integration_test.go @@ -0,0 +1,798 @@ +package billingpostgres + +import ( + "context" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// T-2 — `keyring rotate` must reach every envelope-bearing table. +// +// The failure this guards is data destruction via a correct runbook: a table +// with an envelope that the rotation source set does not know about reports +// zero envelopes under a retired key, the operator follows the documented +// rotation procedure and removes that key, and those rows become permanently +// undecryptable. The schema half of the test is the durable half — it fails +// when a *future* envelope table is added and not registered, which is exactly +// the case a round-trip over today's two tables cannot catch. +func TestKeyringRotationCoversEveryEnvelopeTable(t *testing.T) { + pool, ctx := testPool(t) + + // Every table that stores both a key id and a ciphertext is an envelope + // table and must be rotatable. + rows, err := pool.Query(ctx, + `SELECT c.table_name FROM information_schema.columns c + WHERE c.table_schema='public' AND c.column_name='key_id' + AND EXISTS (SELECT 1 FROM information_schema.columns d + WHERE d.table_schema='public' AND d.table_name=c.table_name + AND d.column_name='ciphertext') + ORDER BY c.table_name`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + found := map[string]bool{} + for rows.Next() { + var table string + if err := rows.Scan(&table); err != nil { + t.Fatal(err) + } + found[table] = true + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + + // provider_connection_credentials is rotated by the Provider Connection + // path in cloudworkspacepostgres; the two billing tables are rotated here. + rotatable := map[string]bool{ + "provider_connection_credentials": true, + "store_server_credentials": true, + "billing_raw_inputs": true, + } + for table := range found { + if !rotatable[table] { + t.Fatalf("table %q stores an encryption envelope but no keyring rotation path covers it; "+ + "add it to billingpostgres.EnvelopesNotUnderKey/ReplaceEnvelopes (or the cloudworkspace "+ + "equivalent) before shipping, or a retired key will silently strand its rows", table) + } + } + for _, required := range []string{"store_server_credentials", "billing_raw_inputs"} { + if !found[required] { + t.Fatalf("expected %q to carry an encryption envelope; the schema assertion above is no longer guarding anything", required) + } + } +} + +// The round-trip half: a credential and a raw body sealed under one key must +// still decrypt after being resealed under another. +func TestKeyringRotationResealsBothBillingTables(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "rotate") + now := time.Now().UTC() + + var organizationID string + if err := pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, projectID). + Scan(&organizationID); err != nil { + t.Fatal(err) + } + + keyA := "3q2-7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + keyB := "7v7-3QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + underA, err := providercredential.NewAESGCMCipher( + `{"version":1,"activeKeyId":"key-a","keys":{"key-a":"`+keyA+`","key-b":"`+keyB+`"}}`, randomReader{}) + if err != nil { + t.Fatal(err) + } + underB, err := providercredential.NewAESGCMCipher( + `{"version":1,"activeKeyId":"key-b","keys":{"key-a":"`+keyA+`","key-b":"`+keyB+`"}}`, randomReader{}) + if err != nil { + t.Fatal(err) + } + + credentialID := "ssc_rotate_fixture" + credentialSecret := []byte("-----BEGIN PRIVATE KEY-----fixture-----END PRIVATE KEY-----") + credentialScope := providercredential.SubjectScope{ + OrganizationID: organizationID, ProjectID: projectID, + SubjectKind: providercredential.SubjectStoreServerCredential, SubjectID: credentialID, + CredentialClass: billing.ClassAppleInAppPurchaseKey, + } + sealed, err := underA.EncryptSubject(credentialSecret, credentialScope) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO store_server_credentials( + id, project_id, organization_id, environment_id, environment_mode, provider, store_environment, + name, status, health_status, credential_class, envelope_version, algorithm, key_id, nonce, + ciphertext, fingerprint, apple_issuer_id, apple_key_id, intake_token_digest, + created_by_actor_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,'production','app_store','production','Rotate','active','untested',$5, + $6,$7,$8,$9::bytea,$10::bytea,$11::bytea,'fixture-issuer','fixture-key',$12::bytea,'actor',$13,$13)`, + credentialID, projectID, organizationID, environmentID, billing.ClassAppleInAppPurchaseKey, + sealed.Version, sealed.Algorithm, sealed.KeyID, sealed.Nonce, sealed.Ciphertext, sealed.Fingerprint, + billing.TokenDigest("fixture-rotate-intake"), now); err != nil { + t.Fatal(err) + } + + // A raw input with a retained, sealed body. + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-rotate") + body := []byte(`{"signedPayload":"fixture-rotate-body"}`) + bodyEnvelope, err := underA.EncryptSubject(body, providercredential.SubjectScope{ + OrganizationID: organizationID, ProjectID: projectID, + SubjectKind: providercredential.SubjectBillingRawInput, SubjectID: "bri_rotate_fixture", + CredentialClass: billing.ClassBillingRawPayload, + }) + if err != nil { + t.Fatal(err) + } + input.ID = "bri_rotate_fixture" + input.OrganizationID = organizationID + input.BodyState = "stored" + input.Envelope = &billing.Envelope{ + Version: bodyEnvelope.Version, Algorithm: bodyEnvelope.Algorithm, KeyID: bodyEnvelope.KeyID, + Nonce: bodyEnvelope.Nonce, Ciphertext: bodyEnvelope.Ciphertext, Fingerprint: bodyEnvelope.Fingerprint, + } + if _, err := repository.PersistRawInput(ctx, input, false, now); err != nil { + t.Fatal(err) + } + + // inspect must see both under the retired key. + counts, err := repository.EnvelopeCountsByKeyID(ctx) + if err != nil { + t.Fatal(err) + } + if counts["key-a"] < 2 { + t.Fatalf("keyring inspect reports %d envelope(s) under the retired key, want at least 2 "+ + "(a credential and a raw body); an under-report is what makes a documented rotation destructive", counts["key-a"]) + } + + // rotate: page, reseal under key B, write back. + envelopes, err := repository.EnvelopesNotUnderKey(ctx, "key-b", 100) + if err != nil { + t.Fatal(err) + } + tables := map[string]bool{} + resealed := make([]BillingEnvelope, 0, len(envelopes)) + for _, envelope := range envelopes { + tables[envelope.Table] = true + plaintext, err := underB.DecryptSubject(providercredential.Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, + CredentialClass: envelope.CredentialClass, Fingerprint: envelope.Fingerprint, + }, envelope.Scope()) + if err != nil { + t.Fatalf("%s/%s could not be decrypted during rotation: %v", envelope.Table, envelope.RowID, err) + } + sealed, err := underB.EncryptSubject(plaintext, envelope.Scope()) + if err != nil { + t.Fatal(err) + } + envelope.Version, envelope.Algorithm, envelope.KeyID = sealed.Version, sealed.Algorithm, sealed.KeyID + envelope.Nonce, envelope.Ciphertext, envelope.Fingerprint = sealed.Nonce, sealed.Ciphertext, sealed.Fingerprint + resealed = append(resealed, envelope) + } + if !tables["store_server_credentials"] || !tables["billing_raw_inputs"] { + t.Fatalf("rotation paged over %v; both billing envelope tables must appear", tables) + } + if err := repository.ReplaceEnvelopes(ctx, resealed, now); err != nil { + t.Fatal(err) + } + + // Both must still open, now under the new key. + credential, envelope, class, orgID, _, err := repository.CredentialSecretFor(ctx, projectID, credentialID) + if err != nil { + t.Fatal(err) + } + if envelope.KeyID != "key-b" { + t.Fatalf("credential still sealed under %q after rotation", envelope.KeyID) + } + opened, err := underB.DecryptSubject(providercredential.Envelope{ + Version: envelope.Version, Algorithm: envelope.Algorithm, KeyID: envelope.KeyID, + Nonce: envelope.Nonce, Ciphertext: envelope.Ciphertext, + CredentialClass: class, Fingerprint: envelope.Fingerprint, + }, providercredential.SubjectScope{ + OrganizationID: orgID, ProjectID: projectID, + SubjectKind: providercredential.SubjectStoreServerCredential, SubjectID: credential.ID, + CredentialClass: class, + }) + if err != nil || string(opened) != string(credentialSecret) { + t.Fatalf("credential did not survive rotation: %v", err) + } + + stored, err := repository.RawInput(ctx, projectID, input.ID) + if err != nil { + t.Fatal(err) + } + if stored.Envelope == nil || stored.Envelope.KeyID != "key-b" { + t.Fatal("raw body was not resealed under the new key") + } + openedBody, err := underB.DecryptSubject(providercredential.Envelope{ + Version: stored.Envelope.Version, Algorithm: stored.Envelope.Algorithm, KeyID: stored.Envelope.KeyID, + Nonce: stored.Envelope.Nonce, Ciphertext: stored.Envelope.Ciphertext, + CredentialClass: billing.ClassBillingRawPayload, Fingerprint: stored.Envelope.Fingerprint, + }, providercredential.SubjectScope{ + OrganizationID: organizationID, ProjectID: projectID, + SubjectKind: providercredential.SubjectBillingRawInput, SubjectID: input.ID, + CredentialClass: billing.ClassBillingRawPayload, + }) + if err != nil || string(openedBody) != string(body) { + t.Fatalf("raw body did not survive rotation: %v", err) + } +} + +// randomReader is a deterministic nonce source. Nonce uniqueness is not what +// these tests are about, and a fixed source keeps them reproducible. +type randomReader struct{} + +func (randomReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte(i*7 + 3) + } + return len(p), nil +} + +// BL-2 — a live lease must never be stolen. +// +// Without the guard, an operator's quarantine retry could take the lease while +// ProcessNextValidation was mid-flight inside a provider call. Both paths would +// then read the same NextAttemptNumber, one CompleteAttempt would abort on +// UNIQUE (raw_input_id, attempt_number), and the losing side would discard its +// attempt, fact, resolution snapshot and ledger entries while reporting a +// failure it did not cause. +func TestLeaseValidationJobForDoesNotStealALiveLease(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "lease") + now := time.Now().UTC() + + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-lease") + // ReplayInputs only returns inputs whose body is retained, because an + // expired body cannot be revalidated. + input.BodyState = "stored" + input.Envelope = &billing.Envelope{ + Version: 1, Algorithm: "AES-256-GCM", KeyID: "key-a", + Nonce: make([]byte, 12), Ciphertext: make([]byte, 32), Fingerprint: make([]byte, 32), + } + if _, err := repository.PersistRawInput(ctx, input, true, now); err != nil { + t.Fatal(err) + } + stored, err := repository.ReplayInputsForTest(ctx, projectID, environmentID) + if err != nil || len(stored) != 1 { + t.Fatalf("expected one stored input, got %d (%v)", len(stored), err) + } + held := stored[0] + + // The ordinary worker takes the lease first. + leased, ok, err := repository.LeaseValidationJob(ctx, "worker-a", now, now.Add(2*time.Minute)) + if err != nil || !ok { + t.Fatalf("worker-a could not lease: ok=%v err=%v", ok, err) + } + + // A replay or quarantine retry now asks for the same input while the lease + // is live. It must be refused. + if _, err := repository.LeaseValidationJobFor(ctx, "worker-b", held, now, now.Add(2*time.Minute)); err != billing.ErrValidationBusy { + t.Fatalf("a live lease was stolen: err=%v", err) + } + + // The original lease is untouched. + var owner string + if err := pool.QueryRow(ctx, `SELECT lease_owner FROM billing_validation_jobs WHERE id=$1`, leased.ID). + Scan(&owner); err != nil { + t.Fatal(err) + } + if owner != "worker-a" { + t.Fatalf("lease owner is %q, want worker-a", owner) + } + + // Once the lease has expired, takeover is correct: a worker that died must + // not strand the input forever. + afterExpiry := now.Add(3 * time.Minute) + if _, err := repository.LeaseValidationJobFor(ctx, "worker-b", held, afterExpiry, afterExpiry.Add(2*time.Minute)); err != nil { + t.Fatalf("an expired lease was not taken over: %v", err) + } +} + +// BL-3 — reconciliation discovery must persist in a staging Environment. +// +// The mode was previously derived from the Store Environment, which yields only +// "production" or "development", so every discovery in a staging Environment +// failed the composite FK onto environments(id, project_id, mode), incremented +// a failure counter silently, and left the run reporting `partial` with no +// diagnostic — the recovery path failing in exactly the outage it exists to +// repair. +func TestReconciliationDiscoveryPersistsInStagingEnvironment(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, _, applicationID := seed(t, ctx, pool, "staging") + now := time.Now().UTC() + + stagingID := "env_billing_staging_extra" + if _, err := pool.Exec(ctx, + `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`, + stagingID, projectID, now); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanupContext, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, _ = pool.Exec(cleanupContext, `DELETE FROM billing_validation_jobs WHERE environment_id=$1`, stagingID) + _, _ = pool.Exec(cleanupContext, `DELETE FROM billing_ledger_entries WHERE environment_id=$1`, stagingID) + _, _ = pool.Exec(cleanupContext, `DELETE FROM billing_raw_inputs WHERE environment_id=$1`, stagingID) + _, _ = pool.Exec(cleanupContext, `DELETE FROM environments WHERE id=$1`, stagingID) + }) + + mode, organizationID, err := repository.EnvironmentScope(ctx, projectID, stagingID) + if err != nil { + t.Fatal(err) + } + if mode != "staging" { + t.Fatalf("EnvironmentScope reported mode %q, want staging", mode) + } + + // A sandbox-classified discovery inside a staging Environment: the exact + // combination the derived mode could not express. + input := sampleInput(projectID, stagingID, applicationID, "fixture-uuid-staging") + input.EnvironmentMode = mode + input.OrganizationID = organizationID + input.StoreEnvironment = billing.StoreSandbox + input.Source = billing.SourceAppleNotificationHistory + input.SourceAuthority = billing.AuthorityStoreReconciliation + result, err := repository.PersistRawInput(ctx, input, true, now) + if err != nil { + t.Fatalf("a staging reconciliation discovery could not be persisted: %v", err) + } + if result.Status != billing.IngestAccepted { + t.Fatalf("discovery status %q, want accepted", result.Status) + } +} + +// BL-4 — a window larger than one batch must be walked to the end. +// +// Reporting `completed` over a silently partial scan is worse than a failure in +// an evidence system: the operator concludes the window is verified. The cursor +// is a keyset over (received_at, id), so this also pins that resuming neither +// skips nor repeats a row. +func TestReplayInputsWalkTheWholeWindowByCursor(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "cursor") + base := time.Now().UTC().Add(-time.Hour) + + const total = 7 + for index := range total { + input := sampleInput(projectID, environmentID, applicationID, + "fixture-uuid-cursor-"+strings.Repeat("x", index+1)) + input.ReceivedAt = base.Add(time.Duration(index) * time.Second) + input.ExpiresAt = input.ReceivedAt.Add(90 * 24 * time.Hour) + input.BodyState = "stored" + input.Envelope = &billing.Envelope{ + Version: 1, Algorithm: "AES-256-GCM", KeyID: "key-a", + Nonce: make([]byte, 12), Ciphertext: make([]byte, 32), Fingerprint: make([]byte, 32), + } + if _, err := repository.PersistRawInput(ctx, input, false, input.ReceivedAt); err != nil { + t.Fatal(err) + } + } + + windowStart := base.Add(-time.Minute) + windowEnd := base.Add(time.Hour) + job := billing.ReplayJob{ + ProjectID: projectID, EnvironmentID: environmentID, + WindowStart: &windowStart, WindowEnd: &windowEnd, + } + + // Page through with a batch smaller than the window, as the worker does. + const pageSize = 3 + seen := map[string]int{} + cursor := billing.InputCursor{} + passes := 0 + for { + passes++ + if passes > 10 { + t.Fatal("the cursor never reached the end of the window") + } + inputs, next, err := repository.ReplayInputs(ctx, job, billing.InputFilter{}, cursor, pageSize) + if err != nil { + t.Fatal(err) + } + for _, input := range inputs { + seen[input.ID]++ + } + if len(inputs) < pageSize { + break + } + if !next.Set() { + t.Fatal("a full page returned no cursor, so the next pass would restart from the beginning") + } + cursor = next + } + + if len(seen) != total { + t.Fatalf("walked %d input(s) of %d; a window larger than one batch was truncated", len(seen), total) + } + for id, count := range seen { + if count != 1 { + t.Fatalf("input %s was examined %d times; the keyset cursor repeated a row", id, count) + } + } +} + +// P1 — disabling Mosaic Billing must be refused while a credential is live. +// +// Without the rule the switch does not mean what it says: Apple keeps posting +// to an endpoint whose intake token still resolves, and every refusal spends +// one of five non-renewable delivery attempts. +func TestBillingCannotBeDisabledWhileCredentialsAreActive(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, _ := seed(t, ctx, pool, "disable") + now := time.Now().UTC() + + 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_disable','owner',$2,$2) + ON CONFLICT (organization_id,actor_id) DO UPDATE SET role=EXCLUDED.role`, + organizationID, now); 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_disable'`, organizationID) + }) + actor := billing.Actor{ID: "actor_owner_disable"} + + if err := repository.SetBillingEnabled(ctx, actor, projectID, true, now); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO store_server_credentials( + id, project_id, organization_id, environment_id, environment_mode, provider, store_environment, + name, status, health_status, credential_class, envelope_version, algorithm, key_id, nonce, + ciphertext, fingerprint, apple_issuer_id, apple_key_id, intake_token_digest, + created_by_actor_id, created_at, updated_at) + VALUES ('ssc_disable_fixture',$1,$2,$3,'production','app_store','production','Live','active','untested', + $4,1,'AES-256-GCM','key-a',$5::bytea,$6::bytea,$7::bytea,'fixture-issuer','fixture-key',$8::bytea,'actor',$9,$9)`, + projectID, organizationID, environmentID, billing.ClassAppleInAppPurchaseKey, + make([]byte, 12), make([]byte, 32), make([]byte, 32), + billing.TokenDigest("fixture-disable-intake"), now); err != nil { + t.Fatal(err) + } + + if err := repository.SetBillingEnabled(ctx, actor, projectID, false, now); err != billing.ErrCredentialsStillActive { + t.Fatalf("billing was disabled with a live credential: %v", err) + } + enabled, err := repository.BillingEnabled(ctx, projectID) + if err != nil || !enabled { + t.Fatal("the refused disable still changed the setting") + } + + // Revoking the credential is what actually stops the store, and it unblocks + // the switch. + if _, err := pool.Exec(ctx, + `UPDATE store_server_credentials SET status='revoked', health_status='revoked', + revoked_at=$1, intake_token_digest=NULL, updated_at=$1 WHERE id='ssc_disable_fixture'`, now); err != nil { + t.Fatal(err) + } + if err := repository.SetBillingEnabled(ctx, actor, projectID, false, now); err != nil { + t.Fatalf("billing could not be disabled after revocation: %v", err) + } + enabled, err = repository.BillingEnabled(ctx, projectID) + if err != nil || enabled { + t.Fatal("billing remained enabled after a permitted disable") + } +} + +// Revoking an Apple credential must clear its intake token. +// +// Clearing the digest is what actually stops the notification endpoint +// resolving, and it is the whole point of revoking after a suspected +// compromise. The Apple shape CHECK originally required the digest on every +// app_store row regardless of status, so the revoke UPDATE failed outright and +// an operator responding to a leaked intake token had no way to close it. This +// exercises the real repository path rather than a hand-written UPDATE. +func TestRevokingAnAppleCredentialClearsItsIntakeToken(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, _ := seed(t, ctx, pool, "revoke") + now := time.Now().UTC() + + 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_revoke','owner',$2,$2) + ON CONFLICT (organization_id,actor_id) DO UPDATE SET role=EXCLUDED.role`, + organizationID, now); 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_revoke'`, organizationID) + }) + + digest := billing.TokenDigest("fixture-revoke-intake") + if _, err := pool.Exec(ctx, + `INSERT INTO store_server_credentials( + id, project_id, organization_id, environment_id, environment_mode, provider, store_environment, + name, status, health_status, credential_class, envelope_version, algorithm, key_id, nonce, + ciphertext, fingerprint, apple_issuer_id, apple_key_id, intake_token_digest, + created_by_actor_id, created_at, updated_at) + VALUES ('ssc_revoke_fixture',$1,$2,$3,'production','app_store','production','Live','active','untested', + $4,1,'AES-256-GCM','key-a',$5::bytea,$6::bytea,$7::bytea,'fixture-issuer','fixture-key',$8::bytea,'actor',$9,$9)`, + projectID, organizationID, environmentID, billing.ClassAppleInAppPurchaseKey, + make([]byte, 12), make([]byte, 32), make([]byte, 32), digest, now); err != nil { + t.Fatal(err) + } + + // The token resolves while the credential is live. + if _, err := repository.ResolveIntakeToken(ctx, digest); err != nil { + t.Fatalf("a live intake token did not resolve: %v", err) + } + + actor := billing.Actor{ID: "actor_owner_revoke"} + revoked, err := repository.RevokeCredential(ctx, actor, projectID, "ssc_revoke_fixture", now) + if err != nil { + t.Fatalf("an Apple credential could not be revoked: %v", err) + } + if revoked.Status != "revoked" { + t.Fatalf("credential status is %q after revocation", revoked.Status) + } + + // The endpoint must stop resolving: that is what revocation buys. + if _, err := repository.ResolveIntakeToken(ctx, digest); err != billing.ErrNotFound { + t.Fatalf("the intake token still resolves after revocation: %v", err) + } +} + +// M-3 — unverified input on the unlimited endpoint must not grow without bound. +// +// The Apple notification endpoint deliberately has no rate limiter: a 429 to +// Apple spends one of five non-renewable delivery attempts. That makes anything +// keyed by content digest an unbounded write vector, because an intake token is +// an unauthenticated bearer value in a URL. Collapsing unverified inputs onto +// (credential, reason, hour) bounds the growth while keeping what an operator +// acts on. Repeats must land as duplicates, not as content conflicts — a +// conflict is a security-severity signal and this is not one. +func TestUnverifiedInputsCollapseOntoAnHourlyBucket(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, _ := seed(t, ctx, pool, "unverified") + now := time.Now().UTC().Truncate(time.Hour).Add(5 * time.Minute) + + // Three distinct garbage bodies inside one hour, as an attacker posting to a + // leaked intake token would produce. + bucket := now.Truncate(time.Hour).Format(time.RFC3339) + key := billing.UnverifiedInputKey("ssc_unverified_fixture", "malformed_body", bucket) + build := func(at time.Time, bucketKey []byte) billing.RawInput { + input := sampleInput(projectID, environmentID, "", "ignored") + input.ID = "" + input.ApplicationID = "" + input.CredentialID = "" + input.IdempotencyKey = bucketKey + input.ContentDigest = bucketKey + input.BodyState = "not_retained" + input.Envelope = nil + input.AuthenticationResult = billing.AuthFailed + input.StoreEnvironment = billing.StoreUnclassified + input.IngestionStatus = billing.IngestQuarantined + input.NotificationKind = "" + input.ReceivedAt = at + input.ExpiresAt = at.Add(90 * 24 * time.Hour) + return input + } + + for index := range 3 { + at := now.Add(time.Duration(index) * time.Minute) + result, err := repository.PersistRawInput(ctx, build(at, key), false, at) + if err != nil { + t.Fatal(err) + } + if result.Conflicted { + t.Fatal("a repeated unverified input was reported as a content conflict; " + + "conflicts are a security signal and garbage on an open endpoint is not one") + } + if index > 0 && result.Status != billing.IngestDuplicate { + t.Fatalf("repeat %d reported %q, want duplicate", index, result.Status) + } + } + + var rows int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_raw_inputs WHERE project_id=$1 AND authentication_result='failed'`, + projectID).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("%d unverified rows after three distinct malformed bodies in one hour, want 1", rows) + } + + // Q-2 — the ledger must be capped too. Bucketing the raw input while the + // duplicate-detected ledger entry still carried the instant simply handed + // the unbounded write one table over, on the same unlimited endpoint. + var ledgerRows int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_ledger_entries + WHERE project_id=$1 AND entry_type='input_duplicate_detected'`, projectID).Scan(&ledgerRows); err != nil { + t.Fatal(err) + } + if ledgerRows > 1 { + t.Fatalf("%d duplicate-detected ledger entries from repeats inside one hour, want at most 1; "+ + "the raw-input cap accomplishes nothing if the ledger grows instead", ledgerRows) + } + + // A different hour, or a different reason, is genuinely different + // information and gets its own row. + nextHour := now.Add(time.Hour) + nextKey := billing.UnverifiedInputKey("ssc_unverified_fixture", "malformed_body", + nextHour.Truncate(time.Hour).Format(time.RFC3339)) + if _, err := repository.PersistRawInput(ctx, build(nextHour, nextKey), false, nextHour); err != nil { + t.Fatal(err) + } + otherReason := billing.UnverifiedInputKey("ssc_unverified_fixture", "signature_invalid", bucket) + if _, err := repository.PersistRawInput(ctx, build(now, otherReason), false, now); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM billing_raw_inputs WHERE project_id=$1 AND authentication_result='failed'`, + projectID).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 3 { + t.Fatalf("%d unverified rows across two hours and two reasons, want 3", rows) + } +} + +// T-7 / Q-1 — a billing list cursor must walk the list to exhaustion. +// +// Every billing list orders by a timestamp, and billing identifiers are +// deliberately not time-ordered: `Service.newID` is sixteen random bytes and +// quarantine ids are a SHA-256 prefix. A cursor carrying only the id therefore +// cannot express "after this row in timestamp order" — the previous +// implementation emitted the last row's id and applied `id < $cursor` against a +// timestamp ordering, so page two returned whatever happened to sort low by +// random id and silently dropped the rest. +// +// This matters most on the quarantine queue, whose entire job is surfacing +// inputs that need attention: a paging control that hides records is worse than +// no control, because the operator believes they have seen everything. +// +// The ids below are chosen so that id order and timestamp order actively +// disagree, which is what the random-id production case does in aggregate. +func TestQuarantineListCursorWalksEveryRecord(t *testing.T) { + pool, ctx := testPool(t) + repository := New(pool) + projectID, environmentID, applicationID := seed(t, ctx, pool, "paging") + base := time.Now().UTC().Add(-time.Hour) + + // Descending by last_attempt_at: zz, aa, mm, bb, yy. Ascending by id: + // aa, bb, mm, yy, zz. The two orders share no prefix. + order := []string{"zz", "aa", "mm", "bb", "yy"} + expected := make(map[string]bool, len(order)) + for index, suffix := range order { + at := base.Add(-time.Duration(index) * time.Minute) + input := sampleInput(projectID, environmentID, applicationID, "fixture-uuid-paging-"+suffix) + input.ReceivedAt = at + input.ExpiresAt = at.Add(90 * 24 * time.Hour) + input.IngestionStatus = billing.IngestQuarantined + result, err := repository.PersistRawInput(ctx, input, false, at) + if err != nil { + t.Fatal(err) + } + // Force the quarantine id and its ordering timestamp so the disagreement + // between the two orders is deterministic rather than incidental. + recordID := "bqr_" + suffix + if _, err := pool.Exec(ctx, + `UPDATE billing_quarantine_records SET id=$1, last_attempt_at=$2, first_seen_at=$2 + WHERE raw_input_id=$3`, recordID, at, result.RawInputID); err != nil { + t.Fatal(err) + } + expected[recordID] = true + } + + 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_paging','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_paging'`, organizationID) + }) + actor := billing.Actor{ID: "actor_owner_paging"} + + // Page at two, as an operator clicking "Next page" would. + seen := map[string]int{} + cursor := "" + var previous time.Time + for page := 0; ; page++ { + if page > 10 { + t.Fatal("the cursor never exhausted a five-record list") + } + result, err := repository.ListQuarantine(ctx, actor, projectID, environmentID, + billing.ListOptions{Limit: 2, Cursor: cursor}) + if err != nil { + t.Fatal(err) + } + for _, record := range result.Items { + seen[record.ID]++ + // Ordering must stay monotonic across the page boundary, otherwise + // the cursor is resuming from the wrong place even if the counts + // happen to add up. + if !previous.IsZero() && record.LastAttemptAt.After(previous) { + t.Fatalf("record %s (%s) sorted after %s: paging broke the ordering", + record.ID, record.LastAttemptAt, previous) + } + previous = record.LastAttemptAt + } + if result.NextCursor == "" { + break + } + if result.NextCursor == cursor { + t.Fatal("the cursor did not advance; paging would loop forever") + } + cursor = result.NextCursor + } + + if len(seen) != len(expected) { + t.Fatalf("paging surfaced %d of %d quarantine records; a paging control that hides "+ + "records from the security queue is worse than no control", len(seen), len(expected)) + } + for id := range expected { + if seen[id] != 1 { + t.Fatalf("record %s was returned %d times across the walk, want exactly 1", 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. +func TestListCursorIsOpaqueAndFailsSafe(t *testing.T) { + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + encoded := encodeCursor(at, "bqr_fixture") + if strings.Contains(encoded, "bqr_fixture") || strings.Contains(encoded, ":") { + t.Fatalf("cursor %q exposes its internals; callers will start parsing it", encoded) + } + decoded := decodeCursor(encoded) + if decoded.At == nil || !decoded.At.Equal(at) || decoded.ID != "bqr_fixture" { + t.Fatalf("cursor did not round-trip: %+v", decoded) + } + for _, malformed := range []string{"", " ", "not-base64!!", "bqr_raw_id", + base64.RawURLEncoding.EncodeToString([]byte("no-colon")), + base64.RawURLEncoding.EncodeToString([]byte("notanumber:bqr_x")), + base64.RawURLEncoding.EncodeToString([]byte("123:"))} { + if got := decodeCursor(malformed); got.At != nil { + t.Fatalf("malformed cursor %q decoded to a position (%+v); it must start from the beginning", + malformed, got) + } + } +} diff --git a/apps/api/internal/platform/billingpostgres/jobs.go b/apps/api/internal/platform/billingpostgres/jobs.go new file mode 100644 index 00000000..d5f1af70 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/jobs.go @@ -0,0 +1,421 @@ +package billingpostgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +// LeaseValidationJob claims one job with SELECT ... FOR UPDATE SKIP LOCKED, +// matching the pattern the analytics and Experiment queues already use so all +// three behave identically under concurrency. +func (r *Repository) LeaseValidationJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (billing.ValidationJob, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.ValidationJob{}, false, fmt.Errorf("begin validation lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var job billing.ValidationJob + err = tx.QueryRow(ctx, + `SELECT id, project_id, environment_id, raw_input_id, provider, attempt_count, max_attempts + FROM billing_validation_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, &job.EnvironmentID, &job.RawInputID, &job.Provider, + &job.AttemptCount, &job.MaxAttempts) + if errors.Is(err, pgx.ErrNoRows) { + return billing.ValidationJob{}, false, nil + } + if err != nil { + return billing.ValidationJob{}, false, fmt.Errorf("select validation job: %w", err) + } + if _, err := tx.Exec(ctx, + `UPDATE billing_validation_jobs + SET status='leased', lease_owner=$2, lease_expires_at=$3, attempt_count=attempt_count+1, updated_at=$4 + WHERE id=$1`, job.ID, workerID, leaseUntil, now); err != nil { + return billing.ValidationJob{}, false, fmt.Errorf("lease validation job: %w", err) + } + job.AttemptCount++ + if err := tx.Commit(ctx); err != nil { + return billing.ValidationJob{}, false, fmt.Errorf("commit validation lease: %w", err) + } + return job, true, nil +} + +// LeaseValidationJobFor claims the validation job for one named input, creating +// it if this input has never been queued. +// +// The row is written already leased. That matters: if it were written as +// 'queued' the ordinary validation worker could claim it between this statement +// and the caller's own run, and the replay or reconciliation that asked for the +// work would attribute an outcome it never produced. +// +// Taking the lease is not, however, permission to take it *from someone*. The +// DO UPDATE is guarded so a live lease is never stolen: takeover happens only +// when the existing lease has expired or the job is already terminal. Without +// the guard, an operator's quarantine retry could overwrite lease_owner while +// ProcessNextValidation was mid-flight inside an eight-second provider call; +// both paths would then read the same NextAttemptNumber before either +// committed, one CompleteAttempt would abort on +// UNIQUE (raw_input_id, attempt_number), and the losing side would silently +// discard its attempt, fact, resolution snapshot and ledger entries while +// reporting a failure it did not cause. +// +// Zero rows updated means busy, and the caller reports that rather than +// proceeding. attempt_count is reset on a successful takeover because a +// caller-initiated revalidation is a fresh budget, exactly as an operator's +// quarantine retry is; the attempt history itself is append-only and unaffected. +func (r *Repository) LeaseValidationJobFor(ctx context.Context, workerID string, input billing.RawInput, now, leaseUntil time.Time) (billing.ValidationJob, error) { + job := billing.ValidationJob{ + ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, + RawInputID: input.ID, Provider: input.Provider, + MaxAttempts: billing.MaxValidationAttempts, + } + err := r.pool.QueryRow(ctx, + `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,$5,'leased',1,$6,$7,$8,$9,$7,$7) + ON CONFLICT (raw_input_id) DO UPDATE + SET status='leased', attempt_count=1, available_at=$7, + lease_owner=$8, lease_expires_at=$9, updated_at=$7 + WHERE billing_validation_jobs.status <> 'leased' + OR billing_validation_jobs.lease_expires_at IS NULL + OR billing_validation_jobs.lease_expires_at <= $7 + RETURNING id, attempt_count, max_attempts`, + "bvj_"+hashID(input.ID, "revalidate", now), input.ProjectID, input.EnvironmentID, input.ID, + input.Provider, billing.MaxValidationAttempts, now, workerID, leaseUntil). + Scan(&job.ID, &job.AttemptCount, &job.MaxAttempts) + if errors.Is(err, pgx.ErrNoRows) { + // The ON CONFLICT WHERE clause suppressed the update: another worker + // holds a live lease on this input. + return billing.ValidationJob{}, billing.ErrValidationBusy + } + if err != nil { + return billing.ValidationJob{}, fmt.Errorf("lease validation job for input: %w", err) + } + return job, nil +} + +// ReplayInputsForTest lists this Environment's stored inputs. It exists so the +// lease tests can obtain a real RawInput without duplicating the scan query. +func (r *Repository) ReplayInputsForTest(ctx context.Context, projectID, environmentID string) ([]billing.RawInput, error) { + inputs, _, err := r.ReplayInputs(ctx, billing.ReplayJob{ProjectID: projectID, EnvironmentID: environmentID}, + billing.InputFilter{}, billing.InputCursor{}, 10) + return inputs, err +} + +// FactDigestsForInput reads the fact digests already on record for one input. +func (r *Repository) FactDigestsForInput(ctx context.Context, projectID, rawInputID string) ([]string, error) { + rows, err := r.pool.Query(ctx, + `SELECT encode(fact_digest,'hex') FROM billing_transaction_facts + WHERE project_id=$1 AND source_raw_input_id=$2`, projectID, rawInputID) + if err != nil { + return nil, fmt.Errorf("read fact digests for input: %w", err) + } + defer rows.Close() + digests := make([]string, 0, 2) + for rows.Next() { + var digest string + if err := rows.Scan(&digest); err != nil { + return nil, fmt.Errorf("scan fact digest: %w", err) + } + digests = append(digests, digest) + } + return digests, rows.Err() +} + +// ParkValidationJob returns a leased job to the queue without recording an +// attempt and without consuming one from the budget. +// +// It exists for conditions that are not failures and not the input's fault — +// today, a Project whose owner turned billing off. Recording a failed attempt +// would put a diagnostic in the append-only ledger about a decision the +// operator made deliberately, and consuming an attempt would mean re-enabling +// billing left the input with a depleted budget. +func (r *Repository) ParkValidationJob(ctx context.Context, job billing.ValidationJob, reason string, now time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE billing_validation_jobs + SET status='queued', attempt_count=GREATEST(attempt_count-1,0), available_at=$2, + lease_owner=NULL, lease_expires_at=NULL, last_error_code=NULLIF($3,''), updated_at=$2 + WHERE id=$1`, job.ID, now.Add(parkedRetryDelay), reason) + if err != nil { + return fmt.Errorf("park validation job: %w", err) + } + return nil +} + +// parkedRetryDelay keeps a parked job from spinning the worker loop. +const parkedRetryDelay = 5 * time.Minute + +func (r *Repository) NextAttemptNumber(ctx context.Context, rawInputID string) (int, error) { + var next int + err := r.pool.QueryRow(ctx, + `SELECT COALESCE(max(attempt_number), 0) + 1 FROM billing_validation_attempts WHERE raw_input_id = $1`, + rawInputID).Scan(&next) + if err != nil { + return 0, fmt.Errorf("read next attempt number: %w", err) + } + return next, nil +} + +// CompleteAttempt writes everything one attempt produced in a single +// transaction. +// +// The attempt, its Resolution Snapshot, its Transaction Fact, its ledger +// entries, its quarantine record, and the queue transition either all land or +// none do. Splitting them would allow a fact with no attempt behind it, or a +// completed job with no record of why — both of which break the guarantee that +// the ledger is a complete account of what the pipeline did. +func (r *Repository) CompleteAttempt(ctx context.Context, job billing.ValidationJob, outcome billing.AttemptOutcome, now time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin attempt commit: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + attempt := outcome.Attempt + if _, err := tx.Exec(ctx, + `INSERT INTO billing_validation_attempts( + id, project_id, environment_id, raw_input_id, credential_id, attempt_number, validator_version, + started_at, completed_at, outcome, retryable, failure_category, diagnostic_code, provider_code, + provider_http_status, store_environment, latency_ms, replay_of_attempt_id, correlation_id) + VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,NULLIF($12,''),NULLIF($13,''),NULLIF($14,''), + NULLIF($15,0),$16,$17,NULLIF($18,''),$19)`, + attempt.ID, attempt.ProjectID, attempt.EnvironmentID, attempt.RawInputID, attempt.CredentialID, + attempt.AttemptNumber, attempt.ValidatorVersion, attempt.StartedAt, attempt.CompletedAt, + attempt.Outcome, attempt.Retryable, attempt.FailureCategory, attempt.DiagnosticCode, + attempt.ProviderCode, attempt.ProviderHTTPStatus, attempt.StoreEnvironment, attempt.LatencyMs, + attempt.ReplayOfAttemptID, attempt.CorrelationID); err != nil { + return fmt.Errorf("insert validation attempt: %w", err) + } + + if record := outcome.Resolution; record != nil { + if _, err := tx.Exec(ctx, + `INSERT INTO billing_product_resolutions( + id, project_id, environment_id, application_id, validation_attempt_id, raw_input_id, provider, + provider_product_identifier, provider_base_plan_identifier, provider_offer_identifier, + outcome, resolution_state, mosaic_product_id, provider_product_mapping_id, matched_mapping_id, + mapping_version, candidate_count, diagnostic_code, occurred_at, resolved_at) + VALUES ($1,$2,$3,NULLIF($4,''),$5,$6,$7,$8,NULLIF($9,''),NULLIF($10,''),$11,NULLIF($12,''), + NULLIF($13,''),NULLIF($14,''),NULLIF($15,''),$16,$17,NULLIF($18,''),$19,$20)`, + record.ID, record.ProjectID, record.EnvironmentID, record.ApplicationID, record.ValidationAttemptID, + record.RawInputID, record.Provider, record.ProviderProductIdentifier, + record.ProviderBasePlanIdentifier, record.ProviderOfferIdentifier, record.Outcome, + record.ResolutionState, record.MosaicProductID, record.ProviderProductMappingID, + record.MatchedMappingID, record.MappingVersion, record.CandidateCount, record.DiagnosticCode, + record.OccurredAt, record.ResolvedAt); err != nil { + return fmt.Errorf("insert product resolution: %w", err) + } + } + + 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) + if err != nil { + return fmt.Errorf("append transaction fact: %w", 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 + if !factRecorded { + if err := insertLedger(ctx, tx, billing.LedgerEntry{ + ID: "ble_" + hashID(fact.SourceRawInputID, "dedup", now), ProjectID: fact.ProjectID, + EnvironmentID: fact.EnvironmentID, EntryType: billing.LedgerFactDeduplicated, + RawInputID: fact.SourceRawInputID, ValidationAttemptID: attempt.ID, + CorrelationID: attempt.CorrelationID, OccurredAt: now, + }); err != nil { + return err + } + } + } + + for _, entry := range outcome.Ledger { + if entry.EntryType == billing.LedgerFactRecorded { + if !factRecorded { + continue + } + entry.TransactionFactID = outcome.Fact.ID + } + if err := insertLedger(ctx, tx, entry); err != nil { + return err + } + } + + if write := outcome.Quarantine; write != nil { + if err := upsertQuarantine(ctx, tx, attempt.ProjectID, attempt.EnvironmentID, *write); err != nil { + return err + } + } + + // A successful attempt closes any open quarantine for the same input, and + // records which attempt justified the closure. Closure is never possible + // without that evidence. + if attempt.Outcome == billing.OutcomeValidated || attempt.Outcome == billing.OutcomeRecordedNoFact { + if _, err := tx.Exec(ctx, + `UPDATE billing_quarantine_records + SET status='closed_after_success', closing_attempt_id=$2, closed_at=$3, last_attempt_at=$3 + WHERE raw_input_id=$1 AND status IN ('open','retrying')`, + attempt.RawInputID, attempt.ID, now); err != nil { + return fmt.Errorf("close quarantine after success: %w", err) + } + } + + status := outcome.JobStatus + if status == "" { + status = "completed" + } + availableAt := outcome.NextAvailableAt + if availableAt.IsZero() { + availableAt = now + } + if _, err := tx.Exec(ctx, + `UPDATE billing_validation_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, attempt.DiagnosticCode, now); err != nil { + return fmt.Errorf("update validation job: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit attempt: %w", err) + } + return nil +} + +// MappingCandidates returns every mapping in scope regardless of status. +// +// Filtering by status in SQL would hide exactly the rows the resolver needs: +// archived mappings are what make historical resolution reproducible, and the +// decision about which one applies depends on the transaction's own timestamp, +// which the resolver owns. +func (r *Repository) MappingCandidates(ctx context.Context, environmentID, applicationID, platform, provider, providerProductIdentifier string) ([]billing.MappingCandidate, error) { + rows, err := r.pool.Query(ctx, + `SELECT m.id, m.project_id, m.product_id, p.type, m.status, m.archived_at, + COALESCE(m.replaces_mapping_id,''), COALESCE(m.provider_base_plan_identifier,''), + COALESCE(m.provider_offer_identifier,''), + (extract(epoch from m.updated_at) * 1000)::bigint + FROM provider_product_mappings m + JOIN products p ON p.id = m.product_id AND p.project_id = m.project_id + WHERE m.connection_id IS NULL AND m.environment_id = $1 AND m.application_id = $2 + AND m.platform = $3 AND m.provider = $4 AND m.provider_product_identifier = $5`, + environmentID, applicationID, platform, provider, providerProductIdentifier) + if err != nil { + return nil, fmt.Errorf("read mapping candidates: %w", err) + } + defer rows.Close() + candidates := make([]billing.MappingCandidate, 0, 4) + for rows.Next() { + var candidate billing.MappingCandidate + if err := rows.Scan(&candidate.ID, &candidate.ProjectID, &candidate.MosaicProductID, + &candidate.MosaicProductType, &candidate.Status, &candidate.ArchivedAt, + &candidate.ReplacesMappingID, &candidate.ProviderBasePlanIdentifier, + &candidate.ProviderOfferIdentifier, &candidate.Version); err != nil { + return nil, fmt.Errorf("scan mapping candidate: %w", err) + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +// MappingSuccessors resolves the replacement chain transitively. +// +// The walk is done here in a recursive CTE rather than by round-tripping per +// hop, and it is bounded by the same depth the resolver enforces so a data +// defect cannot turn into an unbounded query. +func (r *Repository) MappingSuccessors(ctx context.Context, projectID string, mappingIDs []string) (map[string]billing.MappingCandidate, error) { + successors := make(map[string]billing.MappingCandidate) + if len(mappingIDs) == 0 { + return successors, nil + } + rows, err := r.pool.Query(ctx, + `WITH RECURSIVE chain AS ( + SELECT m.id, m.replaces_mapping_id, 1 AS depth + FROM provider_product_mappings m + WHERE m.project_id = $1 AND m.replaces_mapping_id = ANY($2) + UNION ALL + SELECT n.id, n.replaces_mapping_id, chain.depth + 1 + FROM provider_product_mappings n + JOIN chain ON n.replaces_mapping_id = chain.id + WHERE n.project_id = $1 AND chain.depth < 32 + ) + SELECT chain.replaces_mapping_id, m.id, m.project_id, m.product_id, p.type, m.status, m.archived_at, + COALESCE(m.replaces_mapping_id,''), COALESCE(m.provider_base_plan_identifier,''), + COALESCE(m.provider_offer_identifier,''), + (extract(epoch from m.updated_at) * 1000)::bigint + FROM chain + JOIN provider_product_mappings m ON m.id = chain.id + JOIN products p ON p.id = m.product_id AND p.project_id = m.project_id`, + projectID, mappingIDs) + if err != nil { + return nil, fmt.Errorf("read mapping successors: %w", err) + } + defer rows.Close() + for rows.Next() { + var predecessor string + var candidate billing.MappingCandidate + if err := rows.Scan(&predecessor, &candidate.ID, &candidate.ProjectID, &candidate.MosaicProductID, + &candidate.MosaicProductType, &candidate.Status, &candidate.ArchivedAt, + &candidate.ReplacesMappingID, &candidate.ProviderBasePlanIdentifier, + &candidate.ProviderOfferIdentifier, &candidate.Version); err != nil { + return nil, fmt.Errorf("scan mapping successor: %w", err) + } + successors[predecessor] = candidate + } + return successors, rows.Err() +} + +// ExpireRawInputBodies removes bodies past their retention window. +// +// Only the encrypted body goes: the input row, its attempts, its facts, and its +// ledger entries all remain, so the ledger stays a complete account after the +// sensitive payload behind it is gone. +func (r *Repository) ExpireRawInputBodies(ctx context.Context, now time.Time, limit int) (int64, error) { + if limit <= 0 { + limit = 500 + } + // The append-only trigger permits an UPDATE only when nothing but the + // envelope columns changed, so retention clears the envelope and leaves the + // state column to a companion statement the trigger also allows. + tag, err := r.pool.Exec(ctx, + `UPDATE billing_raw_inputs + SET envelope_version=NULL, algorithm=NULL, key_id=NULL, nonce=NULL, ciphertext=NULL, + fingerprint=NULL, envelope_rotated_at=$1, body_state='expired' + WHERE id IN ( + SELECT id FROM billing_raw_inputs + WHERE body_state='stored' AND expires_at <= $1 + ORDER BY expires_at, id LIMIT $2)`, now, limit) + if err != nil { + return 0, fmt.Errorf("expire raw billing input bodies: %w", err) + } + return tag.RowsAffected(), nil +} diff --git a/apps/api/internal/platform/billingpostgres/keyring.go b/apps/api/internal/platform/billingpostgres/keyring.go new file mode 100644 index 00000000..ddbf9f52 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/keyring.go @@ -0,0 +1,177 @@ +package billingpostgres + +import ( + "context" + "fmt" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" +) + +// Phase 9A introduced two new envelope-bearing tables. Both must be reachable +// by `keyring rotate`, because a key that still seals rows cannot be removed +// from the keyring, and an operator who removes it anyway makes those rows +// permanently undecryptable. The functions below give the rotation command the +// same three operations it already has for Provider Connection credentials: +// count by key, page rows not under the active key, and reseal a batch +// atomically. + +// BillingEnvelope is one resealable row. It carries the subject scope rather +// than a connection id, because Phase 9A envelopes are bound to a (kind, id) +// subject under the v2 additional-data domain. +type BillingEnvelope struct { + Table string + RowID string + OrganizationID string + ProjectID string + SubjectKind string + CredentialClass string + Version int + Algorithm string + KeyID string + Nonce []byte + Ciphertext []byte + Fingerprint []byte +} + +// Scope rebuilds the additional-data scope this envelope was sealed under. +func (e BillingEnvelope) Scope() providercredential.SubjectScope { + return providercredential.SubjectScope{ + OrganizationID: e.OrganizationID, + ProjectID: e.ProjectID, + SubjectKind: e.SubjectKind, + SubjectID: e.RowID, + CredentialClass: e.CredentialClass, + } +} + +// EnvelopeCountsByKeyID reports how many Phase 9A envelopes each key seals, +// across both tables. It never returns key material or ciphertext. +func (r *Repository) EnvelopeCountsByKeyID(ctx context.Context) (map[string]int64, error) { + counts := make(map[string]int64) + rows, err := r.pool.Query(ctx, + `SELECT key_id, count(*) FROM store_server_credentials WHERE revoked_at IS NULL GROUP BY key_id + UNION ALL + SELECT key_id, count(*) FROM billing_raw_inputs WHERE body_state = 'stored' AND key_id IS NOT NULL GROUP BY key_id`) + if err != nil { + return nil, fmt.Errorf("count billing envelopes: %w", err) + } + defer rows.Close() + for rows.Next() { + var keyID string + var count int64 + if err := rows.Scan(&keyID, &count); err != nil { + return nil, fmt.Errorf("scan billing envelope count: %w", err) + } + counts[keyID] += count + } + return counts, rows.Err() +} + +// EnvelopesNotUnderKey pages envelopes sealed under a retired key, ordered +// stably so an interrupted rotation resumes deterministically. +// +// Credentials are returned before raw-input bodies. A credential that cannot be +// decrypted stops ingestion for a whole tenant, while an unrotatable body only +// affects replay of one input, so credentials are the ones worth resealing +// first when a rotation is interrupted. +func (r *Repository) EnvelopesNotUnderKey(ctx context.Context, keyID string, limit int) ([]BillingEnvelope, error) { + if limit <= 0 { + limit = 100 + } + envelopes := make([]BillingEnvelope, 0, limit) + + rows, err := r.pool.Query(ctx, + `SELECT id, organization_id, project_id, credential_class, envelope_version, algorithm, key_id, + nonce, ciphertext, fingerprint + FROM store_server_credentials + WHERE key_id <> $1 AND revoked_at IS NULL ORDER BY id LIMIT $2`, keyID, limit) + if err != nil { + return nil, fmt.Errorf("read credential envelopes: %w", err) + } + for rows.Next() { + envelope := BillingEnvelope{Table: "store_server_credentials", SubjectKind: providercredential.SubjectStoreServerCredential} + if err := rows.Scan(&envelope.RowID, &envelope.OrganizationID, &envelope.ProjectID, + &envelope.CredentialClass, &envelope.Version, &envelope.Algorithm, &envelope.KeyID, + &envelope.Nonce, &envelope.Ciphertext, &envelope.Fingerprint); err != nil { + rows.Close() + return nil, fmt.Errorf("scan credential envelope: %w", err) + } + envelopes = append(envelopes, envelope) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read credential 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 + FROM billing_raw_inputs + WHERE key_id IS NOT NULL AND key_id <> $1 AND body_state = 'stored' + ORDER BY id LIMIT $2`, keyID, remaining) + if err != nil { + return nil, fmt.Errorf("read raw input envelopes: %w", err) + } + defer bodyRows.Close() + for bodyRows.Next() { + envelope := BillingEnvelope{ + Table: "billing_raw_inputs", SubjectKind: providercredential.SubjectBillingRawInput, + CredentialClass: "billingRawPayload", + } + if err := bodyRows.Scan(&envelope.RowID, &envelope.OrganizationID, &envelope.ProjectID, + &envelope.Version, &envelope.Algorithm, &envelope.KeyID, &envelope.Nonce, + &envelope.Ciphertext, &envelope.Fingerprint); err != nil { + return nil, fmt.Errorf("scan raw input envelope: %w", err) + } + envelopes = append(envelopes, envelope) + } + return envelopes, bodyRows.Err() +} + +// ReplaceEnvelopes rewrites a batch in one transaction, so an interrupted +// rotation never leaves a row with a half-written envelope. +// +// The raw-input UPDATE only touches envelope columns, which is exactly what the +// append-only trigger on that table permits; any other column in the statement +// would raise 55000 and abort the batch. +func (r *Repository) ReplaceEnvelopes(ctx context.Context, envelopes []BillingEnvelope, now time.Time) error { + if len(envelopes) == 0 { + return nil + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin billing envelope rotation: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + for _, envelope := range envelopes { + var statement string + switch envelope.Table { + case "store_server_credentials": + statement = `UPDATE store_server_credentials + SET envelope_version=$2, algorithm=$3, key_id=$4, nonce=$5, ciphertext=$6, + fingerprint=$7, updated_at=$8 + WHERE id=$1` + case "billing_raw_inputs": + statement = `UPDATE billing_raw_inputs + SET envelope_version=$2, algorithm=$3, key_id=$4, nonce=$5, ciphertext=$6, + fingerprint=$7, envelope_rotated_at=$8 + WHERE id=$1` + default: + return fmt.Errorf("unsupported billing envelope table %q", envelope.Table) + } + tag, err := tx.Exec(ctx, statement, envelope.RowID, envelope.Version, envelope.Algorithm, + envelope.KeyID, envelope.Nonce, envelope.Ciphertext, envelope.Fingerprint, now) + if err != nil { + return fmt.Errorf("rewrite billing envelope: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("billing envelope %s/%s disappeared during rotation", envelope.Table, envelope.RowID) + } + } + return tx.Commit(ctx) +} diff --git a/apps/api/internal/platform/billingpostgres/queries.go b/apps/api/internal/platform/billingpostgres/queries.go new file mode 100644 index 00000000..fa59b8ae --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/queries.go @@ -0,0 +1,873 @@ +package billingpostgres + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +const ( + defaultPageLimit = 25 + maxPageLimit = 100 +) + +// authorizeEnvironment checks the actor's role and that the Environment belongs +// to the Project in one place, so no list query can be reached through a +// mismatched pair. +func (r *Repository) authorizeEnvironment(ctx context.Context, actor billing.Actor, projectID, environmentID string) error { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); 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 billing.ErrNotFound + } + if err != nil { + return fmt.Errorf("resolve billing environment: %w", err) + } + return nil +} + +func pageLimit(options billing.ListOptions) int { + if options.Limit <= 0 { + return defaultPageLimit + } + if options.Limit > maxPageLimit { + return maxPageLimit + } + return options.Limit +} + +// listCursor is a keyset position over a billing list: the ordering timestamp +// of the last row returned, plus its id as the tie-break. +// +// Both halves are required. Every billing list orders by a timestamp, and +// billing identifiers are deliberately *not* time-ordered — `Service.newID` is +// sixteen random bytes and quarantine ids are a SHA-256 prefix — so a cursor +// carrying only the id cannot express "after this row in timestamp order". The +// previous implementation did exactly that: it emitted the last row's id and +// applied `id < $cursor` against a timestamp ordering, so page two returned +// whatever happened to sort low by random id and silently dropped the rest. +// On the quarantine queue, whose whole job is surfacing inputs that need +// attention, a paging control that hides records is worse than no control. +type listCursor struct { + At *time.Time + ID string +} + +// encodeCursor renders a keyset position as one opaque token. +// +// 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 +// travels in a query string. +func encodeCursor(at time.Time, id string) string { + return base64.RawURLEncoding.EncodeToString( + []byte(strconv.FormatInt(at.UTC().UnixMilli(), 10) + ":" + id)) +} + +// decodeCursor parses an opaque cursor. A malformed or stale value yields the +// zero cursor, which starts from the beginning: a caller that mangles a cursor +// gets the first page rather than an error page or 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.UnixMilli(value).UTC() + return listCursor{At: &at, ID: id} +} + +func (r *Repository) ListFacts(ctx context.Context, actor billing.Actor, projectID, environmentID string, options billing.ListOptions) (billing.Page[billing.TransactionFact], error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Page[billing.TransactionFact]{}, err + } + limit := pageLimit(options) + cursor := decodeCursor(options.Cursor) + rows, err := r.pool.Query(ctx, + `SELECT id, project_id, environment_id, application_id, provider, store_environment, + provider_transaction_id, COALESCE(provider_original_transaction_id,''), + transaction_type, fact_kind, occurred_at, period_start_at, period_end_at, + revoked_at, refunded_at, renewal_expected, is_test_transaction, + provider_product_identifier, COALESCE(provider_base_plan_identifier,''), + 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 + FROM billing_transaction_facts + WHERE environment_id=$1 + AND ($2::timestamptz IS NULL OR (occurred_at, id) < ($2::timestamptz, $3)) + AND ($4::text = '' OR provider = $4) + AND ($5::timestamptz IS NULL OR occurred_at >= $5) + AND ($6::timestamptz IS NULL OR occurred_at <= $6) + ORDER BY occurred_at DESC, id DESC + LIMIT $7`, environmentID, cursor.At, cursor.ID, options.Provider, options.From, options.To, limit+1) + if err != nil { + return billing.Page[billing.TransactionFact]{}, fmt.Errorf("list transaction facts: %w", err) + } + defer rows.Close() + items := make([]billing.TransactionFact, 0, limit) + for rows.Next() { + var fact billing.TransactionFact + if err := rows.Scan(&fact.ID, &fact.ProjectID, &fact.EnvironmentID, &fact.ApplicationID, + &fact.Provider, &fact.StoreEnvironment, &fact.ProviderTransactionID, + &fact.ProviderOriginalTransactionID, &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.RecordedAt); err != nil { + return billing.Page[billing.TransactionFact]{}, fmt.Errorf("scan transaction fact: %w", err) + } + items = append(items, fact) + } + if err := rows.Err(); err != nil { + return billing.Page[billing.TransactionFact]{}, fmt.Errorf("read transaction facts: %w", err) + } + return paginate(items, limit, func(fact billing.TransactionFact) (time.Time, string) { + return fact.OccurredAt, fact.ID + }), nil +} + +// paginate trims the lookahead row and emits the keyset cursor for the next +// page. key returns the ordering timestamp and id of a row — the same pair the +// query orders by, which is what makes the cursor and the sort agree. +func paginate[T any](items []T, limit int, key func(T) (time.Time, string)) billing.Page[T] { + page := billing.Page[T]{Items: items} + if len(items) > limit { + page.Items = items[:limit] + at, id := key(page.Items[limit-1]) + page.NextCursor = encodeCursor(at, id) + } + return page +} + +func (r *Repository) ListAttempts(ctx context.Context, actor billing.Actor, projectID, environmentID string, options billing.ListOptions) (billing.Page[billing.ValidationAttempt], error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Page[billing.ValidationAttempt]{}, err + } + limit := pageLimit(options) + cursor := decodeCursor(options.Cursor) + rows, err := r.pool.Query(ctx, + `SELECT id, project_id, environment_id, raw_input_id, COALESCE(credential_id,''), attempt_number, + validator_version, started_at, completed_at, outcome, retryable, + COALESCE(failure_category,''), COALESCE(diagnostic_code,''), COALESCE(provider_code,''), + COALESCE(provider_http_status,0), store_environment, latency_ms, + COALESCE(replay_of_attempt_id,''), correlation_id + FROM billing_validation_attempts + WHERE environment_id=$1 + AND ($2::timestamptz IS NULL OR (started_at, id) < ($2::timestamptz, $3)) + AND ($4::text = '' OR outcome = $4) + AND ($5::text = '' OR raw_input_id = $5) + ORDER BY started_at DESC, id DESC LIMIT $6`, + environmentID, cursor.At, cursor.ID, options.Status, options.RawInputID, limit+1) + if err != nil { + return billing.Page[billing.ValidationAttempt]{}, fmt.Errorf("list validation attempts: %w", err) + } + defer rows.Close() + items := make([]billing.ValidationAttempt, 0, limit) + for rows.Next() { + var attempt billing.ValidationAttempt + if err := rows.Scan(&attempt.ID, &attempt.ProjectID, &attempt.EnvironmentID, &attempt.RawInputID, + &attempt.CredentialID, &attempt.AttemptNumber, &attempt.ValidatorVersion, &attempt.StartedAt, + &attempt.CompletedAt, &attempt.Outcome, &attempt.Retryable, &attempt.FailureCategory, + &attempt.DiagnosticCode, &attempt.ProviderCode, &attempt.ProviderHTTPStatus, + &attempt.StoreEnvironment, &attempt.LatencyMs, &attempt.ReplayOfAttemptID, + &attempt.CorrelationID); err != nil { + return billing.Page[billing.ValidationAttempt]{}, fmt.Errorf("scan validation attempt: %w", err) + } + items = append(items, attempt) + } + if err := rows.Err(); err != nil { + return billing.Page[billing.ValidationAttempt]{}, fmt.Errorf("read validation attempts: %w", err) + } + return paginate(items, limit, func(a billing.ValidationAttempt) (time.Time, string) { + return a.StartedAt, a.ID + }), nil +} + +func (r *Repository) ListLedger(ctx context.Context, actor billing.Actor, projectID, environmentID string, options billing.ListOptions) (billing.Page[billing.LedgerEntry], error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Page[billing.LedgerEntry]{}, err + } + limit := pageLimit(options) + cursor := decodeCursor(options.Cursor) + rows, err := r.pool.Query(ctx, + `SELECT id, project_id, environment_id, entry_type, COALESCE(raw_input_id,''), + COALESCE(validation_attempt_id,''), COALESCE(transaction_fact_id,''), + COALESCE(credential_id,''), correlation_id, occurred_at + FROM billing_ledger_entries + WHERE environment_id=$1 + AND ($2::timestamptz IS NULL OR (occurred_at, id) < ($2::timestamptz, $3)) + AND ($4::text = '' OR entry_type = $4) + AND ($5::timestamptz IS NULL OR occurred_at >= $5) + AND ($6::timestamptz IS NULL OR occurred_at <= $6) + ORDER BY occurred_at DESC, id DESC LIMIT $7`, + environmentID, cursor.At, cursor.ID, options.Status, options.From, options.To, limit+1) + if err != nil { + return billing.Page[billing.LedgerEntry]{}, fmt.Errorf("list billing ledger: %w", err) + } + defer rows.Close() + items := make([]billing.LedgerEntry, 0, limit) + for rows.Next() { + var entry billing.LedgerEntry + if err := rows.Scan(&entry.ID, &entry.ProjectID, &entry.EnvironmentID, &entry.EntryType, + &entry.RawInputID, &entry.ValidationAttemptID, &entry.TransactionFactID, &entry.CredentialID, + &entry.CorrelationID, &entry.OccurredAt); err != nil { + return billing.Page[billing.LedgerEntry]{}, fmt.Errorf("scan billing ledger entry: %w", err) + } + items = append(items, entry) + } + if err := rows.Err(); err != nil { + return billing.Page[billing.LedgerEntry]{}, fmt.Errorf("read billing ledger: %w", err) + } + return paginate(items, limit, func(e billing.LedgerEntry) (time.Time, string) { + return e.OccurredAt, e.ID + }), nil +} + +// quarantineColumns joins the Store Environment back from the quarantined +// input. The quarantine record does not carry its own copy — a record is +// always about exactly one input, so duplicating the column would create a +// second place for the two to disagree — but the operator surface must show it, +// because sandbox and production must stay visibly separate everywhere. +const quarantineColumns = `q.id, q.project_id, q.environment_id, q.raw_input_id, COALESCE(q.application_id,''), q.provider, + COALESCE(i.store_environment, 'unclassified'), + COALESCE(res.provider_product_identifier, ''), + q.reason_code, q.severity, q.scopes, q.status, q.attempt_count, q.first_seen_at, q.last_attempt_at, + COALESCE(q.closing_attempt_id,''), COALESCE(q.superseded_by_record_id,''), q.closed_at, COALESCE(q.diagnostic_code,'')` + +// quarantineFrom is the shared join. LEFT JOIN rather than INNER: a record must +// remain listable even if its input row is somehow unreachable, and the COALESCE +// above turns that into an explicit "unclassified" instead of dropping the row. +// The resolution join is LATERAL and ordered: an input may have several +// resolution attempts, and the operator needs the most recent one — the +// Product identifier that is currently failing to resolve, not the first one +// that ever did. +const quarantineFrom = `FROM billing_quarantine_records q + LEFT JOIN billing_raw_inputs i ON i.id = q.raw_input_id AND i.project_id = q.project_id + LEFT JOIN LATERAL ( + SELECT r.provider_product_identifier FROM billing_product_resolutions r + WHERE r.raw_input_id = q.raw_input_id AND r.project_id = q.project_id + ORDER BY r.resolved_at DESC LIMIT 1 + ) res ON true` + +func scanQuarantine(row pgx.Row) (billing.QuarantineRecord, error) { + var record billing.QuarantineRecord + err := row.Scan(&record.ID, &record.ProjectID, &record.EnvironmentID, &record.RawInputID, + &record.ApplicationID, &record.Provider, &record.StoreEnvironment, + &record.ProviderProductIdentifier, + &record.ReasonCode, &record.Severity, &record.Scopes, + &record.Status, &record.AttemptCount, &record.FirstSeenAt, &record.LastAttemptAt, + &record.ClosingAttemptID, &record.SupersededByRecordID, &record.ClosedAt, &record.DiagnosticCode) + return record, err +} + +func (r *Repository) ListQuarantine(ctx context.Context, actor billing.Actor, projectID, environmentID string, options billing.ListOptions) (billing.Page[billing.QuarantineRecord], error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Page[billing.QuarantineRecord]{}, err + } + limit := pageLimit(options) + cursor := decodeCursor(options.Cursor) + rows, err := r.pool.Query(ctx, + `SELECT `+quarantineColumns+` + `+quarantineFrom+` + WHERE q.environment_id=$1 + AND ($2::timestamptz IS NULL OR (q.last_attempt_at, q.id) < ($2::timestamptz, $3)) + AND ($4::text = '' OR q.status = $4) + AND ($5::text = '' OR q.reason_code = $5) + AND ($6::text = '' OR q.provider = $6) + ORDER BY q.last_attempt_at DESC, q.id DESC LIMIT $7`, + environmentID, cursor.At, cursor.ID, options.Status, options.ReasonCode, options.Provider, limit+1) + if err != nil { + return billing.Page[billing.QuarantineRecord]{}, fmt.Errorf("list quarantine records: %w", err) + } + defer rows.Close() + items := make([]billing.QuarantineRecord, 0, limit) + for rows.Next() { + record, err := scanQuarantine(rows) + if err != nil { + return billing.Page[billing.QuarantineRecord]{}, fmt.Errorf("scan quarantine record: %w", err) + } + items = append(items, record) + } + if err := rows.Err(); err != nil { + return billing.Page[billing.QuarantineRecord]{}, fmt.Errorf("read quarantine records: %w", err) + } + return paginate(items, limit, func(q billing.QuarantineRecord) (time.Time, string) { + return q.LastAttemptAt, q.ID + }), nil +} + +func (r *Repository) Quarantine(ctx context.Context, actor billing.Actor, projectID, recordID string) (billing.QuarantineRecord, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.QuarantineRecord{}, err + } + record, err := scanQuarantine(r.pool.QueryRow(ctx, + `SELECT `+quarantineColumns+` `+quarantineFrom+` WHERE q.id=$1 AND q.project_id=$2`, + recordID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billing.QuarantineRecord{}, billing.ErrNotFound + } + if err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("read quarantine record: %w", err) + } + return record, nil +} + +// RequeueValidation is the retry recovery action. +// +// It re-arms the queue row and marks the quarantine as retrying. It does not +// change the record's status to anything resembling resolved: only a subsequent +// successful attempt can do that, inside CompleteAttempt, with the attempt id +// recorded as the justification. +// OpenQuarantine records a quarantine outside a validation attempt. +// +// Reconciliation needs this because a conflicting discovery is not a failure of +// the attempt that produced it — that attempt validated successfully and +// appended a legitimate fact. What needs an operator is the contradiction +// between that fact and the one already on record, and nothing is overwritten +// either way: both facts stand and the quarantine is the diagnostic over them. +func (r *Repository) OpenQuarantine(ctx context.Context, projectID, environmentID string, write billing.QuarantineWrite) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin quarantine write: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := upsertQuarantine(ctx, tx, projectID, environmentID, write); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *Repository) RequeueValidation(ctx context.Context, actor billing.Actor, projectID, recordID string, now time.Time) (billing.QuarantineRecord, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.QuarantineRecord{}, err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("begin quarantine retry: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var rawInputID, environmentID, provider string + err = tx.QueryRow(ctx, + `SELECT raw_input_id, environment_id, provider FROM billing_quarantine_records + WHERE id=$1 AND project_id=$2 AND status IN ('open','retrying')`, recordID, projectID). + Scan(&rawInputID, &environmentID, &provider) + if errors.Is(err, pgx.ErrNoRows) { + return billing.QuarantineRecord{}, billing.ErrNotFound + } + if err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("read quarantine record for retry: %w", err) + } + + // attempt_count is reset so the operator's retry gets a full attempt budget + // rather than inheriting an exhausted one; the attempt history itself is + // append-only and unaffected. + if _, err := tx.Exec(ctx, + `INSERT INTO billing_validation_jobs( + id, project_id, environment_id, raw_input_id, provider, status, + attempt_count, max_attempts, available_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,'queued',0,$6,$7,$7,$7) + ON CONFLICT (raw_input_id) DO UPDATE + SET status='queued', attempt_count=0, available_at=$7, updated_at=$7, + lease_owner=NULL, lease_expires_at=NULL`, + "bvj_"+hashID(rawInputID, "retry", now), projectID, environmentID, rawInputID, + provider, billing.MaxValidationAttempts, now); err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("requeue validation: %w", err) + } + + if _, err := tx.Exec(ctx, + `UPDATE billing_quarantine_records SET status='retrying', last_attempt_at=$2 + WHERE id=$1`, recordID, now); err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("mark quarantine retrying: %w", err) + } + if _, err := tx.Exec(ctx, + `INSERT INTO billing_quarantine_actions(id, project_id, quarantine_record_id, action, outcome, actor_id, occurred_at) + VALUES ($1,$2,$3,'retry_validation','accepted',$4,$5)`, + "bqa_"+hashID(recordID, "retry", now), projectID, recordID, actor.ID, now); err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("record quarantine action: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("commit quarantine retry: %w", err) + } + return r.Quarantine(ctx, actor, projectID, recordID) +} + +// CloseQuarantineSuperseded closes a record because a later record replaced it. +// It asserts nothing about the original input's authenticity and produces no +// Transaction Fact. +func (r *Repository) CloseQuarantineSuperseded(ctx context.Context, actor billing.Actor, projectID, recordID, supersededBy string, now time.Time) (billing.QuarantineRecord, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.QuarantineRecord{}, err + } + tag, err := r.pool.Exec(ctx, + `UPDATE billing_quarantine_records + SET status='closed_superseded', superseded_by_record_id=$3, closed_at=$4, + closed_by_actor_id=$5, last_attempt_at=$4 + WHERE id=$1 AND project_id=$2 AND status IN ('open','retrying')`, + recordID, projectID, supersededBy, now, actor.ID) + if err != nil { + return billing.QuarantineRecord{}, fmt.Errorf("close quarantine record: %w", err) + } + if tag.RowsAffected() == 0 { + return billing.QuarantineRecord{}, billing.ErrNotFound + } + _, _ = r.pool.Exec(ctx, + `INSERT INTO billing_quarantine_actions(id, project_id, quarantine_record_id, action, outcome, actor_id, occurred_at) + VALUES ($1,$2,$3,'close_superseded','succeeded',$4,$5)`, + "bqa_"+hashID(recordID, "superseded", now), projectID, recordID, actor.ID, now) + return r.Quarantine(ctx, actor, projectID, recordID) +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +const reconciliationColumns = `id, project_id, environment_id, credential_id, provider, trigger, strategy, + status, window_start, window_end, COALESCE(cursor_token,''), examined_count, discovered_count, + duplicate_count, failure_count, conflict_count, COALESCE(last_error_code,''), created_at, started_at, completed_at, + cursor_received_at, COALESCE(cursor_input_id,'')` + +func scanReconciliation(row pgx.Row) (billing.ReconciliationRun, error) { + var run billing.ReconciliationRun + err := row.Scan(&run.ID, &run.ProjectID, &run.EnvironmentID, &run.CredentialID, &run.Provider, + &run.Trigger, &run.Strategy, &run.Status, &run.WindowStart, &run.WindowEnd, &run.CursorToken, + &run.ExaminedCount, &run.DiscoveredCount, &run.DuplicateCount, &run.FailureCount, + &run.ConflictCount, &run.LastErrorCode, &run.CreatedAt, &run.StartedAt, &run.CompletedAt, + &run.Cursor.ReceivedAt, &run.Cursor.InputID) + return run, err +} + +// writeAuditEvent records a sensitive billing operation in the shared audit +// log. +// +// Credential lifecycle and quarantine recovery already have append-only domain +// tables of their own. Replay and manual reconciliation did not: their only +// actor record was `requested_by_actor_id` on a mutable job row, which anyone +// with database access could rewrite after the fact. Metadata carries +// identifiers and enumerations only — never a token, a payload, or a window an +// attacker could use to infer content. +func writeAuditEvent(ctx context.Context, q execer, actor billing.Actor, organizationID, projectID, environmentID, + 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 + } + } + _, err := q.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)`, + "aud_"+hashID(resourceID, action, now), actor.ID, organizationID, projectID, environmentID, + action, resourceType, resourceID, encoded, now) + if err != nil { + return fmt.Errorf("write billing audit event: %w", err) + } + return nil +} + +type execer interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + +func (r *Repository) CreateReconciliationRun(ctx context.Context, actor billing.Actor, run billing.ReconciliationRun, now time.Time) (billing.ReconciliationRun, error) { + organizationID, err := requireRole(ctx, r.pool, actor, run.ProjectID, "owner", "admin") + if err != nil { + return billing.ReconciliationRun{}, err + } + _, err = r.pool.Exec(ctx, + `INSERT INTO billing_reconciliation_runs( + id, project_id, environment_id, credential_id, provider, trigger, strategy, status, + window_start, window_end, available_at, requested_by_actor_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'queued',$8,$9,$10,$11,$10,$10)`, + run.ID, run.ProjectID, run.EnvironmentID, run.CredentialID, run.Provider, run.Trigger, + run.Strategy, run.WindowStart, run.WindowEnd, now, actor.ID) + if err != nil { + if isUniqueViolation(err) { + // A run for this credential and strategy is already live. Refusing + // keeps two runs from scanning the same window concurrently. + return billing.ReconciliationRun{}, billing.ErrConflict + } + return billing.ReconciliationRun{}, fmt.Errorf("create reconciliation run: %w", err) + } + if err := writeAuditEvent(ctx, r.pool, actor, organizationID, run.ProjectID, run.EnvironmentID, + "billing.reconciliation_run.created", "billing_reconciliation_run", run.ID, + map[string]string{"provider": run.Provider, "strategy": run.Strategy, "trigger": run.Trigger}, + now); err != nil { + return billing.ReconciliationRun{}, err + } + return scanReconciliation(r.pool.QueryRow(ctx, + `SELECT `+reconciliationColumns+` FROM billing_reconciliation_runs WHERE id=$1`, run.ID)) +} + +func (r *Repository) ListReconciliationRuns(ctx context.Context, actor billing.Actor, projectID, environmentID string, options billing.ListOptions) (billing.Page[billing.ReconciliationRun], error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Page[billing.ReconciliationRun]{}, err + } + limit := pageLimit(options) + cursor := decodeCursor(options.Cursor) + rows, err := r.pool.Query(ctx, + `SELECT `+reconciliationColumns+` FROM billing_reconciliation_runs + WHERE environment_id=$1 + AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3)) + AND ($4::text = '' OR status = $4) + ORDER BY created_at DESC, id DESC LIMIT $5`, + environmentID, cursor.At, cursor.ID, options.Status, limit+1) + if err != nil { + return billing.Page[billing.ReconciliationRun]{}, fmt.Errorf("list reconciliation runs: %w", err) + } + defer rows.Close() + items := make([]billing.ReconciliationRun, 0, limit) + for rows.Next() { + run, err := scanReconciliation(rows) + if err != nil { + return billing.Page[billing.ReconciliationRun]{}, fmt.Errorf("scan reconciliation run: %w", err) + } + items = append(items, run) + } + if err := rows.Err(); err != nil { + return billing.Page[billing.ReconciliationRun]{}, fmt.Errorf("read reconciliation runs: %w", err) + } + return paginate(items, limit, func(run billing.ReconciliationRun) (time.Time, string) { + return run.CreatedAt, run.ID + }), nil +} + +func (r *Repository) LeaseReconciliationRun(ctx context.Context, workerID string, now, leaseUntil time.Time) (billing.ReconciliationRun, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.ReconciliationRun{}, false, fmt.Errorf("begin reconciliation lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + var id string + err = tx.QueryRow(ctx, + `SELECT id FROM billing_reconciliation_runs + 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(&id) + if errors.Is(err, pgx.ErrNoRows) { + return billing.ReconciliationRun{}, false, nil + } + if err != nil { + return billing.ReconciliationRun{}, false, fmt.Errorf("select reconciliation run: %w", err) + } + if _, err := tx.Exec(ctx, + `UPDATE billing_reconciliation_runs + SET status='leased', lease_owner=$2, lease_expires_at=$3, attempt_count=attempt_count+1, + started_at=COALESCE(started_at,$4), updated_at=$4 + WHERE id=$1`, id, workerID, leaseUntil, now); err != nil { + return billing.ReconciliationRun{}, false, fmt.Errorf("lease reconciliation run: %w", err) + } + run, err := scanReconciliation(tx.QueryRow(ctx, + `SELECT `+reconciliationColumns+` FROM billing_reconciliation_runs WHERE id=$1`, id)) + if err != nil { + return billing.ReconciliationRun{}, false, fmt.Errorf("read leased reconciliation run: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billing.ReconciliationRun{}, false, fmt.Errorf("commit reconciliation lease: %w", err) + } + return run, true, nil +} + +// UpdateReconciliationProgress commits the cursor and returns the run to the +// queue. Committing after each page is what makes a run restart-safe. +func (r *Repository) UpdateReconciliationProgress(ctx context.Context, run billing.ReconciliationRun, cursorToken string, cursor billing.InputCursor, now time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE billing_reconciliation_runs + SET status='queued', cursor_token=NULLIF($2,''), examined_count=$3, discovered_count=$4, + duplicate_count=$5, failure_count=$6, conflict_count=$7, available_at=$8, lease_owner=NULL, + lease_expires_at=NULL, cursor_received_at=$9, cursor_input_id=NULLIF($10,''), updated_at=$8 + WHERE id=$1`, run.ID, cursorToken, run.ExaminedCount, run.DiscoveredCount, + run.DuplicateCount, run.FailureCount, run.ConflictCount, now, + cursor.ReceivedAt, cursor.InputID) + if err != nil { + return fmt.Errorf("update reconciliation progress: %w", err) + } + return nil +} + +func (r *Repository) CompleteReconciliationRun(ctx context.Context, run billing.ReconciliationRun, status, errorCode string, now time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE billing_reconciliation_runs + SET status=$2, examined_count=$3, discovered_count=$4, duplicate_count=$5, failure_count=$6, + conflict_count=$7, + last_error_code=NULLIF($8,''), completed_at=$9, lease_owner=NULL, lease_expires_at=NULL, updated_at=$9 + WHERE id=$1`, run.ID, status, run.ExaminedCount, run.DiscoveredCount, run.DuplicateCount, + run.FailureCount, run.ConflictCount, errorCode, now) + if err != nil { + return fmt.Errorf("complete reconciliation run: %w", err) + } + return nil +} + +// --------------------------------------------------------------------------- +// Replay +// --------------------------------------------------------------------------- + +const replayColumns = `id, project_id, environment_id, kind, COALESCE(raw_input_id,''), window_start, window_end, + validator_version, status, COALESCE(comparison_result,''), examined_count, unchanged_count, + new_fact_count, conflict_count, COALESCE(last_error_code,''), created_at, completed_at, + cursor_received_at, COALESCE(cursor_input_id,'')` + +func scanReplay(row pgx.Row) (billing.ReplayJob, error) { + var job billing.ReplayJob + err := row.Scan(&job.ID, &job.ProjectID, &job.EnvironmentID, &job.Kind, &job.RawInputID, + &job.WindowStart, &job.WindowEnd, &job.ValidatorVersion, &job.Status, &job.ComparisonResult, + &job.ExaminedCount, &job.UnchangedCount, &job.NewFactCount, &job.ConflictCount, + &job.LastErrorCode, &job.CreatedAt, &job.CompletedAt, + &job.Cursor.ReceivedAt, &job.Cursor.InputID) + return job, err +} + +func (r *Repository) CreateReplayJob(ctx context.Context, actor billing.Actor, job billing.ReplayJob, now time.Time) (billing.ReplayJob, error) { + organizationID, err := requireRole(ctx, r.pool, actor, job.ProjectID, "owner", "admin") + if err != nil { + return billing.ReplayJob{}, err + } + _, err = r.pool.Exec(ctx, + `INSERT INTO billing_replay_jobs( + id, project_id, environment_id, kind, raw_input_id, window_start, window_end, + validator_version, status, available_at, requested_by_actor_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,'queued',$9,$10,$9,$9)`, + job.ID, job.ProjectID, job.EnvironmentID, job.Kind, job.RawInputID, job.WindowStart, + job.WindowEnd, job.ValidatorVersion, now, actor.ID) + if err != nil { + return billing.ReplayJob{}, fmt.Errorf("create replay job: %w", err) + } + if err := writeAuditEvent(ctx, r.pool, actor, organizationID, job.ProjectID, job.EnvironmentID, + "billing.replay_job.created", "billing_replay_job", job.ID, + map[string]string{"kind": job.Kind, "validatorVersion": strconv.Itoa(job.ValidatorVersion)}, + now); err != nil { + return billing.ReplayJob{}, err + } + return scanReplay(r.pool.QueryRow(ctx, `SELECT `+replayColumns+` FROM billing_replay_jobs WHERE id=$1`, job.ID)) +} + +func (r *Repository) ListReplayJobs(ctx context.Context, actor billing.Actor, projectID, environmentID string, options billing.ListOptions) (billing.Page[billing.ReplayJob], error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Page[billing.ReplayJob]{}, err + } + limit := pageLimit(options) + cursor := decodeCursor(options.Cursor) + rows, err := r.pool.Query(ctx, + `SELECT `+replayColumns+` FROM billing_replay_jobs + WHERE environment_id=$1 + AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3)) + AND ($4::text = '' OR status = $4) + ORDER BY created_at DESC, id DESC LIMIT $5`, + environmentID, cursor.At, cursor.ID, options.Status, limit+1) + if err != nil { + return billing.Page[billing.ReplayJob]{}, fmt.Errorf("list replay jobs: %w", err) + } + defer rows.Close() + items := make([]billing.ReplayJob, 0, limit) + for rows.Next() { + job, err := scanReplay(rows) + if err != nil { + return billing.Page[billing.ReplayJob]{}, fmt.Errorf("scan replay job: %w", err) + } + items = append(items, job) + } + if err := rows.Err(); err != nil { + return billing.Page[billing.ReplayJob]{}, fmt.Errorf("read replay jobs: %w", err) + } + return paginate(items, limit, func(job billing.ReplayJob) (time.Time, string) { + return job.CreatedAt, job.ID + }), nil +} + +func (r *Repository) LeaseReplayJob(ctx context.Context, workerID string, now, leaseUntil time.Time) (billing.ReplayJob, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.ReplayJob{}, false, fmt.Errorf("begin replay lease: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + var id string + err = tx.QueryRow(ctx, + `SELECT id FROM billing_replay_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(&id) + if errors.Is(err, pgx.ErrNoRows) { + return billing.ReplayJob{}, false, nil + } + if err != nil { + return billing.ReplayJob{}, false, fmt.Errorf("select replay job: %w", err) + } + if _, err := tx.Exec(ctx, + `UPDATE billing_replay_jobs + SET status='leased', lease_owner=$2, lease_expires_at=$3, attempt_count=attempt_count+1, updated_at=$4 + WHERE id=$1`, id, workerID, leaseUntil, now); err != nil { + return billing.ReplayJob{}, false, fmt.Errorf("lease replay job: %w", err) + } + job, err := scanReplay(tx.QueryRow(ctx, `SELECT `+replayColumns+` FROM billing_replay_jobs WHERE id=$1`, id)) + if err != nil { + return billing.ReplayJob{}, false, fmt.Errorf("read leased replay job: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return billing.ReplayJob{}, false, fmt.Errorf("commit replay lease: %w", err) + } + return job, true, nil +} + +// ReplayInputs selects the inputs a replay or reconciliation step will re-run. +// Only inputs whose body is still retained are eligible: an expired body cannot +// be re-validated, and pretending otherwise would produce an attempt with +// nothing behind it. +func (r *Repository) ReplayInputs(ctx context.Context, job billing.ReplayJob, filter billing.InputFilter, cursor billing.InputCursor, limit int) ([]billing.RawInput, billing.InputCursor, error) { + if limit <= 0 { + limit = 50 + } + sources := filter.Sources + if sources == nil { + sources = []string{} + } + // The keyset predicate. Ordering by (received_at, id) is stable under the + // continuous appends this table sees, so resuming strictly after the last + // examined position can neither skip nor repeat a row — which an OFFSET + // would do in both directions as the window fills underneath a multi-pass + // scan. + rows, err := r.pool.Query(ctx, + `SELECT id, received_at FROM billing_raw_inputs + WHERE project_id=$1 AND environment_id=$2 AND body_state='stored' + AND ($3 = '' OR id = $3) + AND ($4::timestamptz IS NULL OR received_at >= $4) + AND ($5::timestamptz IS NULL OR received_at <= $5) + AND ($6 = '' OR provider = $6) + AND (cardinality($7::text[]) = 0 OR source = ANY($7::text[])) + AND ($8::timestamptz IS NULL OR (received_at, id) > ($8::timestamptz, $9)) + ORDER BY received_at, id LIMIT $10`, + job.ProjectID, job.EnvironmentID, job.RawInputID, job.WindowStart, job.WindowEnd, + filter.Provider, sources, cursor.ReceivedAt, cursor.InputID, limit) + if err != nil { + return nil, billing.InputCursor{}, fmt.Errorf("select replay inputs: %w", err) + } + defer rows.Close() + ids := make([]string, 0, limit) + next := cursor + for rows.Next() { + var id string + var receivedAt time.Time + if err := rows.Scan(&id, &receivedAt); err != nil { + return nil, billing.InputCursor{}, fmt.Errorf("scan replay input id: %w", err) + } + ids = append(ids, id) + at := receivedAt + next = billing.InputCursor{ReceivedAt: &at, InputID: id} + } + if err := rows.Err(); err != nil { + return nil, billing.InputCursor{}, fmt.Errorf("read replay inputs: %w", err) + } + inputs := make([]billing.RawInput, 0, len(ids)) + for _, id := range ids { + input, err := r.RawInput(ctx, job.ProjectID, id) + if err != nil { + continue + } + inputs = append(inputs, input) + } + return inputs, next, nil +} + +// UpdateReplayProgress commits counters and the cursor and returns the job to +// the queue. Committing after each page is what makes a long replay resumable: +// a worker that dies mid-window resumes from the last committed position rather +// than restarting or, worse, reporting the partial scan as complete. +func (r *Repository) UpdateReplayProgress(ctx context.Context, job billing.ReplayJob, cursor billing.InputCursor, now time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE billing_replay_jobs + SET status='queued', examined_count=$2, unchanged_count=$3, new_fact_count=$4, + conflict_count=$5, available_at=$6, lease_owner=NULL, lease_expires_at=NULL, + cursor_received_at=$7, cursor_input_id=NULLIF($8,''), updated_at=$6 + WHERE id=$1`, job.ID, job.ExaminedCount, job.UnchangedCount, job.NewFactCount, + job.ConflictCount, now, cursor.ReceivedAt, cursor.InputID) + if err != nil { + return fmt.Errorf("update replay progress: %w", err) + } + return nil +} + +func (r *Repository) CompleteReplayJob(ctx context.Context, job billing.ReplayJob, comparison, errorCode string, now time.Time) error { + status := "completed" + if errorCode != "" { + status = "failed" + } + _, err := r.pool.Exec(ctx, + `UPDATE billing_replay_jobs + SET status=$2, comparison_result=NULLIF($3,''), examined_count=$4, unchanged_count=$5, + new_fact_count=$6, conflict_count=$7, last_error_code=NULLIF($8,''), completed_at=$9, + lease_owner=NULL, lease_expires_at=NULL, updated_at=$9 + WHERE id=$1`, job.ID, status, comparison, job.ExaminedCount, job.UnchangedCount, + job.NewFactCount, job.ConflictCount, errorCode, now) + if err != nil { + return fmt.Errorf("complete replay job: %w", err) + } + return nil +} + +// --------------------------------------------------------------------------- +// Health +// --------------------------------------------------------------------------- + +func (r *Repository) Health(ctx context.Context, actor billing.Actor, projectID, environmentID string) (billing.Health, error) { + if err := r.authorizeEnvironment(ctx, actor, projectID, environmentID); err != nil { + return billing.Health{}, err + } + health := billing.Health{EnvironmentID: environmentID} + enabled, err := r.BillingEnabled(ctx, projectID) + if err != nil { + return billing.Health{}, err + } + health.BillingEnabled = enabled + + err = r.pool.QueryRow(ctx, + `SELECT + (SELECT count(*) FROM store_server_credentials WHERE environment_id=$1 AND status='active'), + (SELECT count(*) FROM store_server_credentials WHERE environment_id=$1 AND status='active' + AND health_status IN ('degraded','unavailable')), + (SELECT count(*) FROM billing_validation_jobs WHERE environment_id=$1 AND status IN ('queued','leased')), + (SELECT COALESCE(max(extract(epoch from (now()-created_at))),0) FROM billing_validation_jobs + WHERE environment_id=$1 AND status IN ('queued','leased')), + (SELECT count(*) FROM billing_quarantine_records WHERE environment_id=$1 AND status IN ('open','retrying')), + (SELECT count(*) FROM billing_transaction_facts WHERE environment_id=$1), + (SELECT max(recorded_at) FROM billing_transaction_facts WHERE environment_id=$1), + (SELECT max(completed_at) FROM billing_reconciliation_runs WHERE environment_id=$1 AND status='completed')`, + environmentID). + Scan(&health.CredentialCount, &health.UnhealthyCredentials, &health.QueueDepth, + &health.OldestQueuedAgeSecs, &health.OpenQuarantineCount, &health.FactCount, + &health.LastFactRecordedAt, &health.LastReconciliationAt) + if err != nil { + return billing.Health{}, fmt.Errorf("read billing health: %w", err) + } + return health, nil +} + +var _ = strings.TrimSpace diff --git a/apps/api/internal/platform/billingpostgres/queue_metrics.go b/apps/api/internal/platform/billingpostgres/queue_metrics.go new file mode 100644 index 00000000..bfa833c4 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/queue_metrics.go @@ -0,0 +1,104 @@ +package billingpostgres + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const queueMetricTimeout = 5 * time.Second + +// billingQueues maps a metric queue label to its backing table. All three share +// the status/available_at/created_at job shape the rest of Mosaic uses. +var billingQueues = map[string]string{ + "validation": "billing_validation_jobs", + "reconciliation": "billing_reconciliation_runs", + "replay": "billing_replay_jobs", +} + +// RegisterQueueMetrics publishes backlog depth, oldest-job age, and dead-letter +// count for every billing queue. +// +// Depth alone cannot distinguish a busy queue from a stuck one, which is why +// oldest age is the alerting signal. The dead-letter gauge is included from day +// one deliberately: provider_sync_jobs shipped without queue metrics and the +// gap only became visible during an incident, so billing does not repeat it. +func (r *Repository) RegisterQueueMetrics() error { + meter := otel.Meter("mosaic/billing") + 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 billing 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 billing 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 billing dead-letter gauge: %w", err) + } + quarantine, err := meter.Int64ObservableGauge("mosaic.billing.quarantine.depth", + metric.WithDescription("Open Mosaic Billing quarantine records by reason.")) + if err != nil { + return fmt.Errorf("register billing quarantine gauge: %w", err) + } + + _, err = meter.RegisterCallback(func(ctx context.Context, observer metric.Observer) error { + ctx, cancel := context.WithTimeout(ctx, queueMetricTimeout) + defer cancel() + for queue, table := range billingQueues { + 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()-created_at))) FILTER (WHERE status IN ('queued','leased')), + count(*) FILTER (WHERE status = 'failed') + FROM `+table) + if err := row.Scan(&count, &age, &failed); err != nil { + continue + } + attributes := metric.WithAttributes( + attribute.String("family", "billing"), + attribute.String("queue", queue), + ) + 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 reason_code, count(*) FROM billing_quarantine_records + WHERE status IN ('open','retrying') GROUP BY reason_code`) + if err != nil { + return nil + } + defer rows.Close() + for rows.Next() { + var reason string + var count int64 + if err := rows.Scan(&reason, &count); err != nil { + continue + } + observer.ObserveInt64(quarantine, count, metric.WithAttributes( + attribute.String("reason_code", reason))) + } + return nil + }, depth, oldest, deadLettered, quarantine) + if err != nil { + return fmt.Errorf("register billing queue metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/billingpostgres/repository.go b/apps/api/internal/platform/billingpostgres/repository.go new file mode 100644 index 00000000..92365257 --- /dev/null +++ b/apps/api/internal/platform/billingpostgres/repository.go @@ -0,0 +1,919 @@ +// Package billingpostgres is the PostgreSQL implementation of the billing +// persistence port. +// +// Two rules run through every query here. Authorization is expressed in SQL +// alongside the data it protects, so a read cannot reach another tenant by +// forgetting a check in Go. And no query ever selects a raw purchase token, a +// signed payload, or a decrypted credential into a value that could reach a log +// line: envelope columns are read as opaque bytes and handed straight to the +// cipher. +package billingpostgres + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +var _ billing.Repository = (*Repository)(nil) + +// requireRole enforces membership and role in one statement. Owner and admin +// are the only roles permitted to touch billing: the ledger contains store +// evidence and the credential lifecycle controls production notification +// delivery, so neither is a member-level surface. +func requireRole(ctx context.Context, q queryer, actor billing.Actor, projectID string, roles ...string) (string, error) { + if strings.TrimSpace(actor.ID) == "" { + return "", billing.ErrUnauthenticated + } + 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, actor.ID).Scan(&role, &organizationID) + if errors.Is(err, pgx.ErrNoRows) { + // Membership absent is reported as not-found rather than forbidden so a + // caller cannot probe which Projects exist. + return "", billing.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve billing role: %w", err) + } + for _, allowed := range roles { + if role == allowed { + return organizationID, nil + } + } + return "", billing.ErrForbidden +} + +type queryer interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +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) { + // Absent means never configured, which is off. Mosaic Billing is opt-in. + return false, nil + } + if err != nil { + return false, fmt.Errorf("read billing settings: %w", err) + } + return enabled, nil +} + +// Settings is the first-class read of a Project's billing configuration. +// +// It exists because the dashboard was inferring enablement by probing billing +// health — a read that answers a different question, costs several aggregate +// queries, and cannot distinguish "billing is off" from "billing is on and +// nothing has happened yet". +func (r *Repository) Settings(ctx context.Context, actor billing.Actor, projectID string) (billing.Settings, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.Settings{}, err + } + settings := billing.Settings{ProjectID: projectID} + err := r.pool.QueryRow(ctx, + `SELECT + COALESCE((SELECT billing_enabled FROM billing_project_settings WHERE project_id=$1), false), + (SELECT updated_at FROM billing_project_settings WHERE project_id=$1), + (SELECT count(*) FROM store_server_credentials WHERE project_id=$1 AND status='active')`, + projectID).Scan(&settings.BillingEnabled, &settings.UpdatedAt, &settings.ActiveCredentialCount) + if err != nil { + return billing.Settings{}, fmt.Errorf("read billing settings: %w", err) + } + // The same rule the write path enforces, reported ahead of time so the + // caller can explain it instead of discovering it through a 409. + settings.CanDisable = settings.ActiveCredentialCount == 0 + return settings, nil +} + +func (r *Repository) OrganizationForProject(ctx context.Context, projectID string) (string, error) { + var organizationID string + err := r.pool.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id = $1`, projectID).Scan(&organizationID) + if errors.Is(err, pgx.ErrNoRows) { + return "", billing.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve project organization: %w", err) + } + return organizationID, nil +} + +// EnvironmentScope reads the Environment's own mode alongside the owning +// organization, in one statement, so the two can never be resolved from +// different rows. +func (r *Repository) EnvironmentScope(ctx context.Context, projectID, environmentID string) (string, string, error) { + var mode, organizationID string + err := r.pool.QueryRow(ctx, + `SELECT e.mode, p.organization_id FROM environments e + JOIN projects p ON p.id = e.project_id + WHERE e.id = $1 AND e.project_id = $2`, environmentID, projectID).Scan(&mode, &organizationID) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", billing.ErrNotFound + } + if err != nil { + return "", "", fmt.Errorf("resolve environment scope: %w", err) + } + return mode, organizationID, nil +} + +func (r *Repository) SetBillingEnabled(ctx context.Context, actor billing.Actor, projectID string, enabled bool, now time.Time) error { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return err + } + // Turning billing off must actually stop ingestion. It cannot, while a + // credential is live: Apple posts to an endpoint whose intake token still + // resolves, and every refusal spends one of five non-renewable delivery + // attempts. Requiring revocation first makes the switch mean what it says, + // and makes the operator's action the one that stops the store rather than a + // setting the store cannot see. + // + // The check and the write are **one statement**. Counting credentials and + // then writing separately is check-then-act: a credential created between + // the two calls leaves billing disabled with a live credential, which is + // precisely the state the rule exists to prevent. Folding the predicate into + // the INSERT ... SELECT makes the guard evaluate against the same snapshot + // that performs the write, and zero rows affected is the refusal. + tag, err := r.pool.Exec(ctx, + `INSERT INTO billing_project_settings(project_id, billing_enabled, updated_by_actor_id, created_at, updated_at) + SELECT $1,$2,$3,$4,$4 + WHERE $2::boolean + OR NOT EXISTS ( + SELECT 1 FROM store_server_credentials + WHERE project_id = $1 AND status = 'active') + ON CONFLICT (project_id) DO UPDATE SET billing_enabled = EXCLUDED.billing_enabled, + updated_by_actor_id = EXCLUDED.updated_by_actor_id, updated_at = EXCLUDED.updated_at`, + projectID, enabled, actor.ID, now) + if err != nil { + return fmt.Errorf("write billing settings: %w", err) + } + if tag.RowsAffected() == 0 { + // The only predicate that can suppress the write is the credential rule. + return billing.ErrCredentialsStillActive + } + return nil +} + +// --------------------------------------------------------------------------- +// Store Server Credentials +// --------------------------------------------------------------------------- + +const credentialColumns = `id, project_id, environment_id, provider, store_environment, name, status, health_status, + COALESCE(apple_issuer_id,''), COALESCE(apple_key_id,''), COALESCE(google_client_email,''), + COALESCE(google_pubsub_project_id,''), COALESCE(google_pubsub_subscription_id,''), + COALESCE(last_error_code,''), last_tested_at, created_at, rotated_at, revoked_at, updated_at` + +func scanCredential(row pgx.Row) (billing.StoreServerCredential, error) { + var value billing.StoreServerCredential + err := row.Scan(&value.ID, &value.ProjectID, &value.EnvironmentID, &value.Provider, &value.StoreEnvironment, + &value.Name, &value.Status, &value.HealthStatus, &value.AppleIssuerID, &value.AppleKeyID, + &value.GoogleClientEmail, &value.GooglePubSubProjectID, &value.GooglePubSubSubscription, + &value.LastErrorCode, &value.LastTestedAt, &value.CreatedAt, &value.RotatedAt, &value.RevokedAt, &value.UpdatedAt) + return value, err +} + +func (r *Repository) CreateCredential(ctx context.Context, actor billing.Actor, input billing.CredentialInput, envelope billing.Envelope, class string, intakeTokenDigest []byte, now time.Time) (billing.StoreServerCredential, error) { + organizationID, err := requireRole(ctx, r.pool, actor, input.ProjectID, "owner", "admin") + if err != nil { + return billing.StoreServerCredential{}, err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.StoreServerCredential{}, fmt.Errorf("begin credential create: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var environmentMode string + if err := tx.QueryRow(ctx, `SELECT mode FROM environments WHERE id = $1 AND project_id = $2`, + input.EnvironmentID, input.ProjectID).Scan(&environmentMode); err != nil { + return billing.StoreServerCredential{}, billing.ErrNotFound + } + + _, err = tx.Exec(ctx, + `INSERT INTO store_server_credentials( + id, project_id, organization_id, environment_id, environment_mode, provider, store_environment, + name, status, health_status, credential_class, + envelope_version, algorithm, key_id, nonce, ciphertext, fingerprint, + apple_issuer_id, apple_key_id, google_client_email, google_pubsub_project_id, + google_pubsub_subscription_id, intake_token_digest, intake_token_rotated_at, + created_by_actor_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active','untested',$9, + $10,$11,$12,$13,$14,$15, + NULLIF($16,''),NULLIF($17,''),NULLIF($18,''),NULLIF($19,''),NULLIF($20,''),$21,$22,$23,$24,$24)`, + input.CredentialID, input.ProjectID, organizationID, input.EnvironmentID, environmentMode, + input.Provider, input.StoreEnvironment, input.Name, class, + envelope.Version, envelope.Algorithm, envelope.KeyID, envelope.Nonce, envelope.Ciphertext, envelope.Fingerprint, + input.AppleIssuerID, input.AppleKeyID, input.GoogleClientEmail, + input.GooglePubSubProjectID, input.GooglePubSubSubscription, + intakeTokenDigest, nullTime(intakeTokenDigest, now), actor.ID, now) + if err != nil { + if isUniqueViolation(err) { + return billing.StoreServerCredential{}, billing.ErrConflict + } + return billing.StoreServerCredential{}, fmt.Errorf("insert store server credential: %w", err) + } + + for _, application := range input.Applications { + if _, err := tx.Exec(ctx, + `INSERT INTO store_server_credential_applications( + project_id, credential_id, application_id, platform, provider_application_identifier, created_at) + VALUES ($1,$2,$3,$4,$5,$6)`, + input.ProjectID, input.CredentialID, application.ApplicationID, application.Platform, + application.ProviderApplicationIdentifier, now); err != nil { + if isUniqueViolation(err) { + return billing.StoreServerCredential{}, billing.ErrConflict + } + return billing.StoreServerCredential{}, fmt.Errorf("scope credential to Application: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return billing.StoreServerCredential{}, fmt.Errorf("commit credential create: %w", err) + } + return r.GetCredential(ctx, actor, input.ProjectID, input.CredentialID) +} + +func nullTime(digest []byte, now time.Time) any { + if len(digest) == 0 { + return nil + } + return now +} + +func (r *Repository) RotateCredential(ctx context.Context, actor billing.Actor, projectID, credentialID string, envelope billing.Envelope, intakeTokenDigest []byte, now time.Time) (billing.StoreServerCredential, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.StoreServerCredential{}, err + } + tag, err := r.pool.Exec(ctx, + `UPDATE store_server_credentials + SET envelope_version=$3, algorithm=$4, key_id=$5, nonce=$6, ciphertext=$7, fingerprint=$8, + intake_token_digest = COALESCE($9, intake_token_digest), + intake_token_rotated_at = CASE WHEN $9 IS NULL THEN intake_token_rotated_at ELSE $10 END, + rotated_at=$10, updated_at=$10, health_status='untested', last_error_code=NULL + WHERE id=$1 AND project_id=$2 AND status='active'`, + credentialID, projectID, envelope.Version, envelope.Algorithm, envelope.KeyID, + envelope.Nonce, envelope.Ciphertext, envelope.Fingerprint, nullBytes(intakeTokenDigest), now) + if err != nil { + return billing.StoreServerCredential{}, fmt.Errorf("rotate store server credential: %w", err) + } + if tag.RowsAffected() == 0 { + return billing.StoreServerCredential{}, billing.ErrNotFound + } + return r.GetCredential(ctx, actor, projectID, credentialID) +} + +func nullBytes(value []byte) any { + if len(value) == 0 { + return nil + } + return value +} + +func (r *Repository) RevokeCredential(ctx context.Context, actor billing.Actor, projectID, credentialID string, now time.Time) (billing.StoreServerCredential, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.StoreServerCredential{}, err + } + // Revocation clears the intake token digest, which is what actually stops + // Apple notifications reaching this tenant: the endpoint stops resolving. + tag, err := r.pool.Exec(ctx, + `UPDATE store_server_credentials + SET status='revoked', health_status='revoked', revoked_at=$3, updated_at=$3, intake_token_digest=NULL + WHERE id=$1 AND project_id=$2 AND status='active'`, credentialID, projectID, now) + if err != nil { + return billing.StoreServerCredential{}, fmt.Errorf("revoke store server credential: %w", err) + } + if tag.RowsAffected() == 0 { + return billing.StoreServerCredential{}, billing.ErrNotFound + } + return r.GetCredential(ctx, actor, projectID, credentialID) +} + +func (r *Repository) ListCredentials(ctx context.Context, actor billing.Actor, projectID string) ([]billing.StoreServerCredential, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return nil, err + } + rows, err := r.pool.Query(ctx, + `SELECT `+credentialColumns+` FROM store_server_credentials WHERE project_id=$1 ORDER BY created_at DESC, id`, projectID) + if err != nil { + return nil, fmt.Errorf("list store server credentials: %w", err) + } + defer rows.Close() + credentials := make([]billing.StoreServerCredential, 0, 4) + for rows.Next() { + credential, err := scanCredential(rows) + if err != nil { + return nil, fmt.Errorf("scan store server credential: %w", err) + } + credentials = append(credentials, credential) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read store server credentials: %w", err) + } + for index := range credentials { + applications, err := r.credentialApplications(ctx, credentials[index].ID) + if err != nil { + return nil, err + } + credentials[index].Applications = applications + } + return credentials, nil +} + +func (r *Repository) GetCredential(ctx context.Context, actor billing.Actor, projectID, credentialID string) (billing.StoreServerCredential, error) { + if _, err := requireRole(ctx, r.pool, actor, projectID, "owner", "admin"); err != nil { + return billing.StoreServerCredential{}, err + } + credential, err := scanCredential(r.pool.QueryRow(ctx, + `SELECT `+credentialColumns+` FROM store_server_credentials WHERE id=$1 AND project_id=$2`, credentialID, projectID)) + if errors.Is(err, pgx.ErrNoRows) { + return billing.StoreServerCredential{}, billing.ErrNotFound + } + if err != nil { + return billing.StoreServerCredential{}, fmt.Errorf("read store server credential: %w", err) + } + credential.Applications, err = r.credentialApplications(ctx, credentialID) + if err != nil { + return billing.StoreServerCredential{}, err + } + return credential, nil +} + +func (r *Repository) credentialApplications(ctx context.Context, credentialID string) ([]billing.CredentialApplication, error) { + rows, err := r.pool.Query(ctx, + `SELECT application_id, platform, provider_application_identifier + FROM store_server_credential_applications WHERE credential_id=$1 ORDER BY application_id`, credentialID) + if err != nil { + return nil, fmt.Errorf("read credential Application scopes: %w", err) + } + defer rows.Close() + applications := make([]billing.CredentialApplication, 0, 4) + for rows.Next() { + var application billing.CredentialApplication + if err := rows.Scan(&application.ApplicationID, &application.Platform, &application.ProviderApplicationIdentifier); err != nil { + return nil, fmt.Errorf("scan credential Application scope: %w", err) + } + applications = append(applications, application) + } + return applications, rows.Err() +} + +// CredentialSecretFor reads the envelope for the worker. It performs no +// authorization because it is reachable only from the worker, which has already +// established the tenant from the input row it is processing. +func (r *Repository) CredentialSecretFor(ctx context.Context, projectID, credentialID string) (billing.StoreServerCredential, billing.Envelope, string, string, string, error) { + var credential billing.StoreServerCredential + var envelope billing.Envelope + var class, organizationID string + var bundleID *string + err := r.pool.QueryRow(ctx, + `SELECT c.id, c.project_id, c.environment_id, c.provider, c.store_environment, c.name, c.status, + c.health_status, COALESCE(c.apple_issuer_id,''), COALESCE(c.apple_key_id,''), + COALESCE(c.google_client_email,''), COALESCE(c.google_pubsub_project_id,''), + COALESCE(c.google_pubsub_subscription_id,''), + c.credential_class, c.organization_id, + c.envelope_version, c.algorithm, c.key_id, c.nonce, c.ciphertext, c.fingerprint, + (SELECT a.provider_application_identifier FROM store_server_credential_applications a + WHERE a.credential_id = c.id ORDER BY a.application_id LIMIT 1) + FROM store_server_credentials c WHERE c.id=$1 AND c.project_id=$2`, credentialID, projectID). + Scan(&credential.ID, &credential.ProjectID, &credential.EnvironmentID, &credential.Provider, + &credential.StoreEnvironment, &credential.Name, &credential.Status, &credential.HealthStatus, + &credential.AppleIssuerID, &credential.AppleKeyID, &credential.GoogleClientEmail, + &credential.GooglePubSubProjectID, &credential.GooglePubSubSubscription, + &class, &organizationID, + &envelope.Version, &envelope.Algorithm, &envelope.KeyID, &envelope.Nonce, &envelope.Ciphertext, &envelope.Fingerprint, + &bundleID) + if errors.Is(err, pgx.ErrNoRows) { + return billing.StoreServerCredential{}, billing.Envelope{}, "", "", "", billing.ErrNotFound + } + if err != nil { + return billing.StoreServerCredential{}, billing.Envelope{}, "", "", "", fmt.Errorf("read credential envelope: %w", err) + } + identifier := "" + if bundleID != nil { + identifier = *bundleID + } + return credential, envelope, class, organizationID, identifier, nil +} + +func (r *Repository) CredentialForApplication(ctx context.Context, environmentID, provider, providerApplicationIdentifier string) (billing.IntakeIdentity, string, error) { + var identity billing.IntakeIdentity + var applicationID string + err := r.pool.QueryRow(ctx, + `SELECT c.id, c.project_id, c.organization_id, c.environment_id, c.environment_mode, + c.store_environment, c.provider, c.status, a.application_id + FROM store_server_credentials c + JOIN store_server_credential_applications a ON a.credential_id = c.id + WHERE c.environment_id=$1 AND c.provider=$2 AND a.provider_application_identifier=$3 + AND c.status='active'`, environmentID, provider, providerApplicationIdentifier). + Scan(&identity.CredentialID, &identity.ProjectID, &identity.OrganizationID, &identity.EnvironmentID, + &identity.EnvironmentMode, &identity.StoreEnvironment, &identity.Provider, &identity.Status, &applicationID) + if errors.Is(err, pgx.ErrNoRows) { + return billing.IntakeIdentity{}, "", billing.ErrNotFound + } + if err != nil { + return billing.IntakeIdentity{}, "", fmt.Errorf("resolve credential for Application: %w", err) + } + return identity, applicationID, nil +} + +// CredentialForEnvironment resolves the one active credential a (Project, +// provider, Environment) scope has. +// +// Migration 00022 declares UNIQUE (project_id, provider, environment_id) on +// store_server_credentials, so this returns at most one row by schema rather +// than by an ordering rule an application defect could get wrong. The Project is +// part of the predicate as well as the Environment, so a caller that supplied a +// mismatched pair gets nothing rather than another tenant's credential. +func (r *Repository) CredentialForEnvironment(ctx context.Context, projectID, provider, environmentID string) (billing.IntakeIdentity, error) { + var identity billing.IntakeIdentity + err := r.pool.QueryRow(ctx, + `SELECT id, project_id, organization_id, environment_id, environment_mode, + store_environment, provider, status + FROM store_server_credentials + WHERE project_id=$1 AND provider=$2 AND environment_id=$3 AND status='active'`, + projectID, provider, environmentID). + Scan(&identity.CredentialID, &identity.ProjectID, &identity.OrganizationID, &identity.EnvironmentID, + &identity.EnvironmentMode, &identity.StoreEnvironment, &identity.Provider, &identity.Status) + if errors.Is(err, pgx.ErrNoRows) { + return billing.IntakeIdentity{}, billing.ErrCredentialMissing + } + if err != nil { + return billing.IntakeIdentity{}, fmt.Errorf("resolve credential for Environment: %w", err) + } + return identity, nil +} + +func (r *Repository) RecordCredentialEvent(ctx context.Context, projectID, credentialID, action, outcome, diagnosticCode, actorID string, now time.Time) error { + id := "sce_" + hashID(credentialID, action, now) + _, err := r.pool.Exec(ctx, + `INSERT INTO store_server_credential_events(id, project_id, credential_id, action, outcome, diagnostic_code, actor_id, occurred_at) + VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),$7,$8)`, + id, projectID, credentialID, action, outcome, diagnosticCode, actorID, now) + if err != nil { + return fmt.Errorf("record credential event: %w", err) + } + return nil +} + +func (r *Repository) UpdateCredentialHealth(ctx context.Context, projectID, credentialID, health, errorCode string, tested bool, now time.Time) error { + _, err := r.pool.Exec(ctx, + `UPDATE store_server_credentials + SET health_status=$3, last_error_code=NULLIF($4,''), updated_at=$5, + last_tested_at = CASE WHEN $6 THEN $5 ELSE last_tested_at END + WHERE id=$1 AND project_id=$2 AND status='active'`, + credentialID, projectID, health, errorCode, now, tested) + if err != nil { + return fmt.Errorf("update credential health: %w", err) + } + return nil +} + +func (r *Repository) ActiveCredentials(ctx context.Context, provider string) ([]billing.IntakeIdentity, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, project_id, organization_id, environment_id, environment_mode, store_environment, provider, status + FROM store_server_credentials WHERE provider=$1 AND status='active' ORDER BY id`, provider) + if err != nil { + return nil, fmt.Errorf("list active credentials: %w", err) + } + defer rows.Close() + identities := make([]billing.IntakeIdentity, 0, 8) + for rows.Next() { + var identity billing.IntakeIdentity + if err := rows.Scan(&identity.CredentialID, &identity.ProjectID, &identity.OrganizationID, + &identity.EnvironmentID, &identity.EnvironmentMode, &identity.StoreEnvironment, + &identity.Provider, &identity.Status); err != nil { + return nil, fmt.Errorf("scan active credential: %w", err) + } + identities = append(identities, identity) + } + return identities, rows.Err() +} + +// --------------------------------------------------------------------------- +// Intake +// --------------------------------------------------------------------------- + +// ResolveIntakeToken is the first statement executed on the unauthenticated +// Apple endpoint. It is a single indexed lookup on the digest so an invalid +// token costs one index probe and no body parsing. +func (r *Repository) ResolveIntakeToken(ctx context.Context, tokenDigest []byte) (billing.IntakeIdentity, error) { + var identity billing.IntakeIdentity + err := r.pool.QueryRow(ctx, + `SELECT id, project_id, organization_id, environment_id, environment_mode, store_environment, provider, status + FROM store_server_credentials + WHERE intake_token_digest = $1 AND status = 'active'`, tokenDigest). + Scan(&identity.CredentialID, &identity.ProjectID, &identity.OrganizationID, &identity.EnvironmentID, + &identity.EnvironmentMode, &identity.StoreEnvironment, &identity.Provider, &identity.Status) + if errors.Is(err, pgx.ErrNoRows) { + return billing.IntakeIdentity{}, billing.ErrNotFound + } + if err != nil { + return billing.IntakeIdentity{}, fmt.Errorf("resolve intake token: %w", err) + } + return identity, nil +} + +// ProviderApplicationIdentifier resolves the store-side identifier (Apple +// bundle id, Google package name) for one Application inside one credential's +// scope. +// +// Apple requires a `bid` claim on every JWT, and it must be the bundle id of +// the Application the transaction actually belongs to. Taking the credential's +// alphabetically-first scoped Application instead — which is what a bare +// "LIMIT 1" does — silently sends the wrong `bid` for every Application after +// the first, and Apple answers 401. Because a 401 classifies as retryable, the +// input then burns its whole attempt budget and dead-letters with a diagnostic +// pointing the operator at credential rotation, which is not the problem. +func (r *Repository) ProviderApplicationIdentifier(ctx context.Context, credentialID, applicationID string) (string, error) { + if strings.TrimSpace(applicationID) == "" { + return "", billing.ErrNotFound + } + var identifier string + err := r.pool.QueryRow(ctx, + `SELECT provider_application_identifier FROM store_server_credential_applications + WHERE credential_id=$1 AND application_id=$2`, credentialID, applicationID).Scan(&identifier) + if errors.Is(err, pgx.ErrNoRows) { + return "", billing.ErrNotFound + } + if err != nil { + return "", fmt.Errorf("resolve provider application identifier: %w", err) + } + return identifier, nil +} + +func (r *Repository) ApplicationForIdentifier(ctx context.Context, credentialID, identifier string) (string, string, error) { + if strings.TrimSpace(identifier) == "" { + return "", "", billing.ErrNotFound + } + var applicationID, platform string + err := r.pool.QueryRow(ctx, + `SELECT application_id, platform FROM store_server_credential_applications + WHERE credential_id=$1 AND provider_application_identifier=$2`, credentialID, identifier). + Scan(&applicationID, &platform) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", billing.ErrNotFound + } + if err != nil { + return "", "", fmt.Errorf("resolve Application for identifier: %w", err) + } + return applicationID, platform, nil +} + +// PersistRawInput writes a Raw Billing Input idempotently and optionally +// enqueues validation, in one transaction. +// +// The conflict handling is the important part. When the idempotency key already +// exists, the stored content digest is compared in constant time. Equal means a +// genuine duplicate delivery and nothing is written. Different means the same +// provider event id arrived carrying different content, which is either a +// provider defect or a forgery attempt; that is recorded as a conflict and +// quarantined rather than being allowed to overwrite the original. +func (r *Repository) PersistRawInput(ctx context.Context, input billing.RawInput, enqueue bool, now time.Time) (billing.PersistResult, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return billing.PersistResult{}, fmt.Errorf("begin raw input write: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if input.ID == "" { + input.ID = "bri_" + hashID(input.ProjectID, string(input.IdempotencyKey), now) + } + if input.OrganizationID == "" { + if err := tx.QueryRow(ctx, `SELECT organization_id FROM projects WHERE id=$1`, input.ProjectID). + Scan(&input.OrganizationID); err != nil { + return billing.PersistResult{}, fmt.Errorf("resolve organization for raw input: %w", err) + } + } + if input.EnvironmentMode == "" { + if err := tx.QueryRow(ctx, `SELECT mode FROM environments WHERE id=$1 AND project_id=$2`, + input.EnvironmentID, input.ProjectID).Scan(&input.EnvironmentMode); err != nil { + return billing.PersistResult{}, fmt.Errorf("resolve environment mode for raw input: %w", err) + } + } + if input.BodyState == "" { + input.BodyState = "not_retained" + } + + var envelopeVersion *int + var algorithm, keyID *string + var nonce, ciphertext, fingerprint []byte + if input.Envelope != nil && input.BodyState == "stored" { + envelopeVersion = &input.Envelope.Version + algorithm, keyID = &input.Envelope.Algorithm, &input.Envelope.KeyID + nonce, ciphertext, fingerprint = input.Envelope.Nonce, input.Envelope.Ciphertext, input.Envelope.Fingerprint + } + + tag, err := tx.Exec(ctx, + `INSERT INTO billing_raw_inputs( + id, project_id, organization_id, environment_id, environment_mode, application_id, credential_id, + provider, source, source_authority, provider_event_id, idempotency_key, content_digest, + transaction_reference_digest, body_state, envelope_version, algorithm, key_id, nonce, ciphertext, + fingerprint, authentication_result, store_environment, notification_kind, notification_subtype, + ingestion_status, correlation_id, provider_occurred_at, received_at, expires_at) + VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,NULLIF($11,''),$12,$13,$14,$15, + $16,$17,$18,$19,$20,$21,$22,$23,NULLIF($24,''),NULLIF($25,''),$26,$27,$28,$29,$30) + ON CONFLICT (project_id, provider, idempotency_key) DO NOTHING`, + input.ID, input.ProjectID, input.OrganizationID, input.EnvironmentID, input.EnvironmentMode, + input.ApplicationID, input.CredentialID, input.Provider, input.Source, input.SourceAuthority, + input.ProviderEventID, input.IdempotencyKey, input.ContentDigest, nullBytes(input.TransactionReferenceDigest), + input.BodyState, envelopeVersion, algorithm, keyID, nonce, ciphertext, fingerprint, + input.AuthenticationResult, input.StoreEnvironment, input.NotificationKind, input.NotificationSubtype, + input.IngestionStatus, input.CorrelationID, input.ProviderOccurredAt, input.ReceivedAt, input.ExpiresAt) + if err != nil { + return billing.PersistResult{}, fmt.Errorf("insert raw billing input: %w", err) + } + + result := billing.PersistResult{RawInputID: input.ID, Status: billing.IngestAccepted} + if tag.RowsAffected() == 0 { + var existingID string + var existingDigest []byte + if err := tx.QueryRow(ctx, + `SELECT id, content_digest FROM billing_raw_inputs + WHERE project_id=$1 AND provider=$2 AND idempotency_key=$3`, + input.ProjectID, input.Provider, input.IdempotencyKey).Scan(&existingID, &existingDigest); err != nil { + return billing.PersistResult{}, fmt.Errorf("read existing raw billing input: %w", err) + } + result.RawInputID = existingID + if subtle.ConstantTimeCompare(existingDigest, input.ContentDigest) == 1 { + result.Status = billing.IngestDuplicate + } else { + result.Status = billing.IngestConflicted + result.Conflicted = true + if err := upsertQuarantine(ctx, tx, input.ProjectID, input.EnvironmentID, billing.QuarantineWrite{ + RawInputID: existingID, Provider: input.Provider, + ReasonCode: billing.QuarantineInputContentConflict, Severity: "security", + DiagnosticCode: "idempotency_key_content_conflict", OccurredAt: now, + }); err != nil { + return billing.PersistResult{}, err + } + } + // The duplicate-detected entry is bucketed by hour, not stamped with the + // instant. + // + // Including `now` in the id made every repeat post a fresh ledger row, + // which handed the unbounded-growth problem straight back to the ledger: + // the intake endpoint is deliberately unlimited (a 429 to Apple spends a + // non-renewable delivery attempt), so whoever holds a leaked intake token + // could post the same body forever and grow the table without bound. The + // raw input is already capped the same way; the ledger has to be too, or + // the cap accomplishes nothing. + // + // One entry per input per hour is the right granularity for what the + // entry actually says. "This input was delivered again" is not new + // information on the thousandth repeat, and the volume already lives in + // the intake counters, which is where a rate belongs. + if err := insertLedger(ctx, tx, billing.LedgerEntry{ + ID: "ble_" + hashID(existingID, "duplicate", now.UTC().Truncate(time.Hour)), + ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, EntryType: billing.LedgerInputDuplicateDetected, + RawInputID: existingID, CorrelationID: input.CorrelationID, OccurredAt: now, + }); err != nil { + return billing.PersistResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return billing.PersistResult{}, fmt.Errorf("commit duplicate raw input: %w", err) + } + return result, nil + } + + if err := insertLedger(ctx, tx, billing.LedgerEntry{ + ID: "ble_" + hashID(input.ID, "received", now), ProjectID: input.ProjectID, + EnvironmentID: input.EnvironmentID, EntryType: billing.LedgerInputReceived, + RawInputID: input.ID, CredentialID: input.CredentialID, + CorrelationID: input.CorrelationID, OccurredAt: now, + }); err != nil { + return billing.PersistResult{}, err + } + if input.IngestionStatus == billing.IngestQuarantined { + if err := upsertQuarantine(ctx, tx, input.ProjectID, input.EnvironmentID, billing.QuarantineWrite{ + RawInputID: input.ID, ApplicationID: input.ApplicationID, Provider: input.Provider, + ReasonCode: quarantineReasonForIntake(input), Severity: "error", + DiagnosticCode: "intake_attribution_failed", OccurredAt: now, + }); err != nil { + return billing.PersistResult{}, err + } + } + + if enqueue { + if _, err := tx.Exec(ctx, + `INSERT INTO billing_validation_jobs( + id, project_id, environment_id, raw_input_id, provider, status, + attempt_count, max_attempts, available_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,'queued',0,$6,$7,$7,$7) + ON CONFLICT (raw_input_id) DO UPDATE + SET status='queued', available_at=$7, updated_at=$7, lease_owner=NULL, lease_expires_at=NULL`, + "bvj_"+hashID(input.ID, "job", now), input.ProjectID, input.EnvironmentID, input.ID, + input.Provider, billing.MaxValidationAttempts, now); err != nil { + return billing.PersistResult{}, fmt.Errorf("enqueue validation job: %w", err) + } + result.Enqueued = true + } + + if err := tx.Commit(ctx); err != nil { + return billing.PersistResult{}, fmt.Errorf("commit raw billing input: %w", err) + } + return result, nil +} + +func quarantineReasonForIntake(input billing.RawInput) string { + if input.AuthenticationResult == billing.AuthFailed { + return billing.QuarantineSignatureInvalid + } + return billing.QuarantineApplicationMismatch +} + +func (r *Repository) RawInput(ctx context.Context, projectID, rawInputID string) (billing.RawInput, error) { + var input billing.RawInput + var applicationID, credentialID, providerEventID, notificationKind, notificationSubtype *string + var envelopeVersion *int + var algorithm, keyID *string + var nonce, ciphertext, fingerprint, referenceDigest []byte + err := r.pool.QueryRow(ctx, + `SELECT id, project_id, organization_id, environment_id, environment_mode, application_id, credential_id, + provider, source, source_authority, provider_event_id, idempotency_key, content_digest, + transaction_reference_digest, body_state, envelope_version, algorithm, key_id, nonce, ciphertext, + fingerprint, authentication_result, store_environment, notification_kind, notification_subtype, + ingestion_status, correlation_id, provider_occurred_at, received_at, expires_at + FROM billing_raw_inputs WHERE id=$1 AND project_id=$2`, rawInputID, projectID). + Scan(&input.ID, &input.ProjectID, &input.OrganizationID, &input.EnvironmentID, &input.EnvironmentMode, + &applicationID, &credentialID, &input.Provider, &input.Source, &input.SourceAuthority, + &providerEventID, &input.IdempotencyKey, &input.ContentDigest, &referenceDigest, + &input.BodyState, &envelopeVersion, &algorithm, &keyID, &nonce, &ciphertext, &fingerprint, + &input.AuthenticationResult, &input.StoreEnvironment, ¬ificationKind, ¬ificationSubtype, + &input.IngestionStatus, &input.CorrelationID, &input.ProviderOccurredAt, &input.ReceivedAt, &input.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return billing.RawInput{}, billing.ErrNotFound + } + if err != nil { + return billing.RawInput{}, fmt.Errorf("read raw billing input: %w", err) + } + input.ApplicationID = deref(applicationID) + input.CredentialID = deref(credentialID) + input.ProviderEventID = deref(providerEventID) + input.NotificationKind = deref(notificationKind) + input.NotificationSubtype = deref(notificationSubtype) + input.TransactionReferenceDigest = referenceDigest + if envelopeVersion != nil && algorithm != nil && keyID != nil { + input.Envelope = &billing.Envelope{ + Version: *envelopeVersion, Algorithm: *algorithm, KeyID: *keyID, + Nonce: nonce, Ciphertext: ciphertext, Fingerprint: fingerprint, + } + } + return input, nil +} + +func deref(value *string) string { + if value == nil { + return "" + } + return *value +} + +// --------------------------------------------------------------------------- +// Observation authentication +// --------------------------------------------------------------------------- + +func (r *Repository) AuthenticateSDKKey(ctx context.Context, raw string) (billing.ObservationScope, error) { + return r.authenticateKey(ctx, raw, "public_sdk") +} + +func (r *Repository) AuthenticateServerKey(ctx context.Context, raw string) (billing.ObservationScope, error) { + return r.authenticateKey(ctx, raw, "secret_server") +} + +// authenticateKey mirrors the analytics SDK-key path exactly: prefix lookup, +// then a constant-time digest comparison, so a wrong key costs the same time as +// a right one and the prefix alone proves nothing. +func (r *Repository) authenticateKey(ctx context.Context, raw, kind string) (billing.ObservationScope, error) { + parts := strings.SplitN(strings.TrimSpace(raw), ".", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return billing.ObservationScope{}, billing.ErrUnauthenticated + } + var scope billing.ObservationScope + var stored []byte + var storedKind string + var revoked *time.Time + var applicationID *string + var platform *string + err := r.pool.QueryRow(ctx, + `SELECT k.id, p.organization_id, e.project_id, e.id, e.mode, k.application_id, a.platform, + k.kind, k.secret_digest, k.revoked_at + FROM api_keys k + JOIN environments e ON e.id = k.environment_id + JOIN projects p ON p.id = e.project_id + LEFT JOIN applications a ON a.id = k.application_id + WHERE k.prefix = $1`, parts[0]). + Scan(&scope.APIKeyID, &scope.OrganizationID, &scope.ProjectID, &scope.EnvironmentID, + &scope.EnvironmentMode, &applicationID, &platform, &storedKind, &stored, &revoked) + if errors.Is(err, pgx.ErrNoRows) { + return billing.ObservationScope{}, billing.ErrUnauthenticated + } + if err != nil { + return billing.ObservationScope{}, fmt.Errorf("authenticate billing key: %w", err) + } + digest := sha256.Sum256([]byte(raw)) + if storedKind != kind || revoked != nil || subtle.ConstantTimeCompare(digest[:], stored) != 1 { + return billing.ObservationScope{}, billing.ErrUnauthenticated + } + scope.ApplicationID = deref(applicationID) + scope.Platform = deref(platform) + if kind == "public_sdk" && scope.ApplicationID == "" { + // A public SDK key without an Application cannot attribute an + // observation to anything, so it is not usable here. + return billing.ObservationScope{}, billing.ErrUnauthenticated + } + return scope, nil +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +func insertLedger(ctx context.Context, tx pgx.Tx, entry billing.LedgerEntry) error { + detail := []byte("{}") + if len(entry.Detail) > 0 { + encoded, err := json.Marshal(entry.Detail) + if err == nil { + detail = encoded + } + } + _, err := tx.Exec(ctx, + `INSERT INTO billing_ledger_entries( + id, project_id, environment_id, entry_type, raw_input_id, validation_attempt_id, + transaction_fact_id, credential_id, detail, correlation_id, occurred_at) + VALUES ($1,$2,$3,$4,NULLIF($5,''),NULLIF($6,''),NULLIF($7,''),NULLIF($8,''),$9,$10,$11) + ON CONFLICT (id) DO NOTHING`, + entry.ID, entry.ProjectID, entry.EnvironmentID, entry.EntryType, entry.RawInputID, + entry.ValidationAttemptID, entry.TransactionFactID, entry.CredentialID, detail, + entry.CorrelationID, entry.OccurredAt) + if err != nil { + return fmt.Errorf("append billing ledger entry: %w", err) + } + return nil +} + +// upsertQuarantine opens a record or advances an existing one. A repeated +// failure increments the attempt counter rather than creating a second record, +// so the operator queue reflects distinct problems rather than retry volume. +func upsertQuarantine(ctx context.Context, tx pgx.Tx, projectID, environmentID string, write billing.QuarantineWrite) error { + scopes := write.Scopes + if scopes == nil { + scopes = []string{} + } + _, err := tx.Exec(ctx, + `INSERT INTO billing_quarantine_records( + id, project_id, environment_id, raw_input_id, application_id, provider, reason_code, + severity, scopes, status, attempt_count, first_seen_at, last_attempt_at, diagnostic_code) + VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,'open',1,$10,$10,NULLIF($11,'')) + ON CONFLICT (raw_input_id) DO UPDATE + SET attempt_count = billing_quarantine_records.attempt_count + 1, + last_attempt_at = EXCLUDED.last_attempt_at, + reason_code = EXCLUDED.reason_code, + severity = EXCLUDED.severity, + diagnostic_code = EXCLUDED.diagnostic_code, + status = CASE WHEN billing_quarantine_records.status IN ('closed_after_success','closed_superseded') + THEN billing_quarantine_records.status ELSE 'open' END`, + "bqr_"+hashID(write.RawInputID, write.ReasonCode, write.OccurredAt), projectID, environmentID, + write.RawInputID, write.ApplicationID, write.Provider, write.ReasonCode, write.Severity, + scopes, write.OccurredAt, write.DiagnosticCode) + if err != nil { + return fmt.Errorf("record quarantine: %w", err) + } + return nil +} + +// 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 { + 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/config/config.go b/apps/api/internal/platform/config/config.go index 309a8a81..bc55052a 100644 --- a/apps/api/internal/platform/config/config.go +++ b/apps/api/internal/platform/config/config.go @@ -47,9 +47,43 @@ type Config struct { Delivery DeliveryConfig Analytics AnalyticsConfig Providers ProviderConfig + Billing BillingConfig Worker WorkerConfig } +// BillingConfig holds Phase 9A's deployment-level settings. Mosaic Billing is +// additionally per-Project opt-in and off by default, so enabling it here only +// makes it available, never active. +type BillingConfig struct { + Enabled bool `envconfig:"MOSAIC_BILLING_ENABLED" default:"false"` + // NotificationBaseURL is the public origin Apple posts notifications to. It + // is used solely to render the endpoint URL returned once on credential + // create and rotate. + NotificationBaseURL string `envconfig:"MOSAIC_BILLING_NOTIFICATION_BASE_URL"` + // RawRetentionDays bounds how long an encrypted Raw Billing Input body is + // kept. Ninety days is the midpoint of Apple's 180-day production and + // 30-day sandbox notification-history windows. Normalized facts are kept + // indefinitely; only the sensitive payload behind them expires. + RawRetentionDays int `envconfig:"MOSAIC_BILLING_RAW_RETENTION_DAYS" default:"90"` + // WorkerPollInterval is dedicated so notification latency is not coupled to + // analytics aggregation load in the shared worker loop. + WorkerPollInterval time.Duration `envconfig:"MOSAIC_BILLING_WORKER_POLL_INTERVAL" default:"1s"` + AppleProductionBaseURL string `envconfig:"MOSAIC_APPLE_STOREKIT_BASE_URL" default:"https://api.storekit.apple.com"` + AppleSandboxBaseURL string `envconfig:"MOSAIC_APPLE_STOREKIT_SANDBOX_BASE_URL" default:"https://api.storekit-sandbox.apple.com"` + GooglePlayBaseURL string `envconfig:"MOSAIC_GOOGLE_PLAY_BASE_URL" default:"https://androidpublisher.googleapis.com"` + GooglePubSubBaseURL string `envconfig:"MOSAIC_GOOGLE_PUBSUB_BASE_URL" default:"https://pubsub.googleapis.com"` + // Observation submissions may be shed with a 429 because SDKs queue and + // retry. Notification intake deliberately has no limiter. + 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"` +} + +// RawRetention is the configured retention window as a duration. +func (cfg BillingConfig) RawRetention() time.Duration { + return time.Duration(cfg.RawRetentionDays) * 24 * time.Hour +} + // ProductionLike reports whether the deployment must satisfy the strict // production configuration guards. func (cfg Config) ProductionLike() bool { @@ -233,6 +267,11 @@ func load() (Config, error) { cfg.Analytics.EventSchemaPath = strings.TrimSpace(cfg.Analytics.EventSchemaPath) cfg.Analytics.EventV2SchemaPath = strings.TrimSpace(cfg.Analytics.EventV2SchemaPath) cfg.Providers.CredentialKeyring = strings.TrimSpace(cfg.Providers.CredentialKeyring) + cfg.Billing.NotificationBaseURL = strings.TrimSpace(cfg.Billing.NotificationBaseURL) + cfg.Billing.AppleProductionBaseURL = strings.TrimSpace(cfg.Billing.AppleProductionBaseURL) + cfg.Billing.AppleSandboxBaseURL = strings.TrimSpace(cfg.Billing.AppleSandboxBaseURL) + cfg.Billing.GooglePlayBaseURL = strings.TrimSpace(cfg.Billing.GooglePlayBaseURL) + cfg.Billing.GooglePubSubBaseURL = strings.TrimSpace(cfg.Billing.GooglePubSubBaseURL) cfg.Providers.RevenueCatBaseURL = strings.TrimSpace(cfg.Providers.RevenueCatBaseURL) cfg.ObjectStore.Endpoint = strings.TrimSpace(cfg.ObjectStore.Endpoint) cfg.ObjectStore.AccessKey = strings.TrimSpace(cfg.ObjectStore.AccessKey) @@ -322,6 +361,7 @@ func (cfg Config) validate() error { cfg.validateObjectStore(report, productionLike) cfg.validateProviders(report, productionLike) cfg.validateAnalytics(report) + cfg.validateBilling(report, productionLike) cfg.validateWorker(report) if strings.TrimSpace(cfg.Telemetry.ServiceName) == "" { @@ -561,6 +601,55 @@ func (cfg Config) validateAnalytics(report *problems) { } } +func (cfg Config) validateBilling(report *problems, productionLike bool) { + if !cfg.Billing.Enabled { + return + } + // Billing cannot run without the keyring: every Store Server Credential and + // every retained Raw Billing Input body is sealed under it. + if cfg.Providers.CredentialKeyring == "" { + report.add("MOSAIC_PROVIDER_CREDENTIAL_KEYRING is required when MOSAIC_BILLING_ENABLED is true") + } + if cfg.Billing.RawRetentionDays < 30 || cfg.Billing.RawRetentionDays > 400 { + report.add("MOSAIC_BILLING_RAW_RETENTION_DAYS must be between 30 and 400") + } + if cfg.Billing.WorkerPollInterval <= 0 { + report.add("MOSAIC_BILLING_WORKER_POLL_INTERVAL must be greater than zero") + } + // Apple posts notifications to this origin, so it must be a real HTTPS + // origin an operator can hand to App Store Connect. + base, err := url.Parse(cfg.Billing.NotificationBaseURL) + switch { + case cfg.Billing.NotificationBaseURL == "": + report.add("MOSAIC_BILLING_NOTIFICATION_BASE_URL is required when MOSAIC_BILLING_ENABLED is true") + case err != nil || base.Host == "" || base.User != nil || (base.Scheme != "https" && base.Scheme != "http"): + report.add("MOSAIC_BILLING_NOTIFICATION_BASE_URL must be an absolute HTTP(S) URL without credentials") + case productionLike && base.Scheme != "https": + report.add("MOSAIC_BILLING_NOTIFICATION_BASE_URL must use HTTPS outside development and test") + } + for name, value := range map[string]string{ + "MOSAIC_APPLE_STOREKIT_BASE_URL": cfg.Billing.AppleProductionBaseURL, + "MOSAIC_APPLE_STOREKIT_SANDBOX_BASE_URL": cfg.Billing.AppleSandboxBaseURL, + "MOSAIC_GOOGLE_PLAY_BASE_URL": cfg.Billing.GooglePlayBaseURL, + "MOSAIC_GOOGLE_PUBSUB_BASE_URL": cfg.Billing.GooglePubSubBaseURL, + } { + parsed, err := url.Parse(value) + if value == "" || err != nil || parsed.Host == "" || parsed.User != nil || + (parsed.Scheme != "https" && parsed.Scheme != "http") { + report.add("%s must be an absolute HTTP(S) URL without credentials", name) + continue + } + if productionLike && parsed.Scheme != "https" { + report.add("%s must use HTTPS outside development and test", name) + } + } + report.requirePositiveInts(map[string]int{ + "MOSAIC_BILLING_OBSERVATIONS_PER_MINUTE": cfg.Billing.ObservationsPerMinute, + "MOSAIC_BILLING_OBSERVATION_BURST": cfg.Billing.ObservationBurst, + "MOSAIC_BILLING_LIMITER_ENTRIES": cfg.Billing.LimiterEntries, + }) +} + func (cfg Config) validateWorker(report *problems) { if _, _, err := net.SplitHostPort(cfg.Worker.HealthAddress); err != nil { report.add("MOSAIC_WORKER_HEALTH_ADDRESS must be a host:port address") diff --git a/apps/api/internal/platform/googleplay/client.go b/apps/api/internal/platform/googleplay/client.go new file mode 100644 index 00000000..7fce0afa --- /dev/null +++ b/apps/api/internal/platform/googleplay/client.go @@ -0,0 +1,487 @@ +package googleplay + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +const ( + DefaultPlayBaseURL = "https://androidpublisher.googleapis.com" + DefaultPubSubBaseURL = "https://pubsub.googleapis.com" + defaultBodyLimit = int64(2 << 20) +) + +type Config struct { + PlayBaseURL string + PubSubBaseURL string + RequestTimeout time.Duration + ConnectTimeout time.Duration + MaxResponseBytes int64 +} + +type Client struct { + play *url.URL + pubsub *url.URL + httpClient *http.Client + maxResponseBytes int64 + tokens *tokenCache + tracer trace.Tracer + now func() time.Time +} + +func New(config Config) (*Client, error) { + play, err := parseBase(config.PlayBaseURL, DefaultPlayBaseURL) + if err != nil { + return nil, err + } + pubsub, err := parseBase(config.PubSubBaseURL, DefaultPubSubBaseURL) + if err != nil { + return nil, err + } + if config.RequestTimeout <= 0 { + config.RequestTimeout = 8 * time.Second + } + if config.ConnectTimeout <= 0 { + config.ConnectTimeout = 3 * time.Second + } + if config.MaxResponseBytes <= 0 { + config.MaxResponseBytes = defaultBodyLimit + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = (&net.Dialer{Timeout: config.ConnectTimeout, KeepAlive: 30 * time.Second}).DialContext + transport.ResponseHeaderTimeout = config.RequestTimeout + transport.TLSHandshakeTimeout = config.ConnectTimeout + return &Client{ + play: play, + pubsub: pubsub, + httpClient: &http.Client{ + Transport: transport, + Timeout: config.RequestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + maxResponseBytes: config.MaxResponseBytes, + tokens: newTokenCache(), + tracer: otel.Tracer("github.com/Mujhtech/mosaic/apps/api/googleplay"), + now: func() time.Time { return time.Now().UTC() }, + }, nil +} + +func parseBase(value, fallback string) (*url.URL, error) { + trimmed := strings.TrimRight(strings.TrimSpace(value), "/") + if trimmed == "" { + trimmed = fallback + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Host == "" || parsed.User != nil || + (parsed.Scheme != "https" && parsed.Scheme != "http") { + return nil, fmt.Errorf("Google base URL %q must be an absolute HTTP(S) URL without credentials", value) + } + return parsed, nil +} + +// Error is a classified Google failure. Like the Apple client it carries a +// status and a machine code but never a response body. +type Error struct { + HTTPStatus int + GoogleCode string + RetryAfter time.Duration + Op string + cause error +} + +func (e *Error) Error() string { + if e.GoogleCode != "" { + return fmt.Sprintf("Google %s failed with status %d (code %s)", e.Op, e.HTTPStatus, e.GoogleCode) + } + return fmt.Sprintf("Google %s failed with status %d", e.Op, e.HTTPStatus) +} + +func (e *Error) Unwrap() error { return e.cause } + +// safeCode bounds a provider-supplied status string to the charset the +// provider_code column accepts, so a hostile or malformed value can never +// become a persistence failure or a log-injection vector. +func safeCode(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if len(trimmed) > 128 { + trimmed = trimmed[:128] + } + for _, r := range trimmed { + if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '.' || r == '-') { + return "unclassified" + } + } + return trimmed +} + +// ParseRetryAfter reads an RFC 7231 Retry-After (delta-seconds or HTTP-date). +// Google follows the standard; Apple does not, which is exactly why the two +// parsers live in two packages and are never interchanged. +func ParseRetryAfter(header string, now time.Time) (time.Duration, bool) { + trimmed := strings.TrimSpace(header) + if trimmed == "" { + return 0, false + } + if seconds, err := strconv.Atoi(trimmed); err == nil { + if seconds <= 0 || seconds > 86400 { + return 0, false + } + return time.Duration(seconds) * time.Second, true + } + if when, err := http.ParseTime(trimmed); err == nil { + delta := when.Sub(now) + if delta <= 0 || delta > 24*time.Hour { + return 0, false + } + return delta, true + } + return 0, false +} + +// SubscriptionPurchase is the subset of SubscriptionPurchaseV2 Phase 9A reads. +// externalAccountIdentifiers, subscribeWithGoogleInfo, and every other +// customer-identifying member are deliberately not decoded. +type SubscriptionPurchase struct { + Kind string `json:"kind"` + RegionCode string `json:"regionCode"` + StartTime string `json:"startTime"` + SubscriptionState string `json:"subscriptionState"` + LatestOrderID string `json:"latestOrderId"` + LinkedPurchaseToken string `json:"linkedPurchaseToken"` + AcknowledgementState string `json:"acknowledgementState"` + TestPurchase *struct{} `json:"testPurchase"` + CanceledStateContext json.RawMessage `json:"canceledStateContext"` + PausedStateContext json.RawMessage `json:"pausedStateContext"` + LineItems []struct { + ProductID string `json:"productId"` + ExpiryTime string `json:"expiryTime"` + OfferDetails *struct { + BasePlanID string `json:"basePlanId"` + OfferID string `json:"offerId"` + OfferTags []string `json:"offerTags"` + } `json:"offerDetails"` + AutoRenewingPlan *struct { + AutoRenewEnabled bool `json:"autoRenewEnabled"` + } `json:"autoRenewingPlan"` + PrepaidPlan json.RawMessage `json:"prepaidPlan"` + } `json:"lineItems"` +} + +// GetSubscription performs purchases.subscriptionsv2.get. It is a pure read; +// no acknowledgement follows it. +func (c *Client) GetSubscription(ctx context.Context, account *ServiceAccount, packageName, purchaseToken string) (SubscriptionPurchase, error) { + var purchase SubscriptionPurchase + path := fmt.Sprintf("/androidpublisher/v3/applications/%s/purchases/subscriptionsv2/tokens/%s", + url.PathEscape(packageName), url.PathEscape(purchaseToken)) + err := c.call(ctx, account, http.MethodGet, c.play, path, nil, &purchase, "subscription_get", ScopeAndroidPublisher) + return purchase, err +} + +// ProductPurchase is the subset of ProductPurchase Phase 9A reads. +type ProductPurchase struct { + Kind string `json:"kind"` + PurchaseTimeMillis string `json:"purchaseTimeMillis"` + PurchaseState int `json:"purchaseState"` + ConsumptionState int `json:"consumptionState"` + AcknowledgementState int `json:"acknowledgementState"` + PurchaseType *int `json:"purchaseType"` + OrderID string `json:"orderId"` + ProductID string `json:"productId"` + Quantity int `json:"quantity"` + RegionCode string `json:"regionCode"` +} + +// GetProduct performs purchases.products.get. +func (c *Client) GetProduct(ctx context.Context, account *ServiceAccount, packageName, productID, purchaseToken string) (ProductPurchase, error) { + var purchase ProductPurchase + path := fmt.Sprintf("/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s", + url.PathEscape(packageName), url.PathEscape(productID), url.PathEscape(purchaseToken)) + err := c.call(ctx, account, http.MethodGet, c.play, path, nil, &purchase, "product_get", ScopeAndroidPublisher) + return purchase, err +} + +// Order is the subset of the orders resource Phase 9A reads. The resource +// exists in this client for exactly one reason: it returns the full +// purchaseToken for an orderId, which is what makes a client observation that +// carries only an order ID actionable server-side. +type Order struct { + OrderID string `json:"orderId"` + PurchaseToken string `json:"purchaseToken"` + State string `json:"state"` + LineItems []struct { + ProductID string `json:"productId"` + } `json:"lineItems"` +} + +// GetOrder performs orders.get. +func (c *Client) GetOrder(ctx context.Context, account *ServiceAccount, packageName, orderID string) (Order, error) { + var order Order + path := fmt.Sprintf("/androidpublisher/v3/applications/%s/orders/%s", + url.PathEscape(packageName), url.PathEscape(orderID)) + err := c.call(ctx, account, http.MethodGet, c.play, path, nil, &order, "order_get", ScopeAndroidPublisher) + return order, err +} + +// ReceivedMessage is one Pub/Sub message from a pull response. +type ReceivedMessage struct { + AckID string + Message PubSubMessage +} + +// PubSubMessage is the Pub/Sub envelope. `data` is base64 in transit. +type PubSubMessage struct { + Data string `json:"data"` + MessageID string `json:"messageId"` + PublishTime string `json:"publishTime"` + Attributes map[string]string `json:"attributes"` +} + +// Pull consumes up to maxMessages from an RTDN subscription. +// +// Google's RTDN transport is a pull subscription rather than a push endpoint by +// owner decision: a push subscription would require Mosaic to expose a second +// public unauthenticated endpoint and to verify Google's OIDC tokens, and a +// misconfigured push subscription produces an unbounded retry loop against that +// endpoint. Pulling inverts the control: the worker asks for work when it is +// ready, using the same service-account credential it already holds. +func (c *Client) Pull(ctx context.Context, account *ServiceAccount, projectID, subscriptionID string, maxMessages int) ([]ReceivedMessage, error) { + if maxMessages <= 0 || maxMessages > 100 { + maxMessages = 25 + } + body, err := json.Marshal(map[string]any{"maxMessages": maxMessages}) + if err != nil { + return nil, err + } + var response struct { + ReceivedMessages []struct { + AckID string `json:"ackId"` + Message PubSubMessage `json:"message"` + } `json:"receivedMessages"` + } + path := fmt.Sprintf("/v1/projects/%s/subscriptions/%s:pull", + url.PathEscape(projectID), url.PathEscape(subscriptionID)) + if err := c.call(ctx, account, http.MethodPost, c.pubsub, path, body, &response, "pubsub_pull", ScopePubSub); err != nil { + return nil, err + } + messages := make([]ReceivedMessage, 0, len(response.ReceivedMessages)) + for _, received := range response.ReceivedMessages { + messages = append(messages, ReceivedMessage{AckID: received.AckID, Message: received.Message}) + } + return messages, nil +} + +// Acknowledge confirms delivery of pulled messages. It is called only after the +// Raw Billing Input is durably committed, so a crash between pull and commit +// causes redelivery rather than data loss; the idempotency key makes the +// redelivery a no-op. +func (c *Client) Acknowledge(ctx context.Context, account *ServiceAccount, projectID, subscriptionID string, ackIDs []string) error { + if len(ackIDs) == 0 { + return nil + } + body, err := json.Marshal(map[string]any{"ackIds": ackIDs}) + if err != nil { + return err + } + path := fmt.Sprintf("/v1/projects/%s/subscriptions/%s:acknowledge", + url.PathEscape(projectID), url.PathEscape(subscriptionID)) + return c.call(ctx, account, http.MethodPost, c.pubsub, path, body, nil, "pubsub_acknowledge", ScopePubSub) +} + +// DeveloperNotification is the decoded RTDN payload. It is a trigger only: +// Google's own documentation states an RTDN signals that state changed and that +// the authoritative state must be read from the Developer API, so nothing in +// this structure is ever normalized into a Transaction Fact directly. +type DeveloperNotification struct { + Version string `json:"version"` + PackageName string `json:"packageName"` + EventTimeMillis string `json:"eventTimeMillis"` + SubscriptionNotification *struct { + Version string `json:"version"` + NotificationType int `json:"notificationType"` + PurchaseToken string `json:"purchaseToken"` + SubscriptionID string `json:"subscriptionId"` + } `json:"subscriptionNotification"` + OneTimeProductNotification *struct { + Version string `json:"version"` + NotificationType int `json:"notificationType"` + PurchaseToken string `json:"purchaseToken"` + SKU string `json:"sku"` + } `json:"oneTimeProductNotification"` + VoidedPurchaseNotification *struct { + PurchaseToken string `json:"purchaseToken"` + OrderID string `json:"orderId"` + ProductType int `json:"productType"` + RefundType int `json:"refundType"` + } `json:"voidedPurchaseNotification"` + TestNotification *struct { + Version string `json:"version"` + } `json:"testNotification"` +} + +// DecodeNotification base64-decodes and parses a Pub/Sub message body. +// +// The envelope is "validated" in the only sense available: Pub/Sub itself is +// the authenticated channel (the pull was made with Mosaic's own service-account +// credential over TLS), and the decoded content must be a well-formed +// DeveloperNotification naming exactly one event. Content that does not satisfy +// that is not a notification Mosaic can attribute and is rejected before any +// tenant is touched. +func DecodeNotification(message PubSubMessage) (DeveloperNotification, []byte, error) { + if strings.TrimSpace(message.MessageID) == "" { + return DeveloperNotification{}, nil, errors.New("Pub/Sub message carried no message id") + } + raw, err := base64.StdEncoding.DecodeString(message.Data) + if err != nil { + return DeveloperNotification{}, nil, errors.New("Pub/Sub message data was not base64") + } + var notification DeveloperNotification + if err := json.Unmarshal(raw, ¬ification); err != nil { + return DeveloperNotification{}, nil, errors.New("Pub/Sub message data was not a developer notification") + } + if strings.TrimSpace(notification.PackageName) == "" { + return DeveloperNotification{}, nil, errors.New("developer notification carried no package name") + } + present := 0 + for _, set := range []bool{ + notification.SubscriptionNotification != nil, + notification.OneTimeProductNotification != nil, + notification.VoidedPurchaseNotification != nil, + notification.TestNotification != nil, + } { + if set { + present++ + } + } + if present != 1 { + return DeveloperNotification{}, nil, errors.New("developer notification did not carry exactly one event") + } + return notification, raw, nil +} + +func (c *Client) call(ctx context.Context, account *ServiceAccount, method string, base *url.URL, path string, body []byte, out any, operation string, scopes ...string) error { + ctx, span := c.tracer.Start(ctx, "billing.provider.google."+operation, trace.WithAttributes( + attribute.String("mosaic.billing.provider", "google_play"), + )) + defer span.End() + + token, err := c.accessToken(ctx, account, scopes...) + if err != nil { + span.SetStatus(codes.Error, "credential_unusable") + return err + } + + endpoint := *base + target, err := url.Parse(path) + if err != nil { + return &Error{Op: operation, cause: err} + } + // url.Parse puts the decoded form in Path and the escaped form in RawPath. + // Copying only Path and letting URL.String() re-encode silently drops the + // escaping every url.PathEscape call above added, so a %2F would become a + // real path separator. Both halves are carried so the escaping survives. + endpoint.Path += target.Path + endpoint.RawPath = escapedPath(base) + escapedPath(target) + endpoint.RawQuery = target.RawQuery + + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) + if err != nil { + return &Error{Op: operation, cause: err} + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + var envelope struct { + Error struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + } `json:"error"` + } + raw, status, header, err := c.executeRaw(ctx, request) + if err != nil { + span.SetStatus(codes.Error, "transport_failure") + return &Error{Op: operation, cause: err} + } + span.SetAttributes(attribute.Int("http.response.status_code", status)) + if status < 200 || status > 299 { + apiError := &Error{HTTPStatus: status, Op: operation} + if json.Unmarshal(raw, &envelope) == nil { + apiError.GoogleCode = safeCode(envelope.Error.Status) + } + if retryAfter, ok := ParseRetryAfter(header.Get("Retry-After"), c.now()); ok { + apiError.RetryAfter = retryAfter + } + span.SetStatus(codes.Error, "provider_error") + span.SetAttributes(attribute.String("mosaic.billing.provider_code", apiError.GoogleCode)) + return apiError + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return &Error{HTTPStatus: status, Op: operation, cause: errors.New("provider response was not valid JSON")} + } + } + return nil +} + +func (c *Client) execute(ctx context.Context, request *http.Request, out any) (int, error) { + raw, status, _, err := c.executeRaw(ctx, request) + if err != nil { + return 0, err + } + if out != nil && len(raw) > 0 { + _ = json.Unmarshal(raw, out) + } + return status, nil +} + +func (c *Client) executeRaw(ctx context.Context, request *http.Request) ([]byte, int, http.Header, error) { + response, err := c.httpClient.Do(request) + if err != nil { + return nil, 0, nil, err + } + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, c.maxResponseBytes)) + _ = response.Body.Close() + }() + payload, err := io.ReadAll(io.LimitReader(response.Body, c.maxResponseBytes)) + if err != nil { + return nil, response.StatusCode, response.Header, err + } + _ = ctx + return payload, response.StatusCode, response.Header, nil +} + +// escapedPath returns the percent-encoded path of u, falling back to the +// decoded form when the two are identical (url.URL leaves RawPath empty then). +func escapedPath(u *url.URL) string { + if u.RawPath != "" { + return u.RawPath + } + return u.Path +} diff --git a/apps/api/internal/platform/googleplay/client_test.go b/apps/api/internal/platform/googleplay/client_test.go new file mode 100644 index 00000000..e703a1cb --- /dev/null +++ b/apps/api/internal/platform/googleplay/client_test.go @@ -0,0 +1,153 @@ +package googleplay + +import ( + "encoding/base64" + "net/http" + "strings" + "testing" + "time" +) + +// Google's intake path has no signature to verify: the authenticity argument is +// that the pull was made with Mosaic's own service-account credential over TLS, +// and that what came back is a well-formed DeveloperNotification naming exactly +// one event. DecodeNotification is that second half, and it is the only thing +// standing between an arbitrary Pub/Sub message and a tenant-attributed Raw +// Billing Input. Plan §13 names it directly. +// +// These are Mosaic's own rules, not Google's library behaviour. +func TestDecodeNotificationRejectsMessagesThatCannotBeAttributed(t *testing.T) { + encode := func(body string) string { + return base64.StdEncoding.EncodeToString([]byte(body)) + } + valid := `{"version":"1.0","packageName":"com.fixture.app","eventTimeMillis":"1769000000000",` + + `"subscriptionNotification":{"version":"1.0","notificationType":4,` + + `"purchaseToken":"fixture-token","subscriptionId":"fixture.pro.monthly"}}` + + cases := []struct { + name string + message PubSubMessage + reason string + }{ + { + name: "no message id", + message: PubSubMessage{Data: encode(valid)}, + reason: "the Pub/Sub message id is half the idempotency key; without it a redelivery would duplicate", + }, + { + name: "data is not base64", + message: PubSubMessage{MessageID: "m1", Data: "not-base64!!"}, + reason: "an undecodable body cannot be a notification", + }, + { + name: "data is not JSON", + message: PubSubMessage{MessageID: "m1", Data: encode("plain text")}, + reason: "arbitrary bytes must not become a tenant-attributed input", + }, + { + name: "no package name", + message: PubSubMessage{MessageID: "m1", Data: encode(`{"version":"1.0","testNotification":{"version":"1.0"}}`)}, + reason: "packageName is what binds the notification to an Application; without it there is nothing to check", + }, + { + name: "no event member", + message: PubSubMessage{MessageID: "m1", Data: encode(`{"version":"1.0","packageName":"com.fixture.app"}`)}, + reason: "a notification about nothing is not actionable", + }, + { + name: "two event members", + message: PubSubMessage{MessageID: "m1", Data: encode( + `{"version":"1.0","packageName":"com.fixture.app",` + + `"subscriptionNotification":{"version":"1.0","notificationType":4,"purchaseToken":"a","subscriptionId":"b"},` + + `"oneTimeProductNotification":{"version":"1.0","notificationType":1,"purchaseToken":"c","sku":"d"}}`)}, + reason: "silently truncating to the first event would discard a real event", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if _, _, err := DecodeNotification(testCase.message); err == nil { + t.Fatalf("message was accepted; %s", testCase.reason) + } + }) + } + + notification, raw, err := DecodeNotification(PubSubMessage{MessageID: "m1", Data: encode(valid)}) + if err != nil { + t.Fatalf("a well-formed notification was rejected: %v", err) + } + if notification.PackageName != "com.fixture.app" || notification.SubscriptionNotification == nil { + t.Fatal("the decoded notification lost its package name or its event") + } + if len(raw) == 0 { + t.Fatal("the raw bytes are what get sealed into the encrypted body; they must be returned") + } +} + +// Google follows RFC 7231 for Retry-After. Apple does not — it sends an +// absolute UNIX millisecond timestamp — and the two parsers must never be +// interchanged: reading an absolute value as delta-seconds schedules a retry +// tens of thousands of years out, which presents as a permanently stalled queue +// with no error anywhere. +func TestParseRetryAfterAcceptsOnlyTheRFC7231Forms(t *testing.T) { + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + + if delay, ok := ParseRetryAfter("90", now); !ok || delay != 90*time.Second { + t.Fatalf("delta-seconds parsed as %s (ok=%v), want 1m30s", delay, ok) + } + if delay, ok := ParseRetryAfter(now.Add(2*time.Minute).Format(http.TimeFormat), now); !ok || delay <= 0 { + t.Fatalf("HTTP-date parsed as %s (ok=%v)", delay, ok) + } + for _, malformed := range []string{"", "soon", "-5", "0", "999999"} { + if _, ok := ParseRetryAfter(malformed, now); ok { + t.Fatalf("malformed or out-of-range Retry-After %q was accepted", malformed) + } + } + // Apple's form must not be honoured here. + if _, ok := ParseRetryAfter("1785283200000", now); ok { + t.Fatal("an absolute-millisecond value was accepted as delta-seconds") + } +} + +// A provider status must land on the correct side of the retry boundary. These +// are the codes Mosaic classifies on, and a value outside the safe charset must +// be neutralised before it can reach the provider_code column or an operator's +// console. +func TestSafeCodeBoundsProviderSuppliedStatuses(t *testing.T) { + if got := safeCode("RESOURCE_EXHAUSTED"); got != "RESOURCE_EXHAUSTED" { + t.Fatalf("a documented status was altered: %q", got) + } + if got := safeCode(""); got != "" { + t.Fatalf("an absent status became %q", got) + } + for _, hostile := range []string{"bad status", "a\nb", "a\x00b", "tok/en"} { + if got := safeCode(hostile); got != "unclassified" { + t.Fatalf("hostile status %q became %q; it must not reach a column or a log verbatim", hostile, got) + } + } + if got := safeCode(strings.Repeat("A", 200)); len(got) > 128 { + t.Fatalf("an over-length status was not bounded: %d chars", len(got)) + } +} + +// The service-account key is parsed before it is ever persisted, so an operator +// learns immediately that they pasted the wrong file. The token endpoint is +// pinned: honouring an arbitrary token_uri from an uploaded file would let a +// doctored key redirect Mosaic's signed assertions to a host of the uploader's +// choosing. +func TestParseServiceAccountRejectsUnusableKeys(t *testing.T) { + cases := map[string]string{ + "not JSON": `not json`, + "wrong type": `{"type":"authorized_user","project_id":"p","private_key_id":"k","private_key":"x","client_email":"e"}`, + "missing client email": `{"type":"service_account","project_id":"p","private_key_id":"k","private_key":"x"}`, + "private key not PEM": `{"type":"service_account","project_id":"p","private_key_id":"k","private_key":"not-pem","client_email":"e@x"}`, + "foreign token uri": `{"type":"service_account","project_id":"p","private_key_id":"k","private_key":"x",` + + `"client_email":"e@x","token_uri":"https://attacker.example/token"}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + if _, err := ParseServiceAccount([]byte(body)); err == nil { + t.Fatal("an unusable service-account key was accepted") + } + }) + } +} diff --git a/apps/api/internal/platform/googleplay/oauth.go b/apps/api/internal/platform/googleplay/oauth.go new file mode 100644 index 00000000..7f17af66 --- /dev/null +++ b/apps/api/internal/platform/googleplay/oauth.go @@ -0,0 +1,202 @@ +// Package googleplay is Mosaic's read-only client for the Google Play +// Developer API and the Cloud Pub/Sub pull endpoint that carries Real-time +// Developer Notifications. +// +// Two deliberate absences define this package: +// +// - There is no acknowledge, consume, or refund call. Acknowledging a Google +// purchase is an assertion that the goods were delivered; it is an +// entitlement act, and Phase 9A grants nothing. Acknowledgement stays with +// the application and its Play Billing adapter. The operator-visible +// consequence — an unacknowledged purchase auto-refunds after three days — +// is documented rather than quietly worked around. +// - There is no OAuth library dependency. The service-account JWT-bearer +// exchange is about eighty lines, an ES256 signer already had to be written +// for Apple, and the RevenueCat client set the precedent of hand-rolling +// provider transport rather than adopting a vendor SDK. +package googleplay + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + tokenEndpoint = "https://oauth2.googleapis.com/token" + jwtBearerType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + + // ScopeAndroidPublisher authorizes the purchase and order lookups. + ScopeAndroidPublisher = "https://www.googleapis.com/auth/androidpublisher" + // ScopePubSub authorizes pulling the RTDN subscription. + ScopePubSub = "https://www.googleapis.com/auth/pubsub" + + assertionLifetime = 30 * time.Minute + // refreshMargin renews an access token before it expires so an in-flight + // call never fails on a token that expired between mint and use. + refreshMargin = 60 * time.Second +) + +// ServiceAccount is the parsed Google service-account key. The whole JSON file +// is what an operator rotates, so the whole file is what Mosaic stores; these +// are the fields the client reads back out of it. +type ServiceAccount struct { + Type string `json:"type"` + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + TokenURI string `json:"token_uri"` + + parsedKey *rsa.PrivateKey +} + +// ParseServiceAccount validates a service-account JSON key without contacting +// Google. It is used both at credential-create time (so an unusable key is +// rejected before it is ever persisted) and on every worker run. +func ParseServiceAccount(raw []byte) (*ServiceAccount, error) { + var account ServiceAccount + if err := json.Unmarshal(raw, &account); err != nil { + return nil, errors.New("Google service-account key is not valid JSON") + } + if account.Type != "service_account" { + return nil, errors.New("Google credential is not a service-account key") + } + if strings.TrimSpace(account.ClientEmail) == "" || strings.TrimSpace(account.PrivateKey) == "" || + strings.TrimSpace(account.PrivateKeyID) == "" || strings.TrimSpace(account.ProjectID) == "" { + return nil, errors.New("Google service-account key is missing required fields") + } + block, _ := pem.Decode([]byte(account.PrivateKey)) + if block == nil { + return nil, errors.New("Google service-account private key is not PEM encoded") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, errors.New("Google service-account private key is not a PKCS#8 key") + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("Google service-account private key is not an RSA key") + } + if account.TokenURI == "" { + account.TokenURI = tokenEndpoint + } + if account.TokenURI != tokenEndpoint { + // The token URI comes out of a file an operator pasted. Honouring an + // arbitrary value would let a doctored key redirect signed assertions to + // a host of the uploader's choosing. + return nil, errors.New("Google service-account key names an unexpected token endpoint") + } + account.parsedKey = key + return &account, nil +} + +// tokenCache holds one access token per (service account, scope set). Tokens +// are process-local and never persisted. +type tokenCache struct { + mutex sync.Mutex + tokens map[string]cachedToken +} + +type cachedToken struct { + value string + expiresAt time.Time +} + +func newTokenCache() *tokenCache { return &tokenCache{tokens: make(map[string]cachedToken)} } + +// accessToken returns a bearer token for the requested scopes, minting one when +// the cache is cold or the cached token is inside the refresh margin. +func (c *Client) accessToken(ctx context.Context, account *ServiceAccount, scopes ...string) (string, error) { + key := account.ClientEmail + "\x00" + strings.Join(scopes, " ") + now := c.now() + + c.tokens.mutex.Lock() + if cached, ok := c.tokens.tokens[key]; ok && cached.expiresAt.After(now.Add(refreshMargin)) { + c.tokens.mutex.Unlock() + return cached.value, nil + } + c.tokens.mutex.Unlock() + + assertion, err := signAssertion(account, strings.Join(scopes, " "), now) + if err != nil { + return "", err + } + form := url.Values{} + form.Set("grant_type", jwtBearerType) + form.Set("assertion", assertion) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, account.TokenURI, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var response struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + Error string `json:"error"` + } + status, err := c.execute(ctx, request, &response) + if err != nil { + return "", err + } + if status != http.StatusOK || response.AccessToken == "" { + // response.Error is Google's stable machine code (`invalid_grant`, + // `unauthorized_client`); it is safe to classify on and is the only part + // of the token response that ever leaves this function. + return "", &Error{HTTPStatus: status, GoogleCode: safeCode(response.Error), Op: "oauth_token"} + } + expiresIn := response.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + + c.tokens.mutex.Lock() + c.tokens.tokens[key] = cachedToken{value: response.AccessToken, expiresAt: now.Add(time.Duration(expiresIn) * time.Second)} + c.tokens.mutex.Unlock() + return response.AccessToken, nil +} + +// signAssertion builds the RS256 JWT-bearer assertion Google exchanges for an +// access token. +func signAssertion(account *ServiceAccount, scope string, now time.Time) (string, error) { + if account.parsedKey == nil { + return "", errors.New("Google service-account key was not parsed") + } + header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": account.PrivateKeyID}) + if err != nil { + return "", err + } + claims, err := json.Marshal(map[string]any{ + "iss": account.ClientEmail, + "scope": scope, + "aud": account.TokenURI, + "iat": now.Unix(), + "exp": now.Add(assertionLifetime).Unix(), + }) + if err != nil { + return "", err + } + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(claims) + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, account.parsedKey, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("sign Google assertion: %w", err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} diff --git a/apps/api/internal/platform/httpserver/router.go b/apps/api/internal/platform/httpserver/router.go index 801f3261..6fbcd15c 100644 --- a/apps/api/internal/platform/httpserver/router.go +++ b/apps/api/internal/platform/httpserver/router.go @@ -13,6 +13,7 @@ import ( "github.com/rs/zerolog" "github.com/Mujhtech/mosaic/apps/api/internal/analytics" + "github.com/Mujhtech/mosaic/apps/api/internal/billing" "github.com/Mujhtech/mosaic/apps/api/internal/browserauth" "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" "github.com/Mujhtech/mosaic/apps/api/internal/experiment" @@ -22,6 +23,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/httpmiddleware" "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" 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" @@ -85,6 +87,12 @@ type Dependencies struct { AnalyticsKeyLimiter analyticshttp.Limiter AnalyticsEventLimiter analyticshttp.EventLimiter Experiment *experiment.Service + // Billing is nil unless MOSAIC_BILLING_ENABLED is set. + Billing *billing.Service + // BillingIPLimiter and BillingKeyLimiter bound the observation endpoints + // only. The store notification endpoint is deliberately unlimited. + BillingIPLimiter httpmiddleware.Limiter + BillingKeyLimiter httpmiddleware.Limiter // APILimiter is the baseline limit for authenticated dashboard APIs. APILimiter httpmiddleware.Limiter // DecisionLimiter bounds Placement and Experiment decision reads. @@ -127,13 +135,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 { + if dependencies.BrowserAuth != nil || dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.PlacementDecision != nil || dependencies.Analytics != nil || dependencies.Billing != nil { 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 { + if dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.Analytics != nil || dependencies.Billing != nil { versioned.Group(func(authenticated chi.Router) { authenticated.Use(authn.Middleware(dependencies.PrincipalResolver)) // Authenticated dashboard APIs had no limit at all before @@ -164,6 +172,14 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende analyticshttp.RegisterProjectRoutes(project, dependencies.Analytics, httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) } + if dependencies.Billing != nil { + // Credential tests, reconciliation, replay, and + // quarantine retries each reach a provider or enqueue + // history-scanning work, so they share the + // export-class bucket rather than the baseline API one. + billinghttp.RegisterProjectRoutes(project, dependencies.Billing, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) + } if dependencies.Experiment != nil { project.Group(func(decision chi.Router) { decision.Use(httpmiddleware.RateLimit("decision", dependencies.DecisionLimiter, principalKey)) @@ -180,6 +196,16 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende analyticshttp.RegisterPublicRoutes(versioned, dependencies.Analytics, dependencies.AnalyticsIPLimiter, dependencies.AnalyticsKeyLimiter, dependencies.AnalyticsEventLimiter, routeTimeout(cfg.IngestTimeout, cfg.RequestTimeout)) } + if dependencies.Billing != nil { + // Store notification intake is registered outside every + // 429-returning limiter family: a 429 to Apple consumes one of + // five non-renewable retries and can lose a transaction + // permanently. Observations, which SDKs queue and retry, keep + // their limiter. + billinghttp.RegisterNotificationRoutes(versioned, dependencies.Billing) + billinghttp.RegisterPublicRoutes(versioned, dependencies.Billing, + dependencies.BillingIPLimiter, dependencies.BillingKeyLimiter) + } }) } router.NotFound(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/internal/platform/httpserver/router_test.go b/apps/api/internal/platform/httpserver/router_test.go index ba3127e8..4f736c1e 100644 --- a/apps/api/internal/platform/httpserver/router_test.go +++ b/apps/api/internal/platform/httpserver/router_test.go @@ -15,6 +15,7 @@ import ( "github.com/rs/zerolog" "github.com/Mujhtech/mosaic/apps/api/internal/analytics" + "github.com/Mujhtech/mosaic/apps/api/internal/billing" "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" @@ -380,3 +381,56 @@ func TestUploadAndExportLimitersCoverOnlyTheExpensiveRoutes(t *testing.T) { t.Fatalf("a read consumed an upload/export token: upload=%d export=%d", len(uploadLimiter.keys), len(exportLimiter.keys)) } } + +// Store notification intake is the one public POST that a browser never makes and +// that Mosaic cannot afford to reject. Two properties of its routing are pinned +// here because both are easy to break by moving a route or adding a middleware, +// and both fail silently in production rather than in a build. +// +// First, `trustedMutationOrigins` wraps every /v1 mutation, and Apple sends no +// Origin header. If that middleware ever stopped passing originless requests +// through, every production notification would 403 and Mosaic would burn Apple's +// five non-renewable retries before anyone noticed. A browser POST from a +// disallowed origin must still be blocked, so the exemption cannot be a blanket one. +// +// Second, the route must not sit behind a limiter that can answer 429, for the +// same reason: a 429 to Apple consumes a delivery attempt that is never re-issued. +func TestStoreNotificationIntakeAcceptsOriginlessPostsAndIsNotRateLimited(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), + BillingIPLimiter: limiter, + BillingKeyLimiter: limiter, + APILimiter: limiter, + ExportLimiter: limiter, + }) + + const intakePath = "/v1/billing/apple/notifications/fixture-intake-token" + + // Originless: Apple's actual shape. It must reach the handler, which answers + // 404 for an unresolvable token rather than 403 for a rejected origin. + request := httptest.NewRequest(http.MethodPost, intakePath, strings.NewReader(`{"signedPayload":"fixture"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code == http.StatusForbidden { + t.Fatal("an originless store notification was rejected by the trusted-origin middleware") + } + if recorder.Code == http.StatusTooManyRequests { + t.Fatal("store notification intake is behind a 429-returning limiter, which would spend Apple's retry budget") + } + + // A browser POST from a disallowed origin must still be blocked: the + // originless pass-through is not a blanket exemption. + forged := httptest.NewRequest(http.MethodPost, intakePath, strings.NewReader(`{"signedPayload":"fixture"}`)) + forged.Header.Set("Content-Type", "application/json") + forged.Header.Set("Origin", "https://attacker.example") + forgedRecorder := httptest.NewRecorder() + handler.ServeHTTP(forgedRecorder, forged) + if forgedRecorder.Code != http.StatusForbidden { + t.Fatalf("a cross-origin browser POST to the intake route returned %d, want 403", forgedRecorder.Code) + } +} diff --git a/apps/api/internal/providercredential/cipher_test.go b/apps/api/internal/providercredential/cipher_test.go index d66c5cdd..c1119fff 100644 --- a/apps/api/internal/providercredential/cipher_test.go +++ b/apps/api/internal/providercredential/cipher_test.go @@ -2,12 +2,16 @@ package providercredential import ( "bytes" + "crypto/rand" "encoding/base64" "errors" "fmt" "testing" ) +// testKeyring is a fixed two-key keyring for the envelope-domain tests. +var testKeyring = keyring("key-a", fmt.Sprintf(`%q:%q,%q:%q`, "key-a", encodedKey(7), "key-b", encodedKey(9))) + func encodedKey(fill byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{fill}, 32)) } @@ -124,3 +128,92 @@ func TestKeyRotationKeepsOldEnvelopesReadableAndUsesOnlyActiveKeyForWrites(t *te t.Fatalf("re-sealed envelope after removing the retired key = %q, %v", plaintext, err) } } + +// The Phase 9A envelope domain must be cryptographically separate from the +// Provider Connection domain. This was a Stage 1A blocking prerequisite that +// the code satisfied and nothing pinned. +// +// The failure it guards is a refactor that unifies additionalData and +// subjectAdditionalData, or reorders their fields: a Store Server Credential +// envelope would then be openable in a Provider Connection scope (scope +// confusion), or a rotation would reseal under the wrong domain and make every +// store credential permanently undecryptable (data loss). Both are silent — +// AES-GCM simply fails to authenticate, and the caller sees "unavailable" — +// which is exactly why the separation needs a test rather than a comment. +func TestEnvelopeDomainsCannotCrossOpen(t *testing.T) { + cipher, err := NewAESGCMCipher(testKeyring, rand.Reader) + if err != nil { + t.Fatal(err) + } + secret := []byte("fixture-store-credential-material") + + v1Scope := Scope{ + OrganizationID: "org_1", ProjectID: "proj_1", + ConnectionID: "conn_1", CredentialClass: "serverSecret", + } + v2Scope := SubjectScope{ + OrganizationID: "org_1", ProjectID: "proj_1", + SubjectKind: SubjectStoreServerCredential, SubjectID: "conn_1", + CredentialClass: "serverSecret", + } + + v1Envelope, err := cipher.Encrypt(secret, v1Scope) + if err != nil { + t.Fatal(err) + } + v2Envelope, err := cipher.EncryptSubject(secret, v2Scope) + if err != nil { + t.Fatal(err) + } + + // Each opens under its own domain, so the rejections below are about the + // domain and not about a broken cipher. + if _, err := cipher.Decrypt(v1Envelope, v1Scope); err != nil { + t.Fatalf("a v1 envelope did not open under its own scope: %v", err) + } + if _, err := cipher.DecryptSubject(v2Envelope, v2Scope); err != nil { + t.Fatalf("a v2 envelope did not open under its own scope: %v", err) + } + + // The two scopes name the same tenant, the same identifier, and the same + // class deliberately: only the domain separates them. + if _, err := cipher.DecryptSubject(v1Envelope, v2Scope); !errors.Is(err, ErrCredentialUnavailable) { + t.Fatalf("a v1 envelope opened through the v2 path (err=%v)", err) + } + if _, err := cipher.Decrypt(v2Envelope, v1Scope); !errors.Is(err, ErrCredentialUnavailable) { + t.Fatalf("a v2 envelope opened through the v1 path (err=%v)", err) + } +} + +// Within v2, the subject kind and subject id are part of the binding: a raw +// billing body must not open as a credential, and a row moved between projects +// or tables must become undecryptable rather than readable in the wrong context. +func TestSubjectScopeBindsKindAndIdentity(t *testing.T) { + cipher, err := NewAESGCMCipher(testKeyring, rand.Reader) + if err != nil { + t.Fatal(err) + } + scope := SubjectScope{ + OrganizationID: "org_1", ProjectID: "proj_1", + SubjectKind: SubjectStoreServerCredential, SubjectID: "ssc_1", + CredentialClass: "appleInAppPurchaseKey", + } + envelope, err := cipher.EncryptSubject([]byte("fixture-p8-material"), scope) + if err != nil { + t.Fatal(err) + } + + for name, mutate := range map[string]func(*SubjectScope){ + "different subject kind": func(s *SubjectScope) { s.SubjectKind = SubjectBillingRawInput }, + "different subject id": func(s *SubjectScope) { s.SubjectID = "ssc_2" }, + "different project": func(s *SubjectScope) { s.ProjectID = "proj_2" }, + "different organization": func(s *SubjectScope) { s.OrganizationID = "org_2" }, + "different class": func(s *SubjectScope) { s.CredentialClass = "googleServiceAccountKey" }, + } { + altered := scope + mutate(&altered) + if _, err := cipher.DecryptSubject(envelope, altered); !errors.Is(err, ErrCredentialUnavailable) { + t.Fatalf("envelope opened under a %s (err=%v)", name, err) + } + } +} diff --git a/apps/api/internal/providercredential/subject.go b/apps/api/internal/providercredential/subject.go new file mode 100644 index 00000000..5bb039fe --- /dev/null +++ b/apps/api/internal/providercredential/subject.go @@ -0,0 +1,133 @@ +package providercredential + +import ( + "bytes" + "crypto/hmac" + "encoding/binary" + "io" + "strings" +) + +// envelopeAADDomainV2 is the Phase 9A additional-authenticated-data domain. +// +// Phase 9A seals two things the Provider Connection design never contemplated: +// Store Server Credentials, which are not attached to a Provider Connection at +// all, and Raw Billing Input bodies, which are payloads rather than credentials. +// Both are addressed by a (kind, id) subject rather than a connection id. +// +// Rather than widen Scope — which would have made ConnectionID optional and let +// an empty value silently weaken the binding of every existing v1 envelope — +// the new shape gets its own domain string. Because the domain is the first +// length-prefixed field of the AAD, a v1 envelope can never be opened through +// the v2 path and a v2 envelope can never be opened through the v1 path, even +// under the same keyring and the same key. +const envelopeAADDomainV2 = "mosaic-billing-envelope-v2" + +// Subject kinds. Each names a distinct table, so two rows with the same +// identifier in different tables never share an AAD. +const ( + SubjectStoreServerCredential = "store_server_credential" + SubjectBillingRawInput = "billing_raw_input" +) + +// SubjectScope binds a v2 envelope to one tenant and one row. Every field is +// required: an empty component would make two different subjects produce the +// same additional data. +type SubjectScope struct { + OrganizationID string + ProjectID string + SubjectKind string + SubjectID string + CredentialClass string +} + +func validSubjectScope(scope SubjectScope) bool { + if strings.TrimSpace(scope.OrganizationID) == "" || strings.TrimSpace(scope.ProjectID) == "" || + strings.TrimSpace(scope.SubjectID) == "" || strings.TrimSpace(scope.CredentialClass) == "" { + return false + } + switch scope.SubjectKind { + case SubjectStoreServerCredential, SubjectBillingRawInput: + return true + default: + return false + } +} + +// EncryptSubject seals plaintext under the Phase 9A domain. +func (c *AESGCMCipher) EncryptSubject(plaintext []byte, scope SubjectScope) (Envelope, error) { + if len(plaintext) == 0 || !validSubjectScope(scope) { + return Envelope{}, ErrCredentialUnavailable + } + key, ok := c.keys[c.activeKeyID] + if !ok { + return Envelope{}, ErrCredentialUnavailable + } + aead, err := newGCM(key) + if err != nil { + return Envelope{}, ErrCredentialUnavailable + } + nonce := make([]byte, nonceSize) + if _, err := io.ReadFull(c.random, nonce); err != nil { + return Envelope{}, ErrCredentialUnavailable + } + return Envelope{ + Version: envelopeVersion, + Algorithm: envelopeAlgorithm, + KeyID: c.activeKeyID, + Nonce: nonce, + Ciphertext: aead.Seal(nil, nonce, plaintext, subjectAdditionalData(scope)), + CredentialClass: scope.CredentialClass, + Fingerprint: fingerprint(key, plaintext), + }, nil +} + +// DecryptSubject opens a Phase 9A envelope. It fails closed on any mismatch of +// tenant, subject, or class, so a row moved between projects or tables becomes +// undecryptable rather than readable in the wrong context. +func (c *AESGCMCipher) DecryptSubject(envelope Envelope, scope SubjectScope) ([]byte, error) { + if !validSubjectScope(scope) || envelope.Version != envelopeVersion || + envelope.Algorithm != envelopeAlgorithm || envelope.CredentialClass != scope.CredentialClass || + len(envelope.Nonce) != nonceSize { + return nil, ErrCredentialUnavailable + } + key, ok := c.keys[envelope.KeyID] + if !ok { + return nil, ErrCredentialUnavailable + } + aead, err := newGCM(key) + if err != nil || len(envelope.Ciphertext) < aead.Overhead() { + return nil, ErrCredentialUnavailable + } + plaintext, err := aead.Open(nil, envelope.Nonce, envelope.Ciphertext, subjectAdditionalData(scope)) + if err != nil || !hmac.Equal(envelope.Fingerprint, fingerprint(key, plaintext)) { + return nil, ErrCredentialUnavailable + } + return plaintext, nil +} + +func subjectAdditionalData(scope SubjectScope) []byte { + var result bytes.Buffer + for _, field := range []string{ + envelopeAADDomainV2, + scope.OrganizationID, + scope.ProjectID, + scope.SubjectKind, + scope.SubjectID, + scope.CredentialClass, + } { + _ = binary.Write(&result, binary.BigEndian, uint32(len(field))) + _, _ = result.WriteString(field) + } + return result.Bytes() +} + +// SubjectCipher is the Phase 9A encryption port. Billing depends on this rather +// than on the concrete cipher so the domain never imports crypto packages. +type SubjectCipher interface { + EncryptSubject(plaintext []byte, scope SubjectScope) (Envelope, error) + DecryptSubject(envelope Envelope, scope SubjectScope) ([]byte, error) + ActiveKeyID() string +} + +var _ SubjectCipher = (*AESGCMCipher)(nil) diff --git a/apps/api/internal/transport/billing/contract_test.go b/apps/api/internal/transport/billing/contract_test.go new file mode 100644 index 00000000..aaa3be98 --- /dev/null +++ b/apps/api/internal/transport/billing/contract_test.go @@ -0,0 +1,538 @@ +package billinghttp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/rs/zerolog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "testing" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" +) + +// The Billing Ingestion Contract v1 is frozen and platform-neutral: four SDKs +// decode one shape. These tests read the contract's own schema and fixtures off +// disk rather than restating them, so a contract change that Mosaic has not +// followed fails here instead of at an SDK decode site in the field. + +const contractRoot = "../../../../../protocol" + +func fixturePath(parts ...string) string { + return filepath.Join(append([]string{contractRoot, "fixtures", "billing-ingestion", "v1"}, parts...)...) +} + +func loadJSON(t *testing.T, path string) map[string]any { + t.Helper() + // A missing fixture is a failure, not a skip. Skipping meant a renamed or + // deleted fixture turned every contract assertion into a silent no-op — + // the suite would stay green while the server drifted away from the + // document four SDKs are written against. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("contract fixture %s could not be read: %v", path, err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("fixture %s is not valid JSON: %v", path, err) + } + return decoded +} + +func encode(t *testing.T, result billing.SubmissionResult) map[string]any { + t.Helper() + raw, err := json.Marshal(result.Envelope()) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + return decoded +} + +func keysOf(value map[string]any) []string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// Every response Mosaic emits must carry the contract record envelope and the +// exact field set of the corresponding fixture. The submission-response schema +// declares additionalProperties:false at both levels, so an extra Mosaic- +// internal field (a request id, a raw input id) would be a hard decode failure +// in a strict reader rather than a tolerated addition. +func TestSubmissionResponsesMatchContractFixtureShape(t *testing.T) { + now := time.Date(2026, 7, 27, 12, 0, 0, 400_000_000, time.UTC) + received := billing.ContractTimestamp(now) + + cases := []struct { + fixture string + result billing.SubmissionResult + }{ + { + fixture: "accepted-for-validation.json", + result: billing.SubmissionResult{ + SubmissionID: "fixture-submission-apple-0001", ReceivedAt: received, + Status: billing.SubmissionAccepted, EstimatedValidationDelaySeconds: 30, + }, + }, + { + fixture: "duplicate-observation.json", + result: billing.SubmissionResult{ + SubmissionID: "fixture-submission-apple-0001", ReceivedAt: received, + Status: billing.SubmissionDuplicate, + }, + }, + { + fixture: "permanent-rejection.json", + result: billing.Reject("fixture-submission-malformed-0001", now, billing.CodeProviderReferenceMalformed), + }, + { + fixture: "retryable-failure.json", + result: billing.RateLimited("fixture-submission-google-0001", now, 30*time.Second), + }, + } + + for _, testCase := range cases { + t.Run(testCase.fixture, func(t *testing.T) { + fixture := loadJSON(t, fixturePath("responses", testCase.fixture)) + produced := encode(t, testCase.result) + + if !reflect.DeepEqual(keysOf(fixture), keysOf(produced)) { + t.Fatalf("envelope keys %v, want %v", keysOf(produced), keysOf(fixture)) + } + for _, key := range []string{"billingIngestionContractVersion", "recordType"} { + if produced[key] != fixture[key] { + t.Fatalf("%s = %v, want %v", key, produced[key], fixture[key]) + } + } + + fixturePayload := fixture["payload"].(map[string]any) + producedPayload := produced["payload"].(map[string]any) + + // `diagnostics` is an optional member Mosaic does not populate; + // everything else must match the fixture field-for-field. + expected := make([]string, 0, len(fixturePayload)) + for _, key := range keysOf(fixturePayload) { + if key == "diagnostics" { + continue + } + expected = append(expected, key) + } + if !reflect.DeepEqual(expected, keysOf(producedPayload)) { + t.Fatalf("payload keys %v, want %v", keysOf(producedPayload), expected) + } + for _, key := range expected { + if key == "receivedAt" { + continue + } + if producedPayload[key] != fixturePayload[key] { + t.Fatalf("payload.%s = %v, want %v", key, producedPayload[key], fixturePayload[key]) + } + } + }) + } +} + +// receivedAt must match the contract's UTC timestamp pattern. A Go time +// rendered with the default RFC 3339 layout carries an offset like +00:00 and +// nanosecond precision, both of which the pattern rejects, so this is a real +// and easy regression. +func TestContractTimestampMatchesSchemaPattern(t *testing.T) { + schema := loadJSON(t, filepath.Join(contractRoot, "schema", "billing-ingestion", "v1", "observation.schema.json")) + defs := schema["$defs"].(map[string]any) + pattern := defs["utcTimestamp"].(map[string]any)["pattern"].(string) + + expression := regexpMustCompile(t, pattern) + for _, instant := range []time.Time{ + time.Date(2026, 7, 27, 12, 0, 0, 400_000_000, time.UTC), + time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + // A non-UTC input must still render as UTC with a literal Z. + time.Date(2026, 7, 27, 12, 0, 0, 0, time.FixedZone("test", 5*3600)), + } { + rendered := billing.ContractTimestamp(instant) + if !expression.MatchString(rendered) { + t.Fatalf("ContractTimestamp(%s) = %q, which the contract pattern rejects", instant, rendered) + } + } +} + +// The status and code vocabularies are frozen. A Mosaic constant outside them +// would be rejected by any SDK validating against the schema, and the failure +// would surface as an undecodable response rather than a clear error. +func TestSubmissionStatusesAndCodesAreInTheContractVocabulary(t *testing.T) { + schema := loadJSON(t, filepath.Join(contractRoot, "schema", "billing-ingestion", "v1", "submission-response.schema.json")) + defs := schema["$defs"].(map[string]any) + + permanent := enumOf(t, defs, "permanentCode") + retryable := enumOf(t, defs, "retryableCode") + + for _, code := range []string{ + billing.CodeObservationSchemaInvalid, billing.CodeUnknownField, billing.CodeInvalidIdentifier, + billing.CodeInvalidTimestamp, billing.CodeObservationTooLarge, billing.CodeProviderReferenceMalformed, + billing.CodeReferenceKindUnsupported, billing.CodeSensitiveValueRejected, billing.CodeAuthorityNotAllowed, + billing.CodeBillingNotEnabled, billing.CodeObservationIDConflict, + } { + if !permanent[code] { + t.Fatalf("permanent code %q is not in the contract vocabulary", code) + } + } + for _, code := range []string{ + billing.CodeRateLimited, billing.CodeStorageUnavailable, billing.CodeServiceUnavailable, + billing.CodeIngestionTimeout, billing.CodeValidationBacklogFull, + } { + if !retryable[code] { + t.Fatalf("retryable code %q is not in the contract vocabulary", code) + } + } + + // The whole point of the status set: nothing in it may imply the + // transaction was proven real. + statuses := map[string]bool{} + for _, variant := range defs["observationSubmissionResult"].(map[string]any)["oneOf"].([]any) { + properties := variant.(map[string]any)["properties"].(map[string]any) + statuses[properties["status"].(map[string]any)["const"].(string)] = true + } + for _, status := range []string{ + billing.SubmissionAccepted, billing.SubmissionDuplicate, + billing.SubmissionPermanentlyRejected, billing.SubmissionRetryableFailure, + } { + if !statuses[status] { + t.Fatalf("status %q is not in the contract status set", status) + } + } + for forbidden := range statuses { + switch forbidden { + case "validated", "verified", "confirmed", "entitled": + t.Fatalf("the contract status set contains %q, which claims proof", forbidden) + } + } +} + +func enumOf(t *testing.T, defs map[string]any, name string) map[string]bool { + t.Helper() + values := defs[name].(map[string]any)["enum"].([]any) + set := make(map[string]bool, len(values)) + for _, value := range values { + set[value.(string)] = true + } + return set +} + +// The canonical fixtures are decoded through the real strict decoder, so the +// Go request types are proven to accept exactly what the SDKs are told to send. +// Restating the shape in the test instead would let the two drift silently. +func TestCanonicalObservationFixturesDecode(t *testing.T) { + for _, fixture := range []string{"apple-client-observation.json", "google-client-observation.json"} { + t.Run(fixture, func(t *testing.T) { + body := readFixture(t, fixturePath(fixture)) + envelope, code := decodeEnvelope[clientObservationPayload](body, recordTypeClientObservation) + if code != "" { + t.Fatalf("canonical client fixture was rejected with %q", code) + } + if code := envelope.Payload.validate(); code != "" { + t.Fatalf("canonical client fixture failed validation with %q", code) + } + observation := envelope.Payload.toObservation() + if observation.SubmissionID == "" || observation.Reference == "" { + t.Fatal("the decoded observation lost its submission id or reference") + } + // A client observation is never allowed to arrive classified. + if observation.StoreEnvironment != "unclassified" { + t.Fatalf("client observation classified as %q", observation.StoreEnvironment) + } + }) + } + + t.Run("trusted-server-observation.json", func(t *testing.T) { + body := readFixture(t, fixturePath("trusted-server-observation.json")) + envelope, code := decodeEnvelope[serverObservationPayload](body, recordTypeServerObservation) + if code != "" { + t.Fatalf("canonical trusted-server fixture was rejected with %q", code) + } + if code := envelope.Payload.validate(); code != "" { + t.Fatalf("canonical trusted-server fixture failed validation with %q", code) + } + // The trusted fixture asserts production, and a trusted server is + // permitted to classify. + if got := envelope.Payload.toObservation().StoreEnvironment; got != "production" { + t.Fatalf("trusted classification decoded as %q, want production", got) + } + }) +} + +// Each invalid fixture encodes a rule the contract exists to enforce. Decoding +// them through the same path proves Mosaic refuses what the contract refuses, +// rather than being more permissive than the document four SDKs were written +// against. +func TestInvalidObservationFixturesAreRejected(t *testing.T) { + cases := []struct { + fixture string + reason string + }{ + {"client-observation-asserts-store-environment.json", + "a client may not classify the Store Environment"}, + {"client-observation-foreign-authority.json", + "a public SDK key proves only client authority"}, + {"client-observation-platform-reference-mismatch.json", + "an Android digest must not be validated against Apple's API"}, + {"client-observation-tenant-field.json", + "tenant scope comes from the authenticated key, never the body"}, + {"client-observation-credential-shaped-reference.json", + "a reference must not be able to carry credential-shaped material"}, + {"malformed-token-digest-reference.json", + "a malformed token digest cannot address a Google purchase"}, + {"client-observation-carries-purchase-token.json", + "a public SDK may never carry a full purchase token"}, + {"unknown-contract-version.json", + "a reader of another contract version must be told so precisely"}, + {"unknown-record-type.json", + "an unknown record type must not be interpreted as a known one"}, + } + for _, testCase := range cases { + t.Run(testCase.fixture, func(t *testing.T) { + body := readFixture(t, fixturePath("invalid", testCase.fixture)) + envelope, code := decodeEnvelope[clientObservationPayload](body, recordTypeClientObservation) + if code == "" { + code = envelope.Payload.validate() + } + if code == "" { + t.Fatalf("invalid fixture was accepted; %s", testCase.reason) + } + t.Logf("rejected with %q (%s)", code, testCase.reason) + }) + } +} + +// A trusted server may not claim an authority its key does not prove. A secret +// server key proves a trusted app backend sent the document; it proves nothing +// about a provider having signed anything, so provider_notification and the +// other Mosaic-internal authorities must be refused on this surface. +func TestTrustedEndpointRejectsAuthorityItsKeyDoesNotProve(t *testing.T) { + body := readFixture(t, fixturePath("invalid", "server-observation-unsigned-provider-notification.json")) + envelope, code := decodeEnvelope[serverObservationPayload](body, recordTypeServerObservation) + if code == "" { + code = envelope.Payload.validate() + } + if code != billing.CodeAuthorityNotAllowed { + t.Fatalf("rejected with %q, want %q", code, billing.CodeAuthorityNotAllowed) + } +} + +// Posting a record to the wrong endpoint is a record-type error: the document +// does not belong on that surface at all. Without this a trusted-shaped +// document could be submitted with only a public SDK key. +func TestRecordTypeIsBoundToTheEndpoint(t *testing.T) { + trusted := readFixture(t, fixturePath("trusted-server-observation.json")) + if _, code := decodeEnvelope[clientObservationPayload](trusted, recordTypeClientObservation); code != "unsupported_record_type" { + t.Fatalf("a server record posted to the public endpoint was rejected with %q, want unsupported_record_type", code) + } + client := readFixture(t, fixturePath("apple-client-observation.json")) + if _, code := decodeEnvelope[serverObservationPayload](client, recordTypeServerObservation); code != "unsupported_record_type" { + t.Fatalf("a client record posted to the trusted endpoint was rejected with %q, want unsupported_record_type", code) + } +} + +func readFixture(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("contract fixture %s could not be read: %v", path, err) + } + return raw +} + +func regexpMustCompile(t *testing.T, pattern string) *regexp.Regexp { + t.Helper() + expression, err := regexp.Compile(pattern) + if err != nil { + t.Fatalf("contract pattern %q is not a Go-compatible regular expression: %v", pattern, err) + } + return expression +} + +// BL-1 — the trusted server endpoint carries the full Google purchase token. +// +// Without it a Google observation carries only a digest, a digest cannot be +// reversed, and the observation can never validate: an operator wiring an app +// backend to the trusted endpoint would receive accepted_for_validation for +// every submission and watch every one land in quarantine. +func TestTrustedServerObservationAcceptsPurchaseToken(t *testing.T) { + body := readFixture(t, fixturePath("trusted-server-observation.json")) + envelope, code := decodeEnvelope[serverObservationPayload](body, recordTypeServerObservation) + if code != "" { + t.Fatalf("the canonical trusted-server fixture was rejected with %q", code) + } + if code := envelope.Payload.validate(); code != "" { + t.Fatalf("the canonical trusted-server fixture failed validation with %q", code) + } + if envelope.Payload.PurchaseToken == "" { + t.Skip("the canonical fixture carries no purchase token; the synthetic cases below still apply") + } + if got := envelope.Payload.toObservation().PurchaseToken; got != envelope.Payload.PurchaseToken { + t.Fatal("the decoded purchase token did not reach the observation") + } +} + +// The token is bound to the record's own reference. Without the binding a +// caller could file a real purchase token under a *different* transaction's +// reference; Mosaic would validate the token against Google, get a genuine +// answer, and record it as a fact about the transaction the reference named. +func TestPurchaseTokenMustDigestToItsOwnReference(t *testing.T) { + token := "fixture-google-purchase-token-0001" + matching := hexOf(billing.TokenDigest(token)) + + base := func() serverObservationPayload { + return serverObservationPayload{ + ObservationID: "fixture-observation-google-0001", + SubmissionID: "fixture-submission-google-0001", + ProviderID: "fixture-provider-google", + StorePlatform: storePlatformGoogle, + TransactionReference: transactionReference{ + ReferenceKind: billing.ReferenceGooglePlayTokenDigest, Value: matching, + }, + SourceAuthority: authorityTrustedServer, + TrustBasis: "provider_server_api", + ReceivedAt: "2026-07-27T12:05:00.000Z", + PurchaseToken: token, + } + } + + if code := base().validate(); code != "" { + t.Fatalf("a token matching its own reference was rejected with %q", code) + } + + mismatched := base() + mismatched.TransactionReference.Value = hexOf(billing.TokenDigest("fixture-a-different-purchase")) + if code := mismatched.validate(); code != billing.CodeProviderReferenceMalformed { + t.Fatalf("a token filed under another transaction's reference was accepted (code=%q)", code) + } + + // Gated to Google: an Apple record may never carry one. + apple := base() + apple.StorePlatform = storePlatformApple + apple.TransactionReference = transactionReference{ + ReferenceKind: billing.ReferenceAppStoreTransactionID, Value: "2000000900000001", + } + if code := apple.validate(); code != billing.CodeReferenceKindUnsupported { + t.Fatalf("an Apple record carried a purchase token (code=%q)", code) + } + + // Gated to trusted-server authority. + foreign := base() + foreign.SourceAuthority = "provider_notification" + if code := foreign.validate(); code != billing.CodeAuthorityNotAllowed { + t.Fatalf("a non-trusted authority carried a purchase token (code=%q)", code) + } + + // Bounds: control characters and over-length values are refused before the + // digest comparison, so a hostile value cannot reach storage. + oversize := base() + oversize.PurchaseToken = strings.Repeat("t", maxPurchaseTokenLength+1) + if code := oversize.validate(); code != billing.CodeSensitiveValueRejected { + t.Fatalf("an over-length token was accepted (code=%q)", code) + } + control := base() + control.PurchaseToken = "fixture\ntoken" + if code := control.validate(); code != billing.CodeSensitiveValueRejected { + t.Fatalf("a token with a control character was accepted (code=%q)", code) + } +} + +// The client record has no place to put a token at all, so a client that sends +// one is rejected as an unknown field rather than silently ignored. +func TestClientObservationRejectsPurchaseToken(t *testing.T) { + body := readFixture(t, fixturePath("invalid", "client-observation-carries-purchase-token.json")) + envelope, code := decodeEnvelope[clientObservationPayload](body, recordTypeClientObservation) + if code == "" { + code = envelope.Payload.validate() + } + if code != billing.CodeUnknownField { + t.Fatalf("a client observation carrying a purchase token was rejected with %q, want %q", + code, billing.CodeUnknownField) + } +} + +// T-5 — the billing error path must never emit request content. +// +// response.Error logs the cause behind every 5xx. On this surface a cause can +// quote a URL containing a purchase token, a decode fragment, or a transport +// error naming internal hosts, so writeError deliberately never populates +// APIError.Cause and logs the error's *type* rather than its message. Nothing +// exercised that until now, and m-3 — errorTypeName returning a message prefix +// and logging colon-less errors verbatim — is exactly the regression this +// catches. Plan §13 names a redaction test explicitly. +func TestBillingErrorPathEmitsNoRequestContent(t *testing.T) { + const secret = "gtokenAbCdEf0123456789-SECRET-PURCHASE-TOKEN" + causes := map[string]error{ + "transport error with a token in a URL": errors.New( + "Get \"https://androidpublisher.googleapis.com/v3/tokens/" + secret + "\": dial tcp 10.1.2.3:443: refused"), + "error with no colon at all": errors.New("purchase token " + secret + " was rejected"), + "wrapped safe error": fmt.Errorf("outer context: %w", + errors.New("signedPayload eyJhbGciOiJFUzI1NiJ9."+secret)), + } + + for name, cause := range causes { + t.Run(name, func(t *testing.T) { + var logged bytes.Buffer + logger := zerolog.New(&logged) + request := httptest.NewRequest(http.MethodGet, "/v1/projects/p/billing/store-credentials", nil) + request = request.WithContext(logger.WithContext(request.Context())) + recorder := httptest.NewRecorder() + + writeError(recorder, request, cause) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status %d, want 500 for an unmapped error", recorder.Code) + } + // Neither the operator log nor the response body may carry it. + if strings.Contains(logged.String(), secret) { + t.Fatalf("the operator log leaked request content: %s", logged.String()) + } + if strings.Contains(recorder.Body.String(), secret) { + t.Fatalf("the response body leaked request content: %s", recorder.Body.String()) + } + // The whole message must be absent, not just the token: a message is + // where internal topology leaks. + if strings.Contains(logged.String(), "dial tcp") || + strings.Contains(logged.String(), "androidpublisher") { + t.Fatalf("the operator log leaked the cause message: %s", logged.String()) + } + // The type is what makes the line useful for triage, so it must be + // present — otherwise a future refactor could "fix" this test by + // logging nothing at all. + if !strings.Contains(logged.String(), "billing_error_kind") { + t.Fatalf("no billing_error_kind field was logged; triage has nothing to go on: %s", logged.String()) + } + }) + } + + // A mapped error keeps its stable code and its fixed message, and still + // carries no cause. + var logged bytes.Buffer + logger := zerolog.New(&logged) + request := httptest.NewRequest(http.MethodGet, "/v1/projects/p/billing/store-credentials", nil) + request = request.WithContext(logger.WithContext(request.Context())) + recorder := httptest.NewRecorder() + writeError(recorder, request, fmt.Errorf("reading credential %s: %w", secret, billing.ErrNotFound)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("status %d, want 404", recorder.Code) + } + if strings.Contains(recorder.Body.String(), secret) || strings.Contains(logged.String(), secret) { + t.Fatalf("a mapped error leaked its wrapped content: body=%s log=%s", recorder.Body.String(), logged.String()) + } +} diff --git a/apps/api/internal/transport/billing/handler.go b/apps/api/internal/transport/billing/handler.go new file mode 100644 index 00000000..fcb65e0f --- /dev/null +++ b/apps/api/internal/transport/billing/handler.go @@ -0,0 +1,1263 @@ +// Package billinghttp exposes Mosaic Billing 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, constructs SQL, decides authorization, or calls +// a provider — and none of them calls render.JSON directly. +package billinghttp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rs/zerolog" + + "github.com/Mujhtech/mosaic/apps/api/internal/billing" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/httpmiddleware" + chimiddleware "github.com/go-chi/chi/v5/middleware" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// Limiter bounds an observation surface. Notification intake is deliberately +// not given one — see RegisterNotificationRoutes. +type Limiter interface { + Allow(string) (bool, time.Duration) +} + +type Handler struct { + service *billing.Service + ipLimiter Limiter + keyLimiter Limiter +} + +// RegisterNotificationRoutes mounts the Apple App Store Server Notification +// endpoint. +// +// This route carries no rate limiter on purpose. Apple retries a failed V2 +// notification only five times, at 1/12/24/48/72 hours, and never retries in +// sandbox at all. A 429 returned to Apple therefore consumes one of five +// non-renewable delivery attempts and can permanently lose a transaction, which +// is a worse outcome than any load this endpoint can realistically produce. It +// is protected instead by a hard body-size ceiling, the unguessable intake +// token in the path, JWS verification against the pinned Apple root, and +// anomaly telemetry. +func RegisterNotificationRoutes(router chi.Router, service *billing.Service) { + h := &Handler{service: service} + router.Post("/billing/apple/notifications/{intakeToken}", h.appleNotification) +} + +// RegisterPublicRoutes mounts the untrusted SDK observation endpoint. This one +// may return 429: SDKs hold a durable queue and retry, so shedding load costs +// latency rather than data. +func RegisterPublicRoutes(router chi.Router, service *billing.Service, ip, key Limiter) { + h := &Handler{service: service, ipLimiter: ip, keyLimiter: key} + router.Post("/sdk/billing/observations", h.clientObservation) + router.Post("/billing/server/observations", h.serverObservation) +} + +// RegisterProjectRoutes mounts the authenticated operator API. +func RegisterProjectRoutes(router chi.Router, service *billing.Service, expensive ...func(http.Handler) http.Handler) { + h := &Handler{service: service} + guarded := nonNil(expensive) + + router.Route("/billing/settings", func(settings chi.Router) { + settings.Get("/", h.settings) + settings.Put("/", h.updateSettings) + }) + router.Route("/billing/store-credentials", func(credentials chi.Router) { + credentials.Get("/", h.listCredentials) + credentials.Post("/", h.createCredential) + credentials.Get("/{credentialId}", h.getCredential) + credentials.Post("/{credentialId}/rotate", h.rotateCredential) + 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 + // mark-as-valid route, and adding one would require asserting an + // outcome the store never confirmed. + record.With(guarded...).Post("/retry", h.retryQuarantine) + record.With(guarded...).Post("/close-superseded", h.closeQuarantine) + }) +} + +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) billing.Actor { + principal, _ := authn.FromContext(r.Context()) + return billing.Actor{ID: principal.ActorID} +} + +func correlationID(r *http.Request) string { + if id := chimiddleware.GetReqID(r.Context()); id != "" { + return id + } + return "billing" +} + +// --------------------------------------------------------------------------- +// Apple notification intake +// --------------------------------------------------------------------------- + +func (h *Handler) appleNotification(w http.ResponseWriter, r *http.Request) { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeError(w, r, billing.ErrInvalid) + return + } + r.Body = http.MaxBytesReader(w, r.Body, billing.MaxNotificationBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, r, billing.ErrInvalid) + return + } + + token := strings.TrimSpace(chi.URLParam(r, "intakeToken")) + if token == "" { + response.Error(w, r, response.NewAPIError(http.StatusNotFound, "not_found", "The requested resource was not found.")) + return + } + + // The body is handed to the service unparsed. Parsing here would put a + // signed payload into a handler-local variable that a future logging + // statement could reach. + if err := h.service.AcceptAppleNotification(r.Context(), token, body, correlationID(r)); err != nil { + switch { + case errors.Is(err, billing.ErrNotFound): + // An unknown or revoked token has no tenant. 404 with no body: there + // is nothing to attribute and nothing to say. + response.Error(w, r, response.NewAPIError(http.StatusNotFound, "not_found", "The requested resource was not found.")) + default: + // The only condition worth spending one of Apple's five retries on + // is Mosaic being unable to durably record the input. + response.Error(w, r, response.NewAPIError(http.StatusServiceUnavailable, + "billing_storage_unavailable", "The notification could not be recorded.")) + } + return + } + // 202 is inside Apple's 200-206 success range, so the notification is not + // retried, and it honestly describes what happened: accepted, not validated. + response.Accepted(w, r, map[string]string{"status": "accepted"}) +} + +// --------------------------------------------------------------------------- +// Observations +// --------------------------------------------------------------------------- + +// The observation endpoints speak the Billing Ingestion Contract v1 record +// shape on the way in as well as on the way out, so a single platform-neutral +// document travels from four SDKs to one server. The Go types below mirror the +// contract's `clientTransactionObservation` and `serverTransactionObservation` +// records exactly, and every one of them is decoded with +// DisallowUnknownFields: the schema declares additionalProperties:false at +// every level, so a member the contract does not define must be a rejection +// rather than a silently ignored value. + +// observationEnvelope is the outer record. recordType and contract version are +// checked before the payload is interpreted, so a reader of the wrong contract +// gets a precise code instead of a schema error. +type observationEnvelope[T any] struct { + BillingIngestionContractVersion string `json:"billingIngestionContractVersion"` + RecordType string `json:"recordType"` + Payload T `json:"payload"` +} + +// transactionReference is the discriminated provider reference. Raw receipts, +// signed payloads, JWS representations, and purchase tokens are structurally +// impossible to carry here: the value bounds are 24 decimal digits for Apple +// and exactly 64 lowercase hex characters for Google. +type transactionReference struct { + ReferenceKind string `json:"referenceKind"` + Value string `json:"value"` +} + +type providerOrderReference struct { + ReferenceKind string `json:"referenceKind"` + Value string `json:"value"` +} + +type observationContext struct { + Platform string `json:"platform"` + SDKFamily string `json:"sdkFamily"` + SDKVersion string `json:"sdkVersion"` + OperatingSystemVersion string `json:"operatingSystemVersion,omitempty"` + ApplicationVersion string `json:"applicationVersion,omitempty"` +} + +type observationCorrelation struct { + PurchaseAttemptID string `json:"purchaseAttemptId,omitempty"` + ProviderOperationID string `json:"providerOperationId,omitempty"` + ProviderUpdateID string `json:"providerUpdateId,omitempty"` +} + +type storeEnvironmentClassification struct { + Classification string `json:"classification"` + Basis string `json:"basis"` +} + +// clientObservationPayload is the contract's clientTransactionObservation. +// +// It has no storeEnvironmentClassification member and no purchase-token member, +// because the contract gives a client neither. A device can be made to say +// anything, so accepting a client's Store Environment would let a sandbox +// purchase present itself as production; classification comes only from +// server-side validation of the store's own response. +type clientObservationPayload struct { + ObservationID string `json:"observationId"` + SubmissionID string `json:"submissionId"` + ProviderID string `json:"providerId"` + StorePlatform string `json:"storePlatform"` + TransactionReference transactionReference `json:"transactionReference"` + ProviderOrderReference *providerOrderReference `json:"providerOrderReference,omitempty"` + ObservedAt string `json:"observedAt"` + SourceAuthority string `json:"sourceAuthority"` + Context observationContext `json:"context"` + Correlation *observationCorrelation `json:"correlation,omitempty"` + // ClaimedMosaicProductID is a claim only. The server resolves the Mosaic + // Product independently and a mismatch is a diagnostic, never an override, + // so the value is accepted for shape conformance and deliberately not used. + ClaimedMosaicProductID string `json:"claimedMosaicProductId,omitempty"` +} + +// serverObservationPayload is the contract's serverTransactionObservation. +// +// A trusted server may classify the Store Environment and record how trust was +// established. It still carries no purchase token: the contract states that +// purchase tokens are structurally impossible to carry across this boundary, +// and the reference is the same digest a client would send. +type serverObservationPayload struct { + ObservationID string `json:"observationId"` + SubmissionID string `json:"submissionId"` + ProviderID string `json:"providerId"` + StorePlatform string `json:"storePlatform"` + TransactionReference transactionReference `json:"transactionReference"` + ProviderOrderReference *providerOrderReference `json:"providerOrderReference,omitempty"` + SourceAuthority string `json:"sourceAuthority"` + TrustBasis string `json:"trustBasis"` + ReceivedAt string `json:"receivedAt"` + ProviderReportedAt string `json:"providerReportedAt,omitempty"` + ProviderNotificationReference string `json:"providerNotificationReference,omitempty"` + // PurchaseToken is the full Google Play purchase token. It is permitted + // only here, only on a Google record, and only under trusted-server + // authority; the client record has no such member at all and rejects one as + // an unknown field. + // + // It is a transaction reference the buyer's own purchase produced, not a + // Mosaic provider credential — service-account keys and signing material + // remain forbidden everywhere. It is encrypted at rest on receipt, never + // logged, never returned on any read, and never relieves the record of full + // provider validation. + PurchaseToken string `json:"purchaseToken,omitempty"` + StoreEnvironmentClassification *storeEnvironmentClassification `json:"storeEnvironmentClassification,omitempty"` + Correlation *observationCorrelation `json:"correlation,omitempty"` + OriginatingObservationID string `json:"originatingObservationId,omitempty"` +} + +// contract vocabulary the transport enforces before the service is called. +const ( + recordTypeClientObservation = "clientTransactionObservation" + recordTypeServerObservation = "serverTransactionObservation" + + storePlatformApple = "apple_app_store" + storePlatformGoogle = "google_play" + + authorityClientObservation = "client_observation" + authorityTrustedServer = "trusted_server_observation" +) + +var contractIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) + +func validIdentifier(value string) bool { + return len(value) >= 1 && len(value) <= 128 && contractIdentifier.MatchString(value) +} + +// validateReference enforces the contract's storePlatformReferenceAlignment: an +// Apple record carries a decimal transaction id and no Google order reference; +// a Google record carries a 64-character lowercase hex token digest. Mosaic +// checks it rather than trusting the sender, because the alignment is what stops +// an Android digest being validated against Apple's API. +func validateReference(platform string, reference transactionReference, order *providerOrderReference) string { + switch platform { + case storePlatformApple: + if reference.ReferenceKind != billing.ReferenceAppStoreTransactionID { + return billing.CodeReferenceKindUnsupported + } + if order != nil { + return billing.CodeProviderReferenceMalformed + } + if len(reference.Value) < 1 || len(reference.Value) > 24 || !isDecimalString(reference.Value) { + return billing.CodeProviderReferenceMalformed + } + case storePlatformGoogle: + if reference.ReferenceKind != billing.ReferenceGooglePlayTokenDigest { + return billing.CodeReferenceKindUnsupported + } + if _, ok := billing.ValidHexDigest(reference.Value); !ok { + return billing.CodeProviderReferenceMalformed + } + if order != nil { + if order.ReferenceKind != billing.ReferenceGooglePlayOrderID || !validIdentifier(order.Value) { + return billing.CodeProviderReferenceMalformed + } + } + default: + return billing.CodeProviderReferenceMalformed + } + return "" +} + +func isDecimalString(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func (p clientObservationPayload) validate() string { + for _, id := range []string{p.ObservationID, p.SubmissionID, p.ProviderID} { + if !validIdentifier(id) { + return billing.CodeInvalidIdentifier + } + } + // sourceAuthority must match what this endpoint's authentication actually + // proves. A public SDK key proves only that a client sent the document, so + // any higher authority claimed in the envelope is refused rather than + // quietly downgraded. + if p.SourceAuthority != authorityClientObservation { + return billing.CodeAuthorityNotAllowed + } + if p.Context.Platform == "" || p.Context.SDKFamily == "" || p.Context.SDKVersion == "" { + return billing.CodeObservationSchemaInvalid + } + if !contractTimestampValid(p.ObservedAt) { + return billing.CodeInvalidTimestamp + } + return validateReference(p.StorePlatform, p.TransactionReference, p.ProviderOrderReference) +} + +func (p serverObservationPayload) validate() string { + for _, id := range []string{p.ObservationID, p.SubmissionID, p.ProviderID} { + if !validIdentifier(id) { + return billing.CodeInvalidIdentifier + } + } + // A Mosaic secret server key proves a trusted app backend sent the + // document. It does not prove a provider signed anything, so + // provider_notification, reconciliation_discovery, and manual_revalidation + // — which are authorities only Mosaic's own pipeline may author — are + // refused on this endpoint. + if p.SourceAuthority != authorityTrustedServer { + return billing.CodeAuthorityNotAllowed + } + if p.TrustBasis == "" { + return billing.CodeObservationSchemaInvalid + } + if !contractTimestampValid(p.ReceivedAt) { + return billing.CodeInvalidTimestamp + } + if p.StoreEnvironmentClassification != nil { + classification := p.StoreEnvironmentClassification + switch classification.Classification { + case "sandbox", "production", "unclassified": + default: + return billing.CodeObservationSchemaInvalid + } + if classification.Basis == "unknown" && classification.Classification != "unclassified" { + return billing.CodeObservationSchemaInvalid + } + } + if code := p.validatePurchaseToken(); code != "" { + return code + } + return validateReference(p.StorePlatform, p.TransactionReference, p.ProviderOrderReference) +} + +// maxPurchaseTokenLength and purchaseTokenCharset mirror the contract's bounds: +// printable, no control characters, at most 4096 runes. +const maxPurchaseTokenLength = 4096 + +var purchaseTokenCharset = regexp.MustCompile(`^[^\r\n\x00-\x1F\x7F]+$`) + +// validatePurchaseToken enforces the three rules the contract attaches to the +// token, in the order that fails fastest. +// +// The last of them is the important one: a present token must SHA-256-digest to +// the record's own transactionReference.value. Without that check a caller +// could file a real purchase token under a *different* transaction's reference, +// and Mosaic would validate the token against Google, get a genuine answer, and +// record it as a fact about the transaction named in the reference. Binding the +// two makes the reference and the token two views of one purchase rather than +// two independent claims. +func (p serverObservationPayload) validatePurchaseToken() string { + if p.PurchaseToken == "" { + return "" + } + // Gated to Google and to trusted-server authority by the contract's if/then. + if p.StorePlatform != storePlatformGoogle { + return billing.CodeReferenceKindUnsupported + } + if p.SourceAuthority != authorityTrustedServer { + return billing.CodeAuthorityNotAllowed + } + if len([]rune(p.PurchaseToken)) > maxPurchaseTokenLength || !purchaseTokenCharset.MatchString(p.PurchaseToken) { + return billing.CodeSensitiveValueRejected + } + if hexOf(billing.TokenDigest(p.PurchaseToken)) != p.TransactionReference.Value { + return billing.CodeProviderReferenceMalformed + } + return "" +} + +var contractTimestamp = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?Z$`) + +func contractTimestampValid(value string) bool { + if !contractTimestamp.MatchString(value) { + return false + } + _, err := time.Parse(time.RFC3339, value) + return err == nil +} + +func (p clientObservationPayload) toObservation() billing.Observation { + observation := billing.Observation{ + SubmissionID: p.SubmissionID, + ReferenceKind: p.TransactionReference.ReferenceKind, + Reference: p.TransactionReference.Value, + // Classification comes only from server-side validation. + StoreEnvironment: "unclassified", + ObservedAt: parseContractTime(p.ObservedAt), + } + if p.ProviderOrderReference != nil { + observation.OrderReference = p.ProviderOrderReference.Value + } + return observation +} + +func (p serverObservationPayload) toObservation() billing.Observation { + observation := billing.Observation{ + SubmissionID: p.SubmissionID, + ReferenceKind: p.TransactionReference.ReferenceKind, + Reference: p.TransactionReference.Value, + StoreEnvironment: "unclassified", + ObservedAt: parseContractTime(p.ReceivedAt), + } + if p.ProviderOrderReference != nil { + observation.OrderReference = p.ProviderOrderReference.Value + } + if p.StoreEnvironmentClassification != nil { + observation.StoreEnvironment = p.StoreEnvironmentClassification.Classification + } + // The token travels no further than the service, which seals it into the + // encrypted raw body immediately. It is never logged and never echoed. + observation.PurchaseToken = p.PurchaseToken + return observation +} + +// hexOf renders a digest as lowercase hex, matching the cross-SDK contract for +// the Google token digest. +func hexOf(value []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, len(value)*2) + for i, b := range value { + out[i*2] = digits[b>>4] + out[i*2+1] = digits[b&0x0f] + } + return string(out) +} + +func parseContractTime(value string) time.Time { + if when, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil { + return when.UTC() + } + return time.Time{} +} + +func (h *Handler) clientObservation(w http.ResponseWriter, r *http.Request) { + body, submissionID, ok := h.readObservation(w, r) + if !ok { + return + } + if !h.allow(w, r, submissionID) { + return + } + envelope, code := decodeEnvelope[clientObservationPayload](body, recordTypeClientObservation) + if code == "" { + code = envelope.Payload.validate() + } + if code != "" { + writeSubmission(w, billing.Reject(submissionID, h.now(), code)) + return + } + result, err := h.service.SubmitClientObservation(r.Context(), bearer(r), envelope.Payload.toObservation(), correlationID(r)) + if err != nil { + h.writeSubmissionError(w, r, submissionID, err) + return + } + writeSubmission(w, result) +} + +func (h *Handler) serverObservation(w http.ResponseWriter, r *http.Request) { + body, submissionID, ok := h.readObservation(w, r) + if !ok { + return + } + if !h.allow(w, r, submissionID) { + return + } + envelope, code := decodeEnvelope[serverObservationPayload](body, recordTypeServerObservation) + if code == "" { + code = envelope.Payload.validate() + } + if code != "" { + writeSubmission(w, billing.Reject(submissionID, h.now(), code)) + return + } + result, err := h.service.SubmitServerObservation(r.Context(), bearer(r), envelope.Payload.toObservation(), correlationID(r)) + if err != nil { + h.writeSubmissionError(w, r, submissionID, err) + return + } + writeSubmission(w, result) +} + +// readObservation bounds and buffers the body, then leniently peeks the +// submission id. +// +// The peek exists because every response shape in the contract requires +// submissionId, including rejections: a client that cannot correlate a +// rejection cannot drain its queue. The peek is tolerant by design and its +// result is only ever echoed back, never trusted — the strict decode that +// follows is what actually accepts the document. +func (h *Handler) readObservation(w http.ResponseWriter, r *http.Request) ([]byte, string, bool) { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeSubmission(w, billing.Reject("unknown", h.now(), billing.CodeObservationSchemaInvalid)) + return nil, "", false + } + r.Body = http.MaxBytesReader(w, r.Body, billing.MaxObservationBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + writeSubmission(w, billing.Reject("unknown", h.now(), billing.CodeObservationTooLarge)) + return nil, "", false + } + return body, peekSubmissionID(body), true +} + +// decodeEnvelope strictly decodes one contract record. +// +// The envelope is inspected before the payload is interpreted, in two passes. +// That order matters: a record of the wrong type or the wrong contract version +// would otherwise fail on whichever payload member happened to be unknown, and +// the caller would be told "unknown_field" when the real answer is "this +// document does not belong on this endpoint". +func decodeEnvelope[T any](body []byte, expectedRecordType string) (observationEnvelope[T], string) { + var envelope observationEnvelope[T] + + var outer observationEnvelope[json.RawMessage] + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&outer); err != nil { + if strings.Contains(err.Error(), "unknown field") { + return envelope, billing.CodeUnknownField + } + return envelope, billing.CodeObservationSchemaInvalid + } + // Trailing JSON would let a caller smuggle a second document past the first. + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return envelope, billing.CodeObservationSchemaInvalid + } + if outer.BillingIngestionContractVersion != billing.BillingContractVersion { + return envelope, "unsupported_contract_version" + } + if outer.RecordType != expectedRecordType { + // Posting a server record to the public endpoint, or the reverse, is a + // record-type error rather than an authority error. + return envelope, "unsupported_record_type" + } + envelope.BillingIngestionContractVersion = outer.BillingIngestionContractVersion + envelope.RecordType = outer.RecordType + + payloadDecoder := json.NewDecoder(bytes.NewReader(outer.Payload)) + payloadDecoder.DisallowUnknownFields() + if err := payloadDecoder.Decode(&envelope.Payload); err != nil { + // An unknown field is reported distinctly: it is how a client learns it + // sent something the contract forbids — a Store Environment assertion, + // for example — rather than seeing a generic schema error. + if strings.Contains(err.Error(), "unknown field") { + return envelope, billing.CodeUnknownField + } + return envelope, billing.CodeObservationSchemaInvalid + } + return envelope, "" +} + +// peekSubmissionID reads submissionId without strict decoding. +func peekSubmissionID(body []byte) string { + var peek struct { + Payload struct { + SubmissionID string `json:"submissionId"` + } `json:"payload"` + } + if json.Unmarshal(body, &peek) == nil && validIdentifier(peek.Payload.SubmissionID) { + return peek.Payload.SubmissionID + } + return "unknown" +} + +// writeSubmission emits the contract record envelope. +// +// It uses response.Representation rather than response.Accepted because the +// wire contract here is the Billing Ingestion Contract record, not the +// dashboard data envelope: an SDK must decode one platform-neutral shape. +func writeSubmission(w http.ResponseWriter, result billing.SubmissionResult) { + status := http.StatusAccepted + switch result.Status { + case billing.SubmissionDuplicate: + status = http.StatusOK + case billing.SubmissionPermanentlyRejected: + status = http.StatusUnprocessableEntity + case billing.SubmissionRetryableFailure: + status = http.StatusServiceUnavailable + if result.Code == billing.CodeRateLimited { + status = http.StatusTooManyRequests + } + if result.RetryAfterSeconds > 0 { + w.Header().Set("Retry-After", strconv.Itoa(result.RetryAfterSeconds)) + } + } + encoded, err := json.Marshal(result.Envelope()) + if err != nil { + // The envelope is built from bounded constants and validated + // identifiers, so this cannot carry caller content. + w.WriteHeader(http.StatusInternalServerError) + return + } + response.Representation(w, status, "application/json", encoded) +} + +// writeSubmissionError maps a service failure onto the contract shape. +// Authentication is the one outcome that is not a submission result: there is +// no authenticated tenant to answer on behalf of. +func (h *Handler) writeSubmissionError(w http.ResponseWriter, r *http.Request, submissionID string, err error) { + switch { + case errors.Is(err, billing.ErrUnauthenticated): + writeError(w, r, err) + case errors.Is(err, billing.ErrUnavailable): + writeSubmission(w, billing.SubmissionResult{ + SubmissionID: submissionID, ReceivedAt: billing.ContractTimestamp(h.now()), + Status: billing.SubmissionRetryableFailure, Code: billing.CodeStorageUnavailable, + RetryAfterSeconds: 30, + }) + case errors.Is(err, billing.ErrInvalid): + writeSubmission(w, billing.Reject(submissionID, h.now(), billing.CodeObservationSchemaInvalid)) + default: + writeError(w, r, err) + } +} + +func (h *Handler) now() time.Time { + if h.service != nil { + return h.service.Now() + } + return time.Now().UTC() +} + +func (h *Handler) allow(w http.ResponseWriter, r *http.Request, submissionID string) bool { + if h.ipLimiter != nil { + if ok, retry := h.ipLimiter.Allow("ip:" + httpmiddleware.ClientIP(r)); !ok { + writeSubmission(w, billing.RateLimited(submissionID, h.now(), retry)) + return false + } + } + if h.keyLimiter == nil { + return true + } + if ok, retry := h.keyLimiter.Allow("key:" + digestKey(bearer(r))); !ok { + // Shedding is reported in the contract shape, with a retry hint, so the + // SDK queue backs off rather than treating it as a decode failure. + writeSubmission(w, billing.RateLimited(submissionID, h.now(), retry)) + return false + } + return true +} + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- + +type credentialApplicationRequest struct { + ApplicationID string `json:"applicationId"` + Platform string `json:"platform"` + ProviderApplicationIdentifier string `json:"providerApplicationIdentifier"` +} + +type createCredentialRequest struct { + EnvironmentID string `json:"environmentId"` + Provider string `json:"provider"` + StoreEnvironment string `json:"storeEnvironment"` + Name string `json:"name"` + Secret string `json:"secret"` + AppleIssuerID string `json:"appleIssuerId,omitempty"` + AppleKeyID string `json:"appleKeyId,omitempty"` + GoogleClientEmail string `json:"googleClientEmail,omitempty"` + GooglePubSubProjectID string `json:"googlePubSubProjectId,omitempty"` + GooglePubSubSubscription string `json:"googlePubSubSubscriptionId,omitempty"` + Applications []credentialApplicationRequest `json:"applications"` +} + +func (v *createCredentialRequest) Validate() error { + return validation.ValidateStruct(v, + validation.Field(&v.EnvironmentID, validation.Required), + validation.Field(&v.Provider, validation.Required, validation.In(billing.ProviderAppStore, billing.ProviderGooglePlay)), + validation.Field(&v.StoreEnvironment, validation.Required, validation.In("sandbox", "production")), + validation.Field(&v.Name, validation.Required, validation.RuneLength(1, 120)), + validation.Field(&v.Secret, validation.Required), + validation.Field(&v.Applications, validation.Required, validation.Length(1, 32)), + ) +} + +func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) { + var request createCredentialRequest + if !decodeLarge(w, r, &request) { + return + } + input := billing.CredentialInput{ + ProjectID: chi.URLParam(r, "projectId"), + EnvironmentID: request.EnvironmentID, + Provider: request.Provider, + StoreEnvironment: request.StoreEnvironment, + Name: request.Name, + Secret: []byte(request.Secret), + AppleIssuerID: request.AppleIssuerID, + AppleKeyID: request.AppleKeyID, + GoogleClientEmail: request.GoogleClientEmail, + GooglePubSubProjectID: request.GooglePubSubProjectID, + GooglePubSubSubscription: request.GooglePubSubSubscription, + } + for _, application := range request.Applications { + input.Applications = append(input.Applications, billing.CredentialApplication{ + ApplicationID: application.ApplicationID, + Platform: application.Platform, + ProviderApplicationIdentifier: application.ProviderApplicationIdentifier, + }) + } + // The decoded secret string is cleared here too: the request struct outlives + // the service call otherwise. + request.Secret = "" + + credential, err := h.service.CreateCredential(r.Context(), actor(r), input) + if err != nil { + writeError(w, r, err) + return + } + response.Created(w, r, credential) +} + +type rotateCredentialRequest struct { + Secret string `json:"secret"` +} + +func (v *rotateCredentialRequest) Validate() error { + return validation.ValidateStruct(v, validation.Field(&v.Secret, validation.Required)) +} + +func (h *Handler) rotateCredential(w http.ResponseWriter, r *http.Request) { + var request rotateCredentialRequest + if !decodeLarge(w, r, &request) { + return + } + credential, err := h.service.RotateCredential(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "credentialId"), []byte(request.Secret)) + request.Secret = "" + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, credential) +} + +func (h *Handler) revokeCredential(w http.ResponseWriter, r *http.Request) { + credential, err := h.service.RevokeCredential(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "credentialId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, credential) +} + +func (h *Handler) testCredential(w http.ResponseWriter, r *http.Request) { + credential, err := h.service.TestCredential(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "credentialId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, credential) +} + +func (h *Handler) listCredentials(w http.ResponseWriter, r *http.Request) { + credentials, err := h.service.ListCredentials(r.Context(), actor(r), chi.URLParam(r, "projectId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, map[string]any{"items": credentials}) +} + +func (h *Handler) getCredential(w http.ResponseWriter, r *http.Request) { + credential, err := h.service.GetCredential(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "credentialId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, credential) +} + +type settingsRequest struct { + BillingEnabled bool `json:"billingEnabled"` +} + +func (h *Handler) settings(w http.ResponseWriter, r *http.Request) { + value, err := h.service.Settings(r.Context(), actor(r), chi.URLParam(r, "projectId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, value) +} + +func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { + var request settingsRequest + if !decode(w, r, &request) { + return + } + if err := h.service.SetBillingEnabled(r.Context(), actor(r), chi.URLParam(r, "projectId"), request.BillingEnabled); err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, map[string]bool{"billingEnabled": request.BillingEnabled}) +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +// listOptions parses the closed set of filters the ledger surfaces accept. +// Anything outside the enumerations is dropped rather than passed through, so +// no caller-supplied string reaches a query predicate uninspected. +func listOptions(r *http.Request, statuses ...string) billing.ListOptions { + query := r.URL.Query() + options := billing.ListOptions{Cursor: strings.TrimSpace(query.Get("cursor"))} + if limit, err := strconv.Atoi(query.Get("limit")); err == nil { + options.Limit = limit + } + options.Status = allowed(query.Get("status"), statuses) + options.ReasonCode = allowed(query.Get("reasonCode"), quarantineReasons) + options.Provider = allowed(query.Get("provider"), []string{billing.ProviderAppStore, billing.ProviderGooglePlay}) + // The raw-input filter is an identifier, so it is bounded by the same + // charset every other caller-supplied identifier on this surface is. + if rawInputID, ok := billing.SafeProviderCode(query.Get("rawInputId")); ok && validIdentifier(rawInputID) { + options.RawInputID = rawInputID + } + if from, err := time.Parse(time.RFC3339, query.Get("from")); err == nil { + utc := from.UTC() + options.From = &utc + } + if to, err := time.Parse(time.RFC3339, query.Get("to")); err == nil { + utc := to.UTC() + options.To = &utc + } + return options +} + +func allowed(value string, permitted []string) string { + trimmed := strings.TrimSpace(value) + for _, candidate := range permitted { + if trimmed == candidate { + return trimmed + } + } + return "" +} + +var quarantineReasons = []string{ + billing.QuarantineSignatureInvalid, billing.QuarantineApplicationMismatch, + billing.QuarantineEnvironmentMismatch, billing.QuarantineStoreEnvironmentMismatch, + billing.QuarantineCredentialUnavailable, billing.QuarantineCredentialRevoked, + billing.QuarantineMissingCredential, + billing.QuarantineProductUnknown, billing.QuarantineProductAmbiguous, + billing.QuarantineCrossEnvironmentMismatch, billing.QuarantineUnsupportedProductType, + billing.QuarantineUnsupportedTransaction, billing.QuarantineMalformedReference, + billing.QuarantineInputContentConflict, billing.QuarantineReplayConflict, + billing.QuarantineProviderPermanentlyFailed, billing.QuarantineValidationExhausted, +} + +func (h *Handler) listFacts(w http.ResponseWriter, r *http.Request) { + page, err := h.service.ListFacts(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "environmentId"), listOptions(r)) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} + +func (h *Handler) listAttempts(w http.ResponseWriter, r *http.Request) { + page, err := h.service.ListAttempts(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "environmentId"), listOptions(r, + billing.OutcomeValidated, billing.OutcomeRecordedNoFact, billing.OutcomeQuarantined, + billing.OutcomeRetryableFailure, billing.OutcomePermanentlyFailed)) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} + +func (h *Handler) listLedger(w http.ResponseWriter, r *http.Request) { + page, err := h.service.ListLedger(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "environmentId"), listOptions(r)) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} + +func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request) { + page, err := h.service.ListQuarantine(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "environmentId"), listOptions(r, + billing.QuarantineOpen, billing.QuarantineRetrying, + billing.QuarantineClosedAfterSuccess, billing.QuarantineClosedSuperseded)) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} + +func (h *Handler) getQuarantine(w http.ResponseWriter, r *http.Request) { + record, err := h.service.Quarantine(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "recordId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record) +} + +func (h *Handler) retryQuarantine(w http.ResponseWriter, r *http.Request) { + record, err := h.service.RetryQuarantine(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "recordId")) + if err != nil { + writeError(w, r, err) + return + } + response.Accepted(w, r, record) +} + +type closeQuarantineRequest struct { + SupersededByRecordID string `json:"supersededByRecordId"` +} + +func (v *closeQuarantineRequest) Validate() error { + return validation.ValidateStruct(v, validation.Field(&v.SupersededByRecordID, validation.Required)) +} + +func (h *Handler) closeQuarantine(w http.ResponseWriter, r *http.Request) { + var request closeQuarantineRequest + if !decode(w, r, &request) { + return + } + record, err := h.service.CloseQuarantineSuperseded(r.Context(), actor(r), + chi.URLParam(r, "projectId"), chi.URLParam(r, "recordId"), request.SupersededByRecordID) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, record) +} + +func (h *Handler) health(w http.ResponseWriter, r *http.Request) { + value, err := h.service.Health(r.Context(), actor(r), chi.URLParam(r, "projectId"), chi.URLParam(r, "environmentId")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, value) +} + +// --------------------------------------------------------------------------- +// Reconciliation and replay +// --------------------------------------------------------------------------- + +type reconciliationRequest struct { + CredentialID string `json:"credentialId"` + Provider string `json:"provider"` + Strategy string `json:"strategy"` + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` +} + +func (v *reconciliationRequest) Validate() error { + return validation.ValidateStruct(v, + validation.Field(&v.CredentialID, validation.Required), + validation.Field(&v.Provider, validation.Required, validation.In(billing.ProviderAppStore, billing.ProviderGooglePlay)), + // apple_transaction_history is deliberately absent. The worker has no + // run loop for it, so accepting it produced a 202 followed by a run + // that landed `failed / unsupported_strategy` with no explanation + // anywhere in the product — a UI-reachable action that can never + // succeed. The migration CHECK still permits the value for forward + // compatibility; the API refuses it until the loop exists. + validation.Field(&v.Strategy, validation.Required, validation.In( + "apple_notification_history", "google_token_requery")), + validation.Field(&v.WindowStart, validation.Required), + validation.Field(&v.WindowEnd, validation.Required), + ) +} + +func (h *Handler) createReconciliation(w http.ResponseWriter, r *http.Request) { + var request reconciliationRequest + if !decode(w, r, &request) { + return + } + start, startErr := time.Parse(time.RFC3339, request.WindowStart) + end, endErr := time.Parse(time.RFC3339, request.WindowEnd) + if startErr != nil || endErr != nil { + writeError(w, r, billing.ErrInvalid) + return + } + run, err := h.service.CreateReconciliation(r.Context(), actor(r), billing.ReconciliationRun{ + ProjectID: chi.URLParam(r, "projectId"), EnvironmentID: chi.URLParam(r, "environmentId"), + CredentialID: request.CredentialID, Provider: request.Provider, Strategy: request.Strategy, + WindowStart: start.UTC(), WindowEnd: end.UTC(), + }) + if err != nil { + writeError(w, r, err) + return + } + response.Accepted(w, r, run) +} + +func (h *Handler) listReconciliations(w http.ResponseWriter, r *http.Request) { + page, err := h.service.ListReconciliationRuns(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "environmentId"), listOptions(r, "queued", "leased", "completed", "partial", "failed")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} + +type replayRequest struct { + Kind string `json:"kind"` + RawInputID string `json:"rawInputId,omitempty"` + WindowStart string `json:"windowStart,omitempty"` + WindowEnd string `json:"windowEnd,omitempty"` + ValidatorVersion int `json:"validatorVersion,omitempty"` +} + +func (v *replayRequest) Validate() error { + return validation.ValidateStruct(v, + validation.Field(&v.Kind, validation.Required, validation.In("replay", "revalidation")), + validation.Field(&v.ValidatorVersion, validation.Min(0)), + ) +} + +func (h *Handler) createReplay(w http.ResponseWriter, r *http.Request) { + var request replayRequest + if !decode(w, r, &request) { + return + } + job := billing.ReplayJob{ + ProjectID: chi.URLParam(r, "projectId"), EnvironmentID: chi.URLParam(r, "environmentId"), + Kind: request.Kind, RawInputID: strings.TrimSpace(request.RawInputID), + ValidatorVersion: request.ValidatorVersion, + } + if start, err := time.Parse(time.RFC3339, request.WindowStart); err == nil { + utc := start.UTC() + job.WindowStart = &utc + } + if end, err := time.Parse(time.RFC3339, request.WindowEnd); err == nil { + utc := end.UTC() + job.WindowEnd = &utc + } + created, err := h.service.CreateReplay(r.Context(), actor(r), job) + if err != nil { + writeError(w, r, err) + return + } + response.Accepted(w, r, created) +} + +func (h *Handler) listReplays(w http.ResponseWriter, r *http.Request) { + page, err := h.service.ListReplayJobs(r.Context(), actor(r), chi.URLParam(r, "projectId"), + chi.URLParam(r, "environmentId"), listOptions(r, "queued", "leased", "completed", "failed")) + if err != nil { + writeError(w, r, err) + return + } + response.OK(w, r, page) +} + +// --------------------------------------------------------------------------- +// Shared plumbing +// --------------------------------------------------------------------------- + +func decode(w http.ResponseWriter, r *http.Request, target any) bool { + return decodeWithLimit(w, r, target, billing.MaxObservationBytes) +} + +// decodeLarge is used only by the credential routes, whose body legitimately +// carries a service-account JSON key of a few kilobytes. +func decodeLarge(w http.ResponseWriter, r *http.Request, target any) bool { + return decodeWithLimit(w, r, target, 64<<10) +} + +func decodeWithLimit(w http.ResponseWriter, r *http.Request, target any, limit int64) bool { + if encoding := r.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + writeError(w, r, billing.ErrInvalid) + return false + } + r.Body = http.MaxBytesReader(w, r.Body, limit) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(w, r, billing.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, billing.ErrInvalid) + return false + } + if validatable, ok := target.(interface{ Validate() error }); ok { + if err := validatable.Validate(); err != nil { + writeError(w, r, billing.ErrInvalid) + 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 "" +} + +// writeError maps billing errors onto HTTP. +// +// The Cause field is deliberately never populated. response.Error logs the +// cause behind every 5xx, and on this surface a cause can quote a fragment of a +// signed payload, a purchase token, or an Authorization header. The unmapped +// branch logs the error's type only — the same redaction the authentication +// resolver applies for the same reason. +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, billing.ErrUnauthenticated): + status, code, message = http.StatusUnauthorized, "unauthenticated", "Authentication is required." + case errors.Is(err, billing.ErrForbidden): + status, code, message = http.StatusForbidden, "forbidden", "You do not have permission to perform this action." + case errors.Is(err, billing.ErrNotFound): + status, code, message = http.StatusNotFound, "not_found", "The requested resource was not found." + case errors.Is(err, billing.ErrConflict): + status, code, message = http.StatusConflict, "conflict", "The request conflicts with current billing state." + case errors.Is(err, billing.ErrBillingDisabled): + status, code, message = http.StatusConflict, "billing_not_enabled", "Mosaic Billing is not enabled for this Project." + case errors.Is(err, billing.ErrInvalid): + status, code, message = http.StatusUnprocessableEntity, "validation_failed", "The billing request is invalid." + case errors.Is(err, billing.ErrRateLimited): + status, code, message = http.StatusTooManyRequests, "rate_limited", "Billing submissions are temporarily rate limited." + case errors.Is(err, billing.ErrCredentialsStillActive): + status, code = http.StatusConflict, "store_credentials_still_active" + message = "Revoke every active Store Server Credential before disabling Mosaic Billing. " + + "While a credential is active the store keeps delivering notifications, and refusing them " + + "would spend a retry budget that is never re-issued." + case errors.Is(err, billing.ErrValidationBusy): + status, code, message = http.StatusConflict, "validation_in_progress", + "This input is already being validated. Retry once the current attempt finishes." + case errors.Is(err, billing.ErrCredentialUnusable): + status, code, message = http.StatusConflict, "store_credential_unusable", "The Store Server Credential could not be used." + case errors.Is(err, billing.ErrUnavailable): + status, code, message = http.StatusServiceUnavailable, "billing_storage_unavailable", "Billing storage is temporarily unavailable." + default: + var safe *billing.SafeError + event := zerolog.Ctx(r.Context()).Error() + if errors.As(err, &safe) { + event = event.Str("billing_error_code", safe.Code).Str("billing_error_kind", safe.Kind) + } else { + event = event.Str("billing_error_kind", errorTypeName(err)) + } + event.Msg("billing request failed") + } + response.Error(w, r, response.NewAPIError(status, code, message)) +} + +// errorTypeName reports the Go type of an error and nothing else. +// +// It previously returned the first colon-separated segment of err.Error(), +// which is a *message* prefix rather than a type name — and an error carrying +// no colon was logged verbatim. On this surface a message can quote a URL +// containing a purchase token, a decode fragment, or a transport error naming +// internal hosts, so the one thing this value must never be is caller content. +// The %T form is the precedent already used by billing.safeFailure and by the +// authentication resolver, for the same reason. +func errorTypeName(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("%T", err) +} + +func digestKey(raw string) string { + sum := billing.TokenDigest(raw) + const digits = "0123456789abcdef" + out := make([]byte, len(sum)*2) + for i, b := range sum { + out[i*2] = digits[b>>4] + out[i*2+1] = digits[b&0x0f] + } + return string(out) +} diff --git a/apps/api/migrations/00022_billing_store_server_credentials.sql b/apps/api/migrations/00022_billing_store_server_credentials.sql new file mode 100644 index 00000000..15d0de83 --- /dev/null +++ b/apps/api/migrations/00022_billing_store_server_credentials.sql @@ -0,0 +1,202 @@ +-- Phase 9A: Store Server Credentials and Product-mapping resolution hardening. +-- +-- A Store Server Credential is a new first-class entity, deliberately not an +-- overload of provider_connections: a Provider Connection is a runtime commerce +-- provider (RevenueCat/custom) that can become an active assignment, while a +-- Store Server Credential is server-side proof material used only by the +-- billing ingestion pipeline and can never become a runtime provider. +-- +-- Sandbox and production never mix. The credential carries both the Mosaic +-- Environment and the Store Environment, and the pair is constrained by a +-- composite foreign key onto the Environment's own mode, so the alignment is a +-- schema invariant rather than an application rule. + +-- +goose Up + +-- Environment mode becomes part of a unique key so downstream tables can carry +-- a denormalized mode and have PostgreSQL enforce that it matches. +ALTER TABLE environments + ADD CONSTRAINT environments_id_project_mode_key UNIQUE (id, project_id, mode); + +-- Mosaic Billing is per-Project opt-in and off by default. +CREATE TABLE billing_project_settings ( + project_id text PRIMARY KEY REFERENCES projects(id) ON DELETE RESTRICT, + billing_enabled boolean NOT NULL DEFAULT false, + updated_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE TABLE store_server_credentials ( + id text PRIMARY KEY, + project_id text NOT NULL, + organization_id text NOT NULL, + environment_id text NOT NULL, + environment_mode text NOT NULL CHECK (environment_mode IN ('development', 'staging', 'production')), + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + store_environment text NOT NULL CHECK (store_environment IN ('sandbox', 'production')), + name text NOT NULL CHECK (btrim(name) <> '' AND length(name) <= 120), + status text NOT NULL CHECK (status IN ('active', 'revoked')), + health_status text NOT NULL CHECK ( + health_status IN ('untested', 'healthy', 'degraded', 'unavailable', 'revoked') + ), + credential_class text NOT NULL CHECK ( + credential_class IN ('appleInAppPurchaseKey', 'googleServiceAccountKey') + ), + -- Encrypted envelope, sealed under the Phase 9A AAD domain v2. The domain is + -- distinct from the Provider Connection domain so a v1 envelope can never be + -- opened as a v2 envelope even with the same keyring. + envelope_version integer NOT NULL CHECK (envelope_version = 1), + algorithm text NOT NULL CHECK (algorithm = 'AES-256-GCM'), + key_id text NOT NULL CHECK (length(key_id) BETWEEN 1 AND 64 AND key_id ~ '^[ -~]+$'), + nonce bytea NOT NULL CHECK (octet_length(nonce) = 12), + ciphertext bytea NOT NULL CHECK (octet_length(ciphertext) >= 16), + fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32), + -- Non-secret identifiers, kept in plaintext for display and lookup without + -- decrypting the envelope. None of these is a bearer value. + apple_issuer_id text CHECK (apple_issuer_id IS NULL OR (btrim(apple_issuer_id) <> '' AND length(apple_issuer_id) <= 128)), + apple_key_id text CHECK (apple_key_id IS NULL OR (btrim(apple_key_id) <> '' AND length(apple_key_id) <= 64)), + google_client_email text CHECK (google_client_email IS NULL OR (btrim(google_client_email) <> '' AND length(google_client_email) <= 254)), + google_pubsub_project_id text CHECK (google_pubsub_project_id IS NULL OR (btrim(google_pubsub_project_id) <> '' AND length(google_pubsub_project_id) <= 128)), + google_pubsub_subscription_id text CHECK (google_pubsub_subscription_id IS NULL OR (btrim(google_pubsub_subscription_id) <> '' AND length(google_pubsub_subscription_id) <= 128)), + -- Apple notification intake identity. Presented once at create/rotate and + -- stored only as SHA-256, matching the API-key posture. + intake_token_digest bytea CHECK (intake_token_digest IS NULL OR octet_length(intake_token_digest) = 32), + intake_token_rotated_at timestamptz, + last_error_code text CHECK (last_error_code IS NULL OR (btrim(last_error_code) <> '' AND length(last_error_code) <= 128)), + last_tested_at timestamptz, + created_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + rotated_at timestamptz, + revoked_at timestamptz, + updated_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (id, project_id, provider), + -- One credential per store per Mosaic Environment. Because an Environment + -- has exactly one mode, this yields the intended sandbox/production pair. + UNIQUE (project_id, provider, environment_id), + UNIQUE (intake_token_digest), + FOREIGN KEY (project_id, organization_id) + REFERENCES projects(id, organization_id) ON DELETE RESTRICT, + FOREIGN KEY (environment_id, project_id, environment_mode) + REFERENCES environments(id, project_id, mode) ON DELETE RESTRICT, + CONSTRAINT store_server_credentials_store_environment_alignment_check CHECK ( + (environment_mode = 'production') = (store_environment = 'production') + ), + CONSTRAINT store_server_credentials_apple_shape_check CHECK ( + provider <> 'app_store' OR ( + credential_class = 'appleInAppPurchaseKey' AND + apple_issuer_id IS NOT NULL AND apple_key_id IS NOT NULL AND + intake_token_digest IS NOT NULL AND + google_client_email IS NULL AND google_pubsub_project_id IS NULL AND + google_pubsub_subscription_id IS NULL + ) + ), + CONSTRAINT store_server_credentials_google_shape_check CHECK ( + provider <> 'google_play' OR ( + credential_class = 'googleServiceAccountKey' AND + google_client_email IS NOT NULL AND google_pubsub_project_id IS NOT NULL AND + google_pubsub_subscription_id IS NOT NULL AND + apple_issuer_id IS NULL AND apple_key_id IS NULL AND + intake_token_digest IS NULL AND intake_token_rotated_at IS NULL + ) + ), + CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)), + CHECK ((status = 'revoked') = (health_status = 'revoked')), + CHECK (rotated_at IS NULL OR rotated_at >= created_at), + CHECK (revoked_at IS NULL OR revoked_at >= created_at) +); +CREATE INDEX store_server_credentials_project_idx + ON store_server_credentials(project_id, provider, id); +-- Rotation pages over envelopes sealed under a retired key. +CREATE INDEX store_server_credentials_key_rotation_idx + ON store_server_credentials(key_id, id) + WHERE revoked_at IS NULL; + +-- An Apple credential covers one Apple team and many Applications; the verified +-- `bid` selects the Application per request. A Google credential covers one +-- package name per Application. +CREATE TABLE store_server_credential_applications ( + project_id text NOT NULL, + credential_id text NOT NULL, + application_id text NOT NULL, + platform text NOT NULL CHECK (platform IN ('ios', 'android')), + provider_application_identifier text NOT NULL CHECK ( + btrim(provider_application_identifier) <> '' AND + length(provider_application_identifier) <= 255 AND + provider_application_identifier ~ '^[ -~]+$' + ), + created_at timestamptz NOT NULL, + PRIMARY KEY (credential_id, application_id), + UNIQUE (project_id, credential_id, application_id), + UNIQUE (credential_id, provider_application_identifier), + FOREIGN KEY (credential_id, project_id) + REFERENCES store_server_credentials(id, project_id) ON DELETE CASCADE, + FOREIGN KEY (application_id, project_id, platform) + REFERENCES applications(id, project_id, platform) ON DELETE RESTRICT +); +CREATE INDEX store_server_credential_applications_lookup_idx + ON store_server_credential_applications(provider_application_identifier, credential_id); + +-- Credential lifecycle audit. Kept append-only so a revocation cannot be edited +-- out of the record after an incident. +CREATE TABLE store_server_credential_events ( + id text PRIMARY KEY, + project_id text NOT NULL, + credential_id text NOT NULL, + action text NOT NULL CHECK ( + action IN ('created', 'rotated', 'revoked', 'tested', 'intake_token_rotated') + ), + outcome text NOT NULL CHECK (outcome IN ('succeeded', 'failed')), + diagnostic_code text CHECK (diagnostic_code IS NULL OR (btrim(diagnostic_code) <> '' AND length(diagnostic_code) <= 128)), + actor_id text NOT NULL, + occurred_at timestamptz NOT NULL, + FOREIGN KEY (credential_id, project_id) + REFERENCES store_server_credentials(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX store_server_credential_events_history_idx + ON store_server_credential_events(credential_id, occurred_at DESC, id); + +-- +goose StatementBegin +CREATE FUNCTION reject_store_server_credential_event_change() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'store server credential events are append-only' USING ERRCODE = '55000'; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER store_server_credential_events_no_change +BEFORE UPDATE OR DELETE ON store_server_credential_events +FOR EACH ROW EXECUTE FUNCTION reject_store_server_credential_event_change(); + +-- Product-mapping hardening required before mapping history can be walked +-- safely by the resolution algorithm. +-- +-- The existing UNIQUE (replaces_mapping_id) makes replacement chains linear but +-- does not stop a row from replacing itself, which would make the forward walk +-- non-terminating. +ALTER TABLE provider_product_mappings + ADD CONSTRAINT provider_product_mappings_replaces_self_check + CHECK (replaces_mapping_id IS NULL OR replaces_mapping_id <> id); + +-- Resolution looks a mapping up in the opposite direction to every existing +-- index: it starts from the provider Product identifier observed on a +-- transaction and needs both current and archived candidates ordered by +-- archived_at. The existing partial unique indexes only cover current rows. +CREATE INDEX provider_product_mappings_resolution_idx + ON provider_product_mappings( + environment_id, provider, application_id, platform, + provider_product_identifier, archived_at + ) + WHERE connection_id IS NULL AND environment_id IS NOT NULL; + +-- +goose Down +DROP INDEX provider_product_mappings_resolution_idx; +ALTER TABLE provider_product_mappings + DROP CONSTRAINT provider_product_mappings_replaces_self_check; +DROP TRIGGER store_server_credential_events_no_change ON store_server_credential_events; +DROP FUNCTION reject_store_server_credential_event_change(); +DROP TABLE store_server_credential_events; +DROP TABLE store_server_credential_applications; +DROP TABLE store_server_credentials; +DROP TABLE billing_project_settings; +ALTER TABLE environments DROP CONSTRAINT environments_id_project_mode_key; diff --git a/apps/api/migrations/00023_billing_ingestion.sql b/apps/api/migrations/00023_billing_ingestion.sql new file mode 100644 index 00000000..c3a67ab7 --- /dev/null +++ b/apps/api/migrations/00023_billing_ingestion.sql @@ -0,0 +1,250 @@ +-- Phase 9A: Raw Billing Inputs, Validation Attempts, and the validation queue. +-- +-- Intake never validates inline. Both stores punish a slow or failing endpoint +-- (Apple retries a V2 notification five times in production and never in +-- sandbox; Pub/Sub redelivers aggressively), so the intake contract is +-- authenticate -> persist -> enqueue -> 2xx, and every provider call happens in +-- the worker against these rows. +-- +-- Raw inputs are the replay substrate: the whole pipeline must be a pure +-- function of billing_raw_inputs plus Product-mapping history plus provider API +-- responses. Nothing downstream may hold state that cannot be rebuilt from them. + +-- +goose Up + +CREATE TABLE billing_raw_inputs ( + id text PRIMARY KEY, + project_id text NOT NULL, + organization_id text NOT NULL, + environment_id text NOT NULL, + environment_mode text NOT NULL CHECK (environment_mode IN ('development', 'staging', 'production')), + -- Resolved lazily: a notification names an Application by bundle id or + -- package name, and a mismatch is a quarantine outcome rather than a + -- rejection, so the column stays nullable. + application_id text, + credential_id text, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + source text NOT NULL CHECK (source IN ( + 'apple_notification', 'apple_notification_history', 'apple_transaction_history', + 'google_rtdn', 'google_token_requery', + 'client_observation', 'trusted_server_observation' + )), + source_authority text NOT NULL CHECK (source_authority IN ( + 'store_notification', 'store_reconciliation', + 'client_observation', 'trusted_server_observation' + )), + -- Provider-assigned identity of the delivery (Apple notificationUUID, + -- Pub/Sub messageId, client submissionId). Kept for operator display; the + -- deduplication key is idempotency_key. + provider_event_id text CHECK (provider_event_id IS NULL OR (btrim(provider_event_id) <> '' AND length(provider_event_id) <= 256)), + idempotency_key bytea NOT NULL CHECK (octet_length(idempotency_key) = 32), + content_digest bytea NOT NULL CHECK (octet_length(content_digest) = 32), + -- SHA-256 of the transaction reference the input points at: the Apple + -- transaction id or the Google purchase token. This is the RTDN-to- + -- observation attribution join and never the raw value. + transaction_reference_digest bytea CHECK ( + transaction_reference_digest IS NULL OR octet_length(transaction_reference_digest) = 32 + ), + -- Encrypted body. Sealed only after the tenant is known; an input that + -- cannot be attributed is never persisted with a plaintext body. + body_state text NOT NULL CHECK (body_state IN ('stored', 'not_retained', 'expired')), + envelope_version integer CHECK (envelope_version IS NULL OR envelope_version = 1), + algorithm text CHECK (algorithm IS NULL OR algorithm = 'AES-256-GCM'), + key_id text CHECK (key_id IS NULL OR (length(key_id) BETWEEN 1 AND 64 AND key_id ~ '^[ -~]+$')), + nonce bytea CHECK (nonce IS NULL OR octet_length(nonce) = 12), + ciphertext bytea CHECK (ciphertext IS NULL OR octet_length(ciphertext) >= 16), + fingerprint bytea CHECK (fingerprint IS NULL OR octet_length(fingerprint) = 32), + envelope_rotated_at timestamptz, + authentication_result text NOT NULL CHECK (authentication_result IN ( + 'verified_signature', 'verified_transport', 'unauthenticated_client', 'failed' + )), + store_environment text NOT NULL CHECK (store_environment IN ('sandbox', 'production', 'unclassified')), + notification_kind text CHECK (notification_kind IS NULL OR (btrim(notification_kind) <> '' AND length(notification_kind) <= 64)), + notification_subtype text CHECK (notification_subtype IS NULL OR (btrim(notification_subtype) <> '' AND length(notification_subtype) <= 64)), + ingestion_status text NOT NULL CHECK (ingestion_status IN ( + 'accepted', 'duplicate', 'conflicted', 'quarantined' + )), + correlation_id text NOT NULL CHECK (btrim(correlation_id) <> '' AND length(correlation_id) <= 128), + -- The store's own timestamp and Mosaic's receive timestamp are always kept + -- apart: an older event that arrives after a newer one is never discarded, + -- and ordering semantics are deliberately out of Phase 9A scope. + provider_occurred_at timestamptz, + received_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (project_id, provider, idempotency_key), + FOREIGN KEY (project_id, organization_id) + REFERENCES projects(id, organization_id) ON DELETE RESTRICT, + 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 (credential_id, project_id, provider) + REFERENCES store_server_credentials(id, project_id, provider) ON DELETE RESTRICT, + CONSTRAINT billing_raw_inputs_envelope_shape_check CHECK ( + (body_state = 'stored') = ( + envelope_version IS NOT NULL AND algorithm IS NOT NULL AND key_id IS NOT NULL AND + nonce IS NOT NULL AND ciphertext IS NOT NULL AND fingerprint IS NOT NULL + ) + ), + CHECK (expires_at >= received_at) +); +CREATE INDEX billing_raw_inputs_environment_idx + ON billing_raw_inputs(environment_id, received_at DESC, id); +CREATE INDEX billing_raw_inputs_reference_idx + ON billing_raw_inputs(environment_id, transaction_reference_digest, received_at DESC) + WHERE transaction_reference_digest IS NOT NULL; +CREATE INDEX billing_raw_inputs_retention_idx + ON billing_raw_inputs(expires_at, id) + WHERE body_state = 'stored'; +CREATE INDEX billing_raw_inputs_key_rotation_idx + ON billing_raw_inputs(key_id, id) + WHERE body_state = 'stored'; + +-- Raw inputs are append-only with two deliberate exceptions, both of which are +-- schema-level rather than application-level trust: +-- +-- * DELETE is permitted, because the retention job is the only path that +-- removes an expired body and Phase 6 established the same shape for +-- analytics_events. +-- * UPDATE is permitted only when nothing but the encryption envelope +-- changed, so `keyring rotate` can reseal a body under a new key without +-- being able to alter what the body says, and so the retention job can drop +-- an expired body by clearing the envelope. The one permitted body_state +-- transition is 'stored' -> 'expired'; nothing can move the other way, so a +-- body that has aged out cannot be reinstated with different content. +-- +goose StatementBegin +CREATE FUNCTION reject_billing_raw_input_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.body_state IS DISTINCT FROM OLD.body_state + AND NOT (OLD.body_state = 'stored' AND NEW.body_state = 'expired') + THEN + RAISE EXCEPTION 'billing raw input body state may only move from stored to expired' + USING ERRCODE = '55000'; + END IF; + IF NEW.id IS DISTINCT FROM OLD.id + OR NEW.project_id IS DISTINCT FROM OLD.project_id + OR NEW.organization_id IS DISTINCT FROM OLD.organization_id + OR NEW.environment_id IS DISTINCT FROM OLD.environment_id + OR NEW.environment_mode IS DISTINCT FROM OLD.environment_mode + OR NEW.application_id IS DISTINCT FROM OLD.application_id + OR NEW.credential_id IS DISTINCT FROM OLD.credential_id + OR NEW.provider IS DISTINCT FROM OLD.provider + OR NEW.source IS DISTINCT FROM OLD.source + OR NEW.source_authority IS DISTINCT FROM OLD.source_authority + OR NEW.provider_event_id IS DISTINCT FROM OLD.provider_event_id + OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key + OR NEW.content_digest IS DISTINCT FROM OLD.content_digest + OR NEW.transaction_reference_digest IS DISTINCT FROM OLD.transaction_reference_digest + OR NEW.authentication_result IS DISTINCT FROM OLD.authentication_result + OR NEW.store_environment IS DISTINCT FROM OLD.store_environment + OR NEW.notification_kind IS DISTINCT FROM OLD.notification_kind + OR NEW.notification_subtype IS DISTINCT FROM OLD.notification_subtype + OR NEW.ingestion_status IS DISTINCT FROM OLD.ingestion_status + OR NEW.correlation_id IS DISTINCT FROM OLD.correlation_id + OR NEW.provider_occurred_at IS DISTINCT FROM OLD.provider_occurred_at + OR NEW.received_at IS DISTINCT FROM OLD.received_at + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + THEN + RAISE EXCEPTION 'billing raw inputs are append-only outside encryption-key rotation' + USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_raw_inputs_append_only BEFORE UPDATE ON billing_raw_inputs +FOR EACH ROW EXECUTE FUNCTION reject_billing_raw_input_mutation(); + +CREATE TABLE billing_validation_attempts ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + raw_input_id text NOT NULL, + credential_id text, + attempt_number integer NOT NULL CHECK (attempt_number >= 1), + validator_version integer NOT NULL CHECK (validator_version >= 1), + started_at timestamptz NOT NULL, + completed_at timestamptz NOT NULL, + outcome text NOT NULL CHECK (outcome IN ( + 'validated', 'recorded_no_fact', 'quarantined', 'retryable_failure', 'permanently_failed' + )), + retryable boolean NOT NULL, + failure_category text CHECK (failure_category IS NULL OR failure_category IN ( + 'transient', 'rate_limited', 'auth', 'quota', 'not_found_retryable', + 'not_found_terminal', 'invalid', 'signature', 'resolution', 'configuration' + )), + -- Mosaic's own stable taxonomy. Provider response bodies are never stored. + diagnostic_code text CHECK (diagnostic_code IS NULL OR (btrim(diagnostic_code) <> '' AND length(diagnostic_code) <= 128)), + -- The provider's own machine-readable code, bounded to a safe charset. + provider_code text CHECK (provider_code IS NULL OR (btrim(provider_code) <> '' AND provider_code ~ '^[A-Za-z0-9_.-]{1,128}$')), + provider_http_status integer CHECK (provider_http_status IS NULL OR provider_http_status BETWEEN 100 AND 599), + store_environment text NOT NULL CHECK (store_environment IN ('sandbox', 'production', 'unclassified')), + latency_ms integer NOT NULL CHECK (latency_ms >= 0), + replay_of_attempt_id text, + correlation_id text NOT NULL CHECK (btrim(correlation_id) <> '' AND length(correlation_id) <= 128), + UNIQUE (id, project_id), + UNIQUE (raw_input_id, attempt_number), + FOREIGN KEY (raw_input_id, project_id) + REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (credential_id, project_id) + REFERENCES store_server_credentials(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (replay_of_attempt_id, project_id) + REFERENCES billing_validation_attempts(id, project_id) ON DELETE RESTRICT, + CHECK (completed_at >= started_at), + CHECK ((outcome = 'retryable_failure') = retryable) +); +CREATE INDEX billing_validation_attempts_input_idx + ON billing_validation_attempts(raw_input_id, attempt_number); +CREATE INDEX billing_validation_attempts_environment_idx + ON billing_validation_attempts(environment_id, started_at DESC, id); + +-- +goose StatementBegin +CREATE FUNCTION reject_billing_append_only_change() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is append-only', TG_TABLE_NAME USING ERRCODE = '55000'; +END; +$$; +-- +goose StatementEnd +CREATE TRIGGER billing_validation_attempts_append_only +BEFORE UPDATE OR DELETE ON billing_validation_attempts +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Validation queue. Same lease/attempt/available_at shape as every other Mosaic +-- worker queue, so the Phase 8 backlog and dead-letter runbooks apply unchanged. +CREATE TABLE billing_validation_jobs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + raw_input_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + 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, + UNIQUE (raw_input_id), + FOREIGN KEY (raw_input_id, project_id) + REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'leased') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)) +); +CREATE INDEX billing_validation_jobs_lease_idx + ON billing_validation_jobs(available_at, created_at, id) + WHERE status IN ('queued', 'leased'); + +-- +goose Down +DROP TABLE billing_validation_jobs; +DROP TRIGGER billing_validation_attempts_append_only ON billing_validation_attempts; +DROP TABLE billing_validation_attempts; +DROP FUNCTION reject_billing_append_only_change(); +DROP TRIGGER billing_raw_inputs_append_only ON billing_raw_inputs; +DROP FUNCTION reject_billing_raw_input_mutation(); +DROP TABLE billing_raw_inputs; diff --git a/apps/api/migrations/00024_billing_facts_and_ledger.sql b/apps/api/migrations/00024_billing_facts_and_ledger.sql new file mode 100644 index 00000000..3fa48610 --- /dev/null +++ b/apps/api/migrations/00024_billing_facts_and_ledger.sql @@ -0,0 +1,254 @@ +-- Phase 9A: Transaction Facts, Product Resolutions, and the Billing Event Ledger. +-- +-- A Transaction Fact is a provider-independent normalized statement that a +-- store confirmed something happened. It is never a subscription, an +-- entitlement, or an access grant, and Phase 9A creates no table that could +-- become one: there is no customer column, no price or currency column, no +-- subject identity, and no is_active flag. +-- +-- Fact identity is UNIQUE (environment_id, fact_digest). Re-validating the same +-- input against the same mapping history recomputes the same digest, which is +-- what makes replay a structural no-op rather than an application convention. + +-- +goose Up + +CREATE TABLE billing_transaction_facts ( + 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')), + + -- Identity. Apple transaction ids are stored as given because they are the + -- documented lookup key for Get Transaction Info; Google purchase tokens + -- are never stored here, only their digest. + provider_transaction_id text NOT NULL CHECK ( + btrim(provider_transaction_id) <> '' AND length(provider_transaction_id) <= 256 + ), + provider_original_transaction_id text CHECK ( + provider_original_transaction_id IS NULL OR btrim(provider_original_transaction_id) <> '' + ), + purchase_chain_digest bytea CHECK ( + purchase_chain_digest IS NULL OR octet_length(purchase_chain_digest) = 32 + ), + supersedes_chain_digest bytea CHECK ( + supersedes_chain_digest IS NULL OR octet_length(supersedes_chain_digest) = 32 + ), + + -- Classification. Phase 9A supports auto-renewable subscriptions and + -- non-consumables only; consumables quarantine as unsupported. + transaction_type text NOT NULL CHECK ( + transaction_type IN ('auto_renewable_subscription', 'non_consumable') + ), + fact_kind text NOT NULL CHECK (fact_kind IN ( + 'initial_purchase', 'renewal', 'one_time_purchase', 'plan_change', 'offer_redeemed', + 'refund', 'revocation', 'expiration', 'grace_period_start', 'billing_retry_start', + 'cancellation_scheduled', 'auto_renew_disabled', 'auto_renew_enabled', + 'purchase_superseded', 'paused', 'resumed' + )), + occurred_at timestamptz NOT NULL, + -- Provider-stated validity window. Recorded as a provider fact only; Phase + -- 9A never interprets it as customer access. + period_start_at timestamptz, + period_end_at timestamptz, + revoked_at timestamptz, + refunded_at timestamptz, + renewal_expected boolean, + is_test_transaction boolean NOT NULL DEFAULT false, + + -- Resolution Snapshot: the exact mapping row and version used, so historical + -- resolution is reproducible after the mapping changes. + provider_product_identifier text NOT NULL CHECK ( + btrim(provider_product_identifier) <> '' AND length(provider_product_identifier) <= 255 + ), + provider_base_plan_identifier text, + provider_offer_identifier text, + resolution_state text NOT NULL CHECK (resolution_state IN ( + 'active_mapping', 'archived_mapping', 'replacement_chain', 'unresolved' + )), + mosaic_product_id text, + provider_product_mapping_id text, + resolved_mapping_version bigint CHECK (resolved_mapping_version IS NULL OR resolved_mapping_version >= 0), + + -- Provenance. + validator_version integer NOT NULL CHECK (validator_version >= 1), + fact_version integer NOT NULL DEFAULT 1 CHECK (fact_version >= 1), + source_raw_input_id text NOT NULL, + validation_attempt_id text NOT NULL, + fact_digest bytea NOT NULL CHECK (octet_length(fact_digest) = 32), + recorded_at timestamptz NOT NULL, + + UNIQUE (id, project_id), + -- Identity. Duplicate delivery, reconciliation rediscovery, and replay all + -- collapse onto this constraint rather than onto application logic. + UNIQUE (environment_id, fact_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 (mosaic_product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (provider_product_mapping_id, project_id) + REFERENCES provider_product_mappings(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (source_raw_input_id, project_id) + REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (validation_attempt_id, project_id) + REFERENCES billing_validation_attempts(id, project_id) ON DELETE RESTRICT, + -- Sandbox and production never mix: a production Store Environment can only + -- be recorded against a production Mosaic Environment. + CONSTRAINT billing_transaction_facts_environment_alignment_check CHECK ( + (environment_mode = 'production') = (store_environment = 'production') + ), + CONSTRAINT billing_transaction_facts_resolution_shape_check CHECK ( + (resolution_state = 'unresolved') = (mosaic_product_id IS NULL) AND + (resolution_state = 'unresolved') = (provider_product_mapping_id IS NULL) + ), + CHECK (period_end_at IS NULL OR period_start_at IS NULL OR period_end_at >= period_start_at), + CHECK (provider_offer_identifier IS NULL OR provider_base_plan_identifier IS NOT NULL), + CHECK (provider = 'google_play' OR (provider_base_plan_identifier IS NULL AND provider_offer_identifier IS NULL)) +); +CREATE INDEX billing_transaction_facts_environment_idx + ON billing_transaction_facts(environment_id, occurred_at DESC, id); +CREATE INDEX billing_transaction_facts_chain_idx + ON billing_transaction_facts(environment_id, purchase_chain_digest, occurred_at DESC) + WHERE purchase_chain_digest IS NOT NULL; +CREATE INDEX billing_transaction_facts_product_idx + ON billing_transaction_facts(environment_id, mosaic_product_id, occurred_at DESC) + WHERE mosaic_product_id IS NOT NULL; +CREATE INDEX billing_transaction_facts_input_idx + ON billing_transaction_facts(source_raw_input_id, recorded_at DESC); + +CREATE TRIGGER billing_transaction_facts_append_only +BEFORE UPDATE OR DELETE ON billing_transaction_facts +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Product Resolution results, including the ones that failed. Recording a +-- failed resolution rather than dropping the input is what makes the ledger +-- complete enough for reconciliation and later backfill. +CREATE TABLE billing_product_resolutions ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + application_id text, + validation_attempt_id text NOT NULL, + raw_input_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + provider_product_identifier text NOT NULL CHECK (btrim(provider_product_identifier) <> ''), + provider_base_plan_identifier text, + provider_offer_identifier text, + outcome text NOT NULL CHECK (outcome IN ( + 'resolved', 'unknown', 'ambiguous', 'cross_environment_mismatch', 'unsupported_product_type' + )), + resolution_state text CHECK (resolution_state IS NULL OR resolution_state IN ( + 'active_mapping', 'archived_mapping', 'replacement_chain' + )), + mosaic_product_id text, + provider_product_mapping_id text, + -- The mapping row the walk actually matched, which may differ from the + -- mapping whose Mosaic Product was adopted when a replacement chain was + -- followed. Both are recorded so provenance is exact. + matched_mapping_id text, + mapping_version bigint CHECK (mapping_version IS NULL OR mapping_version >= 0), + candidate_count integer NOT NULL DEFAULT 0 CHECK (candidate_count >= 0), + diagnostic_code text CHECK (diagnostic_code IS NULL OR (btrim(diagnostic_code) <> '' AND length(diagnostic_code) <= 128)), + occurred_at timestamptz NOT NULL, + resolved_at timestamptz NOT NULL, + UNIQUE (id, project_id), + UNIQUE (validation_attempt_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 (validation_attempt_id, project_id) + REFERENCES billing_validation_attempts(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 (mosaic_product_id, project_id) + REFERENCES products(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (provider_product_mapping_id, project_id) + REFERENCES provider_product_mappings(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (matched_mapping_id, project_id) + REFERENCES provider_product_mappings(id, project_id) ON DELETE RESTRICT, + CONSTRAINT billing_product_resolutions_outcome_shape_check CHECK ( + (outcome = 'resolved') = (mosaic_product_id IS NOT NULL) AND + (outcome = 'resolved') = (provider_product_mapping_id IS NOT NULL) AND + (outcome = 'resolved') = (resolution_state IS NOT NULL) + ) +); +CREATE INDEX billing_product_resolutions_environment_idx + ON billing_product_resolutions(environment_id, resolved_at DESC, id); +CREATE INDEX billing_product_resolutions_unresolved_idx + ON billing_product_resolutions(environment_id, provider_product_identifier, resolved_at DESC) + WHERE outcome <> 'resolved'; + +CREATE TRIGGER billing_product_resolutions_append_only +BEFORE UPDATE OR DELETE ON billing_product_resolutions +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- The Billing Event Ledger. Operational history only: what the pipeline did and +-- when. There is deliberately no update endpoint anywhere in the API, and the +-- trigger makes that a schema guarantee rather than a routing decision. +CREATE TABLE billing_ledger_entries ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + entry_type text NOT NULL CHECK (entry_type IN ( + 'input_received', 'input_authenticated', 'input_duplicate_detected', + 'validation_started', 'validation_succeeded', 'validation_failed', + 'product_resolved', 'product_resolution_failed', + 'fact_recorded', 'fact_deduplicated', + 'input_quarantined', 'quarantine_closed', + 'reconciliation_started', 'reconciliation_discovery', 'reconciliation_completed', + 'replay_started', 'replay_completed', 'revalidation_completed', + 'credential_health_changed' + )), + raw_input_id text, + validation_attempt_id text, + transaction_fact_id text, + credential_id text, + -- Safe machine-readable detail only. The CHECK bans the key names that + -- carry bearer values so a future caller cannot smuggle a token into the + -- ledger by adding a field. + detail jsonb NOT NULL DEFAULT '{}'::jsonb, + correlation_id text NOT NULL CHECK (btrim(correlation_id) <> '' AND length(correlation_id) <= 128), + occurred_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 (validation_attempt_id, project_id) + REFERENCES billing_validation_attempts(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (transaction_fact_id, project_id) + REFERENCES billing_transaction_facts(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (credential_id, project_id) + REFERENCES store_server_credentials(id, project_id) ON DELETE RESTRICT, + CONSTRAINT billing_ledger_entries_safe_detail_check CHECK ( + jsonb_typeof(detail) = 'object' AND + octet_length(detail::text) <= 2048 AND + NOT (detail ?| ARRAY[ + 'token', 'purchaseToken', 'receipt', 'signedPayload', 'signedTransactionInfo', + 'signedRenewalInfo', 'credential', 'secret', 'password', 'authorization', + 'bearer', 'appAccountToken', 'privateKey' + ]) + ) +); +CREATE INDEX billing_ledger_entries_environment_idx + ON billing_ledger_entries(environment_id, occurred_at DESC, id); +CREATE INDEX billing_ledger_entries_input_idx + ON billing_ledger_entries(raw_input_id, occurred_at DESC) + WHERE raw_input_id IS NOT NULL; + +CREATE TRIGGER billing_ledger_entries_append_only +BEFORE UPDATE OR DELETE ON billing_ledger_entries +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- +goose Down +DROP TRIGGER billing_ledger_entries_append_only ON billing_ledger_entries; +DROP TABLE billing_ledger_entries; +DROP TRIGGER billing_product_resolutions_append_only ON billing_product_resolutions; +DROP TABLE billing_product_resolutions; +DROP TRIGGER billing_transaction_facts_append_only ON billing_transaction_facts; +DROP TABLE billing_transaction_facts; diff --git a/apps/api/migrations/00025_billing_operations.sql b/apps/api/migrations/00025_billing_operations.sql new file mode 100644 index 00000000..eba31c02 --- /dev/null +++ b/apps/api/migrations/00025_billing_operations.sql @@ -0,0 +1,199 @@ +-- Phase 9A: quarantine, reconciliation, and replay. +-- +-- Quarantine is the only place a stuck input lives, and it has exactly one exit +-- that produces a Transaction Fact: a *successful* revalidation. There is no +-- "mark as valid" column, action, or status anywhere in this schema, so an +-- operator cannot assert authenticity that the store never confirmed. + +-- +goose Up + +CREATE TABLE billing_quarantine_records ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + raw_input_id text NOT NULL, + application_id text, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + reason_code text NOT NULL CHECK (reason_code IN ( + 'signature_invalid', 'application_mismatch', 'environment_mismatch', + 'store_environment_mismatch', 'credential_unavailable', 'credential_revoked', + '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' + )), + severity text NOT NULL CHECK (severity IN ('warning', 'error', 'security')), + -- The scopes an operator has to repair before a retry can succeed. Kept as a + -- bounded text array rather than free-form JSON so the dashboard filter is a + -- closed set. + scopes text[] NOT NULL DEFAULT ARRAY[]::text[], + status text NOT NULL CHECK (status IN ('open', 'retrying', 'closed_after_success', 'closed_superseded')), + attempt_count integer NOT NULL DEFAULT 1 CHECK (attempt_count >= 1), + first_seen_at timestamptz NOT NULL, + last_attempt_at timestamptz NOT NULL, + -- Closure always names the successful attempt (or the superseding record) + -- that justified it. A CHECK makes a closure without evidence impossible. + closing_attempt_id text, + superseded_by_record_id text, + closed_at timestamptz, + closed_by_actor_id text, + diagnostic_code text CHECK (diagnostic_code IS NULL OR (btrim(diagnostic_code) <> '' AND length(diagnostic_code) <= 128)), + UNIQUE (id, project_id), + -- One open record per input: retries update the existing record rather than + -- growing the queue. + UNIQUE (raw_input_id), + FOREIGN KEY (raw_input_id, project_id) + REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (environment_id, project_id) + REFERENCES environments(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (application_id, project_id) + REFERENCES applications(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (closing_attempt_id, project_id) + REFERENCES billing_validation_attempts(id, project_id) ON DELETE RESTRICT, + FOREIGN KEY (superseded_by_record_id, project_id) + REFERENCES billing_quarantine_records(id, project_id) ON DELETE RESTRICT, + CHECK (last_attempt_at >= first_seen_at), + CHECK (array_length(scopes, 1) IS NULL OR array_length(scopes, 1) <= 8), + CONSTRAINT billing_quarantine_records_closure_shape_check CHECK ( + (status IN ('closed_after_success', 'closed_superseded')) = (closed_at IS NOT NULL) AND + (status = 'closed_after_success') = (closing_attempt_id IS NOT NULL) AND + (status = 'closed_superseded') = (superseded_by_record_id IS NOT NULL) + ) +); +CREATE INDEX billing_quarantine_records_open_idx + ON billing_quarantine_records(environment_id, reason_code, first_seen_at) + WHERE status IN ('open', 'retrying'); +CREATE INDEX billing_quarantine_records_history_idx + ON billing_quarantine_records(environment_id, last_attempt_at DESC, id); + +-- Recovery history is append-only: the audit trail of who retried what survives +-- even though the quarantine record itself is a mutable work queue. +CREATE TABLE billing_quarantine_actions ( + id text PRIMARY KEY, + project_id text NOT NULL, + quarantine_record_id text NOT NULL, + action text NOT NULL CHECK (action IN ('retry_validation', 'rerun_resolution', 'close_superseded')), + outcome text NOT NULL CHECK (outcome IN ('accepted', 'rejected', 'succeeded', 'failed')), + diagnostic_code text CHECK (diagnostic_code IS NULL OR (btrim(diagnostic_code) <> '' AND length(diagnostic_code) <= 128)), + actor_id text NOT NULL, + occurred_at timestamptz NOT NULL, + FOREIGN KEY (quarantine_record_id, project_id) + REFERENCES billing_quarantine_records(id, project_id) ON DELETE RESTRICT +); +CREATE INDEX billing_quarantine_actions_history_idx + ON billing_quarantine_actions(quarantine_record_id, occurred_at DESC, id); + +CREATE TRIGGER billing_quarantine_actions_append_only +BEFORE UPDATE OR DELETE ON billing_quarantine_actions +FOR EACH ROW EXECUTE FUNCTION reject_billing_append_only_change(); + +-- Reconciliation runs are their own queue, following the analytics retention-run +-- shape. The cursor columns make a run restart-safe: an interrupted run resumes +-- from the last committed cursor rather than re-scanning the whole window. +CREATE TABLE billing_reconciliation_runs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + credential_id text NOT NULL, + provider text NOT NULL CHECK (provider IN ('app_store', 'google_play')), + trigger text NOT NULL CHECK (trigger IN ('scheduled', 'manual')), + strategy text NOT NULL CHECK (strategy IN ( + 'apple_notification_history', 'apple_transaction_history', 'google_token_requery' + )), + status text NOT NULL CHECK (status IN ('queued', 'leased', 'completed', 'partial', 'failed')), + window_start timestamptz NOT NULL, + window_end timestamptz NOT NULL, + -- Provider pagination cursor (Apple paginationToken / revision, Google + -- keyset position). Opaque and bounded; never a credential. + cursor_token text CHECK (cursor_token IS NULL OR length(cursor_token) <= 2048), + cursor_position bigint CHECK (cursor_position IS NULL OR cursor_position >= 0), + examined_count bigint NOT NULL DEFAULT 0 CHECK (examined_count >= 0), + discovered_count bigint NOT NULL DEFAULT 0 CHECK (discovered_count >= 0), + duplicate_count bigint NOT NULL DEFAULT 0 CHECK (duplicate_count >= 0), + failure_count bigint NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts BETWEEN 1 AND 10), + 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)), + requested_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + started_at timestamptz, + completed_at timestamptz, + 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 (credential_id, project_id, provider) + REFERENCES store_server_credentials(id, project_id, provider) ON DELETE RESTRICT, + CHECK (window_end > window_start), + CHECK ((status = 'leased') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)), + CHECK ((status IN ('completed', 'partial', 'failed')) = (completed_at IS NOT NULL)) +); +CREATE INDEX billing_reconciliation_runs_lease_idx + ON billing_reconciliation_runs(available_at, created_at, id) + WHERE status IN ('queued', 'leased'); +CREATE INDEX billing_reconciliation_runs_history_idx + ON billing_reconciliation_runs(environment_id, created_at DESC, id); +-- One live run per credential and strategy, so a scheduled run and an operator +-- run cannot double-scan the same window. +CREATE UNIQUE INDEX billing_reconciliation_runs_active_key + ON billing_reconciliation_runs(credential_id, strategy) + WHERE status IN ('queued', 'leased'); + +-- Replay re-runs accepted inputs deterministically. It appends new Validation +-- Attempts and never rewrites prior attempts or facts; the comparison columns +-- record what changed, which is the entire operator-visible product of a replay. +CREATE TABLE billing_replay_jobs ( + id text PRIMARY KEY, + project_id text NOT NULL, + environment_id text NOT NULL, + kind text NOT NULL CHECK (kind IN ('replay', 'revalidation')), + -- Replay sources: a single input (quarantine recovery) or a bounded window. + raw_input_id text, + window_start timestamptz, + window_end timestamptz, + validator_version integer NOT NULL CHECK (validator_version >= 1), + status text NOT NULL CHECK (status IN ('queued', 'leased', 'completed', 'failed')), + comparison_result text CHECK (comparison_result IS NULL OR comparison_result IN ( + 'identical', 'new_facts', 'conflicting', 'still_failing' + )), + examined_count bigint NOT NULL DEFAULT 0 CHECK (examined_count >= 0), + unchanged_count bigint NOT NULL DEFAULT 0 CHECK (unchanged_count >= 0), + new_fact_count bigint NOT NULL DEFAULT 0 CHECK (new_fact_count >= 0), + conflict_count bigint NOT NULL DEFAULT 0 CHECK (conflict_count >= 0), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + max_attempts integer NOT NULL DEFAULT 3 CHECK (max_attempts BETWEEN 1 AND 10), + 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)), + requested_by_actor_id text NOT NULL, + created_at timestamptz NOT NULL, + completed_at timestamptz, + 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 (raw_input_id, project_id) + REFERENCES billing_raw_inputs(id, project_id) ON DELETE RESTRICT, + CHECK ((status = 'leased') = (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)), + CHECK ((status IN ('completed', 'failed')) = (completed_at IS NOT NULL)), + CONSTRAINT billing_replay_jobs_scope_shape_check CHECK ( + (raw_input_id IS NOT NULL AND window_start IS NULL AND window_end IS NULL) OR + (raw_input_id IS NULL AND window_start IS NOT NULL AND window_end IS NOT NULL AND window_end > window_start) + ) +); +CREATE INDEX billing_replay_jobs_lease_idx + ON billing_replay_jobs(available_at, created_at, id) + WHERE status IN ('queued', 'leased'); +CREATE INDEX billing_replay_jobs_history_idx + ON billing_replay_jobs(environment_id, created_at DESC, id); + +-- +goose Down +DROP TABLE billing_replay_jobs; +DROP TABLE billing_reconciliation_runs; +DROP TRIGGER billing_quarantine_actions_append_only ON billing_quarantine_actions; +DROP TABLE billing_quarantine_actions; +DROP TABLE billing_quarantine_records; diff --git a/apps/api/migrations/00026_billing_missing_credential_quarantine.sql b/apps/api/migrations/00026_billing_missing_credential_quarantine.sql new file mode 100644 index 00000000..ff1d5d86 --- /dev/null +++ b/apps/api/migrations/00026_billing_missing_credential_quarantine.sql @@ -0,0 +1,51 @@ +-- +goose Up +-- Phase 9A follow-up: a quarantine reason for "this Environment has no Store +-- Server Credential for this provider". +-- +-- Before this migration the only reason available was 'credential_unavailable', +-- which is the code for a credential that exists but cannot be used (revoked, +-- undecryptable, wrong key). Reporting an absent connection under that code +-- sends an operator to rotate a credential that does not exist. The two +-- failures have different causes, different fixes, 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. +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' + )); + +-- +goose Down +-- Rolling back has to remove rows carrying the new code, because the narrower +-- CHECK cannot be re-added while they exist. They are quarantine work items +-- rather than ledger evidence: the Raw Billing Input, its validation attempts, +-- and its ledger entries all survive, so nothing that records what the pipeline +-- did is lost. 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 = 'missing_validation_credential'); +ALTER TABLE billing_quarantine_actions ENABLE TRIGGER billing_quarantine_actions_append_only; +DELETE FROM billing_quarantine_records WHERE reason_code = 'missing_validation_credential'; + +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', + '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/00027_billing_replay_cursor.sql b/apps/api/migrations/00027_billing_replay_cursor.sql new file mode 100644 index 00000000..49494055 --- /dev/null +++ b/apps/api/migrations/00027_billing_replay_cursor.sql @@ -0,0 +1,92 @@ +-- Phase 9A fix pass: give replay a resumable cursor. +-- +-- billing_reconciliation_runs already carried cursor_token, and the Apple +-- notification-history strategy used it correctly. Window replay and the Google +-- token re-query did not: both scanned exactly one batch and then reported +-- `completed`, so an operator replaying a week containing four hundred inputs +-- saw twenty-five revalidated and a verdict of "identical". In an evidence +-- system a `completed` verdict over a silently partial scan is worse than an +-- outright failure, because it is indistinguishable from a real one. +-- +-- The cursor is the keyset position of the last input examined, not an offset: +-- inputs are ordered by (received_at, id), which is stable under concurrent +-- appends, so resuming after the recorded position cannot skip or repeat a row. + +-- +goose Up + +ALTER TABLE billing_replay_jobs + ADD COLUMN cursor_received_at timestamptz, + ADD COLUMN cursor_input_id text, + -- Both halves of a keyset position are meaningless alone. + ADD CONSTRAINT billing_replay_jobs_cursor_shape_check + CHECK ((cursor_received_at IS NULL) = (cursor_input_id IS NULL)); + +-- Gate 9A requires reconciliation to detect missing *or conflicting* state. +-- Missing state was detected; conflicting state had no category at all, so a +-- provider answer that contradicted a recorded fact was counted as a discovery +-- and was indistinguishable from newly learned information. Replay already had +-- the vocabulary; reconciliation gains the same counter. +ALTER TABLE billing_reconciliation_runs + ADD COLUMN conflict_count bigint NOT NULL DEFAULT 0 CHECK (conflict_count >= 0); + +-- The same keyset shape for reconciliation. cursor_token stays as it is: it +-- carries Apple's opaque pagination token, which is a provider position rather +-- than a Mosaic row position, and the two must not share a column. +ALTER TABLE billing_reconciliation_runs + ADD COLUMN cursor_received_at timestamptz, + ADD COLUMN cursor_input_id text, + ADD CONSTRAINT billing_reconciliation_runs_cursor_shape_check + CHECK ((cursor_received_at IS NULL) = (cursor_input_id IS NULL)); + +-- Resuming reads inputs in (received_at, id) order inside one Environment. +-- Without this index every resume degrades into a scan of the Environment's +-- whole input history, which is the table that grows fastest in this phase. +CREATE INDEX billing_raw_inputs_replay_cursor_idx + ON billing_raw_inputs(project_id, environment_id, received_at, id) + WHERE body_state = 'stored'; + +-- Revoking an Apple Store Server Credential was impossible. +-- +-- RevokeCredential clears intake_token_digest — that is what actually stops the +-- notification endpoint resolving, and it is the entire point of revoking after +-- a suspected compromise — but the Apple shape CHECK required the digest to be +-- present for every app_store row regardless of status. The UPDATE therefore +-- failed with a constraint violation, so an operator responding to a leaked +-- intake token had no way to close it. +-- +-- The requirement is narrowed to active credentials, which is where it belongs: +-- a live Apple credential must be reachable, a revoked one must not be. +ALTER TABLE store_server_credentials + DROP CONSTRAINT store_server_credentials_apple_shape_check, + ADD CONSTRAINT store_server_credentials_apple_shape_check CHECK ( + provider <> 'app_store' OR ( + credential_class = 'appleInAppPurchaseKey' AND + apple_issuer_id IS NOT NULL AND apple_key_id IS NOT NULL AND + (status <> 'active' OR intake_token_digest IS NOT NULL) AND + google_client_email IS NULL AND google_pubsub_project_id IS NULL AND + google_pubsub_subscription_id IS NULL + ) + ); + +-- +goose Down +ALTER TABLE store_server_credentials + DROP CONSTRAINT store_server_credentials_apple_shape_check, + ADD CONSTRAINT store_server_credentials_apple_shape_check CHECK ( + provider <> 'app_store' OR ( + credential_class = 'appleInAppPurchaseKey' AND + apple_issuer_id IS NOT NULL AND apple_key_id IS NOT NULL AND + intake_token_digest IS NOT NULL AND + google_client_email IS NULL AND google_pubsub_project_id IS NULL AND + google_pubsub_subscription_id IS NULL + ) + ); +DROP INDEX billing_raw_inputs_replay_cursor_idx; +ALTER TABLE billing_reconciliation_runs + DROP CONSTRAINT billing_reconciliation_runs_cursor_shape_check, + DROP COLUMN cursor_input_id, + DROP COLUMN cursor_received_at, + DROP COLUMN conflict_count; +ALTER TABLE billing_replay_jobs + DROP CONSTRAINT billing_replay_jobs_cursor_shape_check, + DROP COLUMN cursor_input_id, + DROP COLUMN cursor_received_at; diff --git a/apps/api/migrations/00028_billing_ledger_detail_and_settings.sql b/apps/api/migrations/00028_billing_ledger_detail_and_settings.sql new file mode 100644 index 00000000..160a6546 --- /dev/null +++ b/apps/api/migrations/00028_billing_ledger_detail_and_settings.sql @@ -0,0 +1,51 @@ +-- Phase 9A fix pass round 2: make the ledger detail guard exhaustive. +-- +-- `billing_ledger_entries_safe_detail_check` banned a list of key names with +-- `detail ?| ARRAY[...]`, which tests **top-level keys only**. A nested value +-- such as {"provider":{"purchaseToken":"…"}} passed it untouched, so the +-- constraint that exists specifically to keep bearer material out of the +-- operator-readable ledger could be stepped around by one level of nesting. +-- +-- Rather than walk the document recursively, `detail` is constrained to what +-- the application already writes: a flat object of string values. That makes +-- the top-level key check exhaustive by construction, and it removes the whole +-- category of "a future caller nests something" rather than chasing it. +-- +-- The key rule is also widened from an exact list to a pattern. An exact list +-- has to be maintained in step with every new field name and fails open on the +-- adjacent one — `googlePurchaseToken` was not on it. + +-- +goose Up + +-- +goose StatementBegin +CREATE FUNCTION billing_ledger_detail_is_safe(detail jsonb) RETURNS boolean +LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT bool_and( + jsonb_typeof(value) = 'string' + AND key !~* '(token|receipt|secret|password|authoriz|bearer|credential|signed|private|payload)' + ) IS NOT FALSE + FROM jsonb_each(detail) +$$; +-- +goose StatementEnd + +ALTER TABLE billing_ledger_entries + DROP CONSTRAINT billing_ledger_entries_safe_detail_check, + ADD CONSTRAINT billing_ledger_entries_safe_detail_check CHECK ( + jsonb_typeof(detail) = 'object' AND + octet_length(detail::text) <= 2048 AND + billing_ledger_detail_is_safe(detail) + ); + +-- +goose Down +ALTER TABLE billing_ledger_entries + DROP CONSTRAINT billing_ledger_entries_safe_detail_check, + ADD CONSTRAINT billing_ledger_entries_safe_detail_check CHECK ( + jsonb_typeof(detail) = 'object' AND + octet_length(detail::text) <= 2048 AND + NOT (detail ?| ARRAY[ + 'token', 'purchaseToken', 'receipt', 'signedPayload', 'signedTransactionInfo', + 'signedRenewalInfo', 'credential', 'secret', 'password', 'authorization', + 'bearer', 'appAccountToken', 'privateKey' + ]) + ); +DROP FUNCTION billing_ledger_detail_is_safe(jsonb); diff --git a/apps/dashboard/src/features/api-keys/components/one-time-secret.test.tsx b/apps/dashboard/src/components/feedback/one-time-secret.test.tsx similarity index 93% rename from apps/dashboard/src/features/api-keys/components/one-time-secret.test.tsx rename to apps/dashboard/src/components/feedback/one-time-secret.test.tsx index 5f107dc3..f07d8a58 100644 --- a/apps/dashboard/src/features/api-keys/components/one-time-secret.test.tsx +++ b/apps/dashboard/src/components/feedback/one-time-secret.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react" import { useState } from "react" import { describe, expect, it, vi } from "vitest" -import { OneTimeSecret } from "@/features/api-keys/components/one-time-secret" +import { OneTimeSecret } from "@/components/feedback/one-time-secret" function SecretHarness({ writeToClipboard, diff --git a/apps/dashboard/src/components/feedback/one-time-secret.tsx b/apps/dashboard/src/components/feedback/one-time-secret.tsx new file mode 100644 index 00000000..0aff07a5 --- /dev/null +++ b/apps/dashboard/src/components/feedback/one-time-secret.tsx @@ -0,0 +1,84 @@ +import { CopyIcon } from "@phosphor-icons/react/dist/ssr/Copy" +import { XIcon } from "@phosphor-icons/react/dist/ssr/X" +import { useId, useState } from "react" + +import { Button } from "@/components/ui/button" + +interface OneTimeSecretProps { + /** Label for the copy control. Name the thing being copied, not "value". */ + copyLabel?: string + description?: string + dismissLabel?: string + eyebrow?: string + onDismiss: () => void + secret: string + title?: string + writeToClipboard?: (value: string) => Promise +} + +/** + * A value the API returns exactly once. Two surfaces need it — API-key secrets + * and Store Notification intake endpoints — so it is a generic feedback + * primitive rather than a feature component. + * + * The value is held only in the caller's local state: it must never be written + * to the Query cache, a route search parameter, or browser storage. Dismissal + * unmounts it, and nothing can render it again. + */ +export function OneTimeSecret({ + copyLabel = "Copy secret", + description = "Mosaic cannot show this secret again after you dismiss it.", + dismissLabel = "Dismiss one-time secret", + eyebrow = "One-time secret", + onDismiss, + secret, + title = "Copy this key now", + writeToClipboard = (value) => navigator.clipboard.writeText(value), +}: OneTimeSecretProps) { + const [copied, setCopied] = useState(false) + const [copyFailed, setCopyFailed] = useState(false) + const titleId = useId() + + async function copySecret() { + try { + await writeToClipboard(secret) + setCopied(true) + setCopyFailed(false) + } catch { + setCopyFailed(true) + } + } + + return ( +
+
+
+

{eyebrow}

+

+ {title} +

+

{description}

+
+ +
+ {/* Selectable as the manual fallback: clipboard access is unavailable + outside secure contexts and in some browsers. */} + + {secret} + + +

+ {copyFailed ? "Copying failed. Select the value above to copy it manually." : ""} +

+
+ ) +} diff --git a/apps/dashboard/src/components/ui/table.tsx b/apps/dashboard/src/components/ui/table.tsx new file mode 100644 index 00000000..1abe32b5 --- /dev/null +++ b/apps/dashboard/src/components/ui/table.tsx @@ -0,0 +1,87 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx b/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx index ce325939..ed570c5d 100644 --- a/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx +++ b/apps/dashboard/src/features/api-keys/components/api-keys-page.tsx @@ -12,7 +12,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" -import { OneTimeSecret } from "@/features/api-keys/components/one-time-secret" +import { OneTimeSecret } from "@/components/feedback/one-time-secret" import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" import { diff --git a/apps/dashboard/src/features/api-keys/components/one-time-secret.tsx b/apps/dashboard/src/features/api-keys/components/one-time-secret.tsx deleted file mode 100644 index 70b6b89f..00000000 --- a/apps/dashboard/src/features/api-keys/components/one-time-secret.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { CopyIcon } from "@phosphor-icons/react/dist/ssr/Copy" -import { XIcon } from "@phosphor-icons/react/dist/ssr/X" -import { useState } from "react" - -import { Button } from "@/components/ui/button" - -interface OneTimeSecretProps { - onDismiss: () => void - secret: string - writeToClipboard?: (value: string) => Promise -} - -export function OneTimeSecret({ - onDismiss, - secret, - writeToClipboard = (value) => navigator.clipboard.writeText(value), -}: OneTimeSecretProps) { - const [copied, setCopied] = useState(false) - - async function copySecret() { - await writeToClipboard(secret) - setCopied(true) - } - - return ( -
-
-
-

One-time secret

-

- Copy this key now -

-

- Mosaic cannot show this secret again after you dismiss it. -

-
- -
- - {secret} - - -
- ) -} diff --git a/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx b/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx new file mode 100644 index 00000000..8cd35d29 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/billing-chrome.tsx @@ -0,0 +1,118 @@ +import { InfoIcon } from "@phosphor-icons/react/dist/ssr/Info" +import type { ReactNode } from "react" + +import { ScopeBadge } from "@/features/organizations/components/workspace-page" +import { + BILLING_BOUNDARY_NOTE, + formatBillingTimestamp, + providerLabel, + storeEnvironmentLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" + +/** + * Chrome shared by every Mosaic Billing surface across the three billing + * features. It exists so the phase boundary and the Mosaic/Store Environment + * distinction are stated identically everywhere instead of being re-worded per + * page. + */ + +export function BillingBoundaryNote({ children }: { children?: ReactNode }) { + return ( +

+ + + {BILLING_BOUNDARY_NOTE} + {children ? <> {children} : null} + +

+ ) +} + +/** + * The two Environments always render as two separately labelled badges. Merging + * them into one "environment" chip is the single most consequential mistake + * this surface can make, because sandbox facts would become indistinguishable + * from production ones. + */ +export function EnvironmentBadges({ + mosaicEnvironmentName, + storeEnvironment, +}: { + mosaicEnvironmentName: string + storeEnvironment: string | undefined +}) { + return ( +
+ + Mosaic Environment: + {mosaicEnvironmentName} + + + Store Environment: + {storeEnvironmentLabel(storeEnvironment)} + +
+ ) +} + +export function ProviderBadge({ provider }: { provider: string | undefined }) { + return {providerLabel(provider)} +} + +/** + * A status treatment that is always text-first. Colour is a secondary signal, + * never the only one. + */ +export function StatusPill({ + label, + tone = "neutral", +}: { + label: string + tone?: "attention" | "negative" | "neutral" | "positive" +}) { + const toneClass = { + attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", + negative: "border-destructive/35 bg-destructive/10 text-destructive", + neutral: "border-border bg-muted/55 text-muted-foreground", + positive: "border-primary/35 bg-primary/10 text-primary", + }[tone] + + return ( + + {label} + + ) +} + +/** Two timestamps, always labelled, always UTC, raw ISO available on hover. */ +export function DualTimestamps({ + occurredAt, + recordedAt, +}: { + occurredAt: string | undefined + recordedAt: string | undefined +}) { + return ( +
+

+ Occurred at + {formatBillingTimestamp(occurredAt)} +

+

+ Recorded at + {formatBillingTimestamp(recordedAt)} +

+
+ ) +} + +export function DefinitionRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/product-resolution-panel.tsx b/apps/dashboard/src/features/billing-ledger/components/product-resolution-panel.tsx new file mode 100644 index 00000000..a933f232 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/product-resolution-panel.tsx @@ -0,0 +1,103 @@ +import { DefinitionRow, StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + resolutionStateExplanation, + resolutionStateLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import type { Product, TransactionFact } from "@/generated/api" + +/** + * The Resolution Snapshot. + * + * Resolution is read-only here. The exact mapping version used is recorded so a + * historical resolution stays reproducible; nothing on this panel can re-point + * a fact at a different Mosaic Product, because that would silently rewrite + * what a past transaction meant. + */ +export function ProductResolutionPanel({ + fact, + productHref, + products, + quarantineHref, +}: { + fact: TransactionFact + productHref: (productId: string) => string + products: readonly Product[] + quarantineHref: string +}) { + const product = products.find((item) => item.id === fact.mosaicProductId) + const unresolved = fact.resolutionState === "unresolved" || !fact.mosaicProductId + + return ( + +
+ +
+

+ {resolutionStateExplanation(fact.resolutionState)} +

+ +
+ + {fact.providerBasePlanIdentifier ? ( + + ) : null} + {fact.providerOfferIdentifier ? ( + + ) : null} + + {product?.internalName ?? fact.mosaicProductId} + + ) : ( + "Unresolved" + ) + } + /> + + + +
+ + {unresolved ? ( +
+

The store confirmed a Product Mosaic does not recognise.

+

+ The input is kept as evidence rather than dropped. Repair the mapping on the Mosaic + Product, then re-run validation from the quarantine record — resolution is never + corrected by hand from this panel. +

+ + Open quarantine + +
+ ) : null} +
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/raw-input-panel.tsx b/apps/dashboard/src/features/billing-ledger/components/raw-input-panel.tsx new file mode 100644 index 00000000..4052b6d8 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/raw-input-panel.tsx @@ -0,0 +1,81 @@ +import { DefinitionRow } from "@/features/billing-ledger/components/billing-chrome" +import { + formatBillingTimestamp, + ledgerEntryTypeLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import type { BillingLedgerEntry, TransactionFact } from "@/generated/api" + +/** + * The source Raw Billing Input, described through the append-only Billing Event + * Ledger. + * + * The stored body — a signed Apple payload or a Google purchase token — is + * sealed under the Mosaic keyring and is deliberately not exposed by any REST + * read. Rendering it would put store bearer material in a browser, which is the + * one thing the redaction rules exist to prevent. What this panel shows instead + * is the identity and the ledger trail: enough to correlate the input with a + * server-side investigation, and nothing that could be replayed against a + * store. + */ +export function RawInputPanel({ + entries, + fact, +}: { + entries: readonly BillingLedgerEntry[] + fact: TransactionFact +}) { + const related = entries + .filter( + (entry) => + (fact.sourceRawInputId && entry.rawInputId === fact.sourceRawInputId) || + (fact.id && entry.transactionFactId === fact.id), + ) + .sort((a, b) => Date.parse(b.occurredAt ?? "") - Date.parse(a.occurredAt ?? "")) + + return ( + +
+ + + + +
+ +

Billing Ledger entries

+ {related.length === 0 ? ( +

+ No ledger entry for this input is inside the recent ledger window. +

+ ) : ( +
    + {related.map((entry) => ( +
  1. + {ledgerEntryTypeLabel(entry.entryType)} + + {formatBillingTimestamp(entry.occurredAt)} + {entry.correlationId ? ` · correlation ${entry.correlationId}` : ""} + +
  2. + ))} +
+ )} +

+ The ledger has no update operation. Entries are appended in the order events happened and + are never edited or removed. +

+
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/replay-panel.tsx b/apps/dashboard/src/features/billing-ledger/components/replay-panel.tsx new file mode 100644 index 00000000..6832b4da --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/replay-panel.tsx @@ -0,0 +1,298 @@ +import { WarningIcon } from "@phosphor-icons/react/dist/ssr/Warning" + +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + formatBillingTimestamp, + replayComparisonExplanation, + replayComparisonLabel, + replayKindLabel, + runStatusLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { + compareReplayAttempts, + describeReplayConflict, + selectComparableAttempts, +} from "@/features/billing-ledger/types/replay-comparison" +import type { ReplayJob, TransactionFact, ValidationAttempt } from "@/generated/api" + +interface ReplayPanelProps { + attempts: readonly ValidationAttempt[] + canManage: boolean + factsByAttemptId: ReadonlyMap + isReplaying: boolean + /** Replay jobs that could have touched this input, newest first. */ + jobs: readonly ReplayJob[] + justQueued: boolean + mappingHistoryHref?: string + onReplay: () => void + quarantineHref: string + replayError?: string + validatorVersion?: number +} + +const TERMINAL_STATUSES: readonly ReplayJob["status"][] = ["completed", "failed"] + +/** + * Replay and revalidation. + * + * Replay re-runs an accepted input and appends a new Validation Attempt. The + * result is presented as a comparison of two retained columns, never as a + * replacement: there is no control here to apply, accept, promote, or discard + * either side, because doing so would destroy the audit trail that makes the + * ledger worth keeping. + */ +export function ReplayPanel({ + attempts, + canManage, + factsByAttemptId, + isReplaying, + jobs, + justQueued, + mappingHistoryHref, + onReplay, + quarantineHref, + replayError, + validatorVersion, +}: ReplayPanelProps) { + const pair = selectComparableAttempts(attempts, factsByAttemptId) + const comparison = pair ? compareReplayAttempts(pair[0], pair[1]) : undefined + const latestJob = jobs[0] + const running = latestJob !== undefined && !TERMINAL_STATUSES.includes(latestJob.status) + + return ( + +
+ {canManage ? ( + + ) : ( +

+ Organization owner or admin permission is required to re-run validation. +

+ )} +

+ Validator version {validatorVersion ?? "—"} is recorded on every attempt, so a later + validator change is visible rather than silent. +

+
+ {replayError ? ( +

+ {replayError} +

+ ) : null} + + {justQueued && !running ? ( +

+ The replay was queued. A worker picks it up shortly; this panel refreshes on its own once + the job reports progress. +

+ ) : null} + + {latestJob ? ( + + ) : null} + + {jobs.length > 1 ? ( +
+ + Earlier replay jobs ({jobs.length - 1}) + +
    + {jobs.slice(1).map((job) => ( +
  • + {replayKindLabel(job.kind)} · {runStatusLabel(job.status)} ·{" "} + {replayComparisonLabel(job.comparisonResult)} ·{" "} + {formatBillingTimestamp(job.completedAt ?? job.createdAt)} +
  • + ))} +
+
+ ) : null} + + {comparison ? ( +
+ {comparison.hasConflict ? ( +
+

+ + The replay contradicts the earlier attempt +

+
    + {comparison.conflicts.map((conflict) => ( +
  • {describeReplayConflict(conflict)}
  • + ))} +
+

+ Both attempts are retained and Mosaic has not overwritten anything. Deciding which + reflects reality is an operator judgement, usually made from the Product mapping + history and the quarantine record. +

+
+ + Open quarantine + + {mappingHistoryHref ? ( + + Review mapping history + + ) : null} +
+
+ ) : null} + + + + Both attempts are kept. This comparison is a read of history, not a choice between two + options. + + + + Field + + Earlier attempt {comparison.earlierAttemptNumber ?? ""} + + + New attempt {comparison.latestAttemptNumber ?? ""} + + + + + {comparison.rows.map((row) => ( + + {row.label} + {row.earlier} + + {row.latest} + + {row.changed ? "changed" : "unchanged"} + + + + ))} + +
+
+ ) : ( +

+ A comparison appears once this input has more than one Validation Attempt. +

+ )} +
+ ) +} + +/** + * Progress and outcome for the most recent replay job. + * + * Modelled on the analytics `JobStatus`: a polite live region that names the + * state, and on failure names the safe error code and one next step. The + * counts are the point of a replay — they say whether re-running changed + * anything, and `conflictCount` says whether it changed something that + * contradicts what is already recorded. + */ +function ReplayJobStatus({ + job, + quarantineHref, + running, +}: { + job: ReplayJob + quarantineHref: string + running: boolean +}) { + const conflicts = job.conflictCount ?? 0 + + return ( +
+
+

+ {replayKindLabel(job.kind)} · {runStatusLabel(job.status)} +

+
+ {job.status === "completed" ? ( + + ) : null} + {conflicts > 0 ? : null} +
+
+ + {running ? ( +

+ Mosaic is re-running this input against the store. This status refreshes automatically and + stops polling once the job finishes. +

+ ) : job.status === "failed" ? ( + <> +

+ The replay failed safely with code {job.lastErrorCode ?? "unknown_error"}. No attempt or + fact already on record was changed. +

+

+ Re-running is safe: replay is idempotent, and an unchanged outcome recomputes the same + fact digest and writes nothing. +

+ + ) : ( + <> +

+ {replayComparisonExplanation(job.comparisonResult)} +

+
+ + + + +
+ + )} + +

+ Validator version {job.validatorVersion ?? "—"} ·{" "} + {formatBillingTimestamp(job.completedAt ?? job.createdAt)} +

+ + {conflicts > 0 ? ( + + Review the quarantine records this opened + + ) : null} +
+ ) +} + +function Count({ label, value }: { label: string; value: number | undefined }) { + return ( +
+
{label}
+
{value ?? 0}
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/transaction-fact-detail-page.tsx b/apps/dashboard/src/features/billing-ledger/components/transaction-fact-detail-page.tsx new file mode 100644 index 00000000..36e084b3 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/transaction-fact-detail-page.tsx @@ -0,0 +1,281 @@ +import { ArrowLeftIcon } from "@phosphor-icons/react/dist/ssr/ArrowLeft" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +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 { + BillingBoundaryNote, + DefinitionRow, + DualTimestamps, + EnvironmentBadges, + ProviderBadge, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { ProductResolutionPanel } from "@/features/billing-ledger/components/product-resolution-panel" +import { RawInputPanel } from "@/features/billing-ledger/components/raw-input-panel" +import { ReplayPanel } from "@/features/billing-ledger/components/replay-panel" +import { ValidationAttemptsPanel } from "@/features/billing-ledger/components/validation-attempts-panel" +import { createReplayJobMutationOptions } from "@/features/billing-ledger/mutations/replay-mutations" +import { replayJobsQueryOptions } from "@/features/billing-ledger/queries/replay-queries" +import { + billingLedgerQueryOptions, + transactionFactQueryOptions, + transactionFactsQueryOptions, + validationAttemptsQueryOptions, +} from "@/features/billing-ledger/queries/transaction-queries" +import { + factKindLabel, + transactionTypeLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { defaultTransactionFilters } from "@/features/billing-ledger/types/transaction-filters" +import { productsQueryOptions } from "@/features/catalog/queries/catalog-query" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import type { TransactionFact } from "@/generated/api" + +interface TransactionFactDetailPageProps { + environmentId: string + factId: string + organizationId: string + projectId: string +} + +export function TransactionFactDetailPage({ + environmentId, + factId, + organizationId, + projectId, +}: TransactionFactDetailPageProps) { + const queryClient = useQueryClient() + const access = useOrganizationAccess(organizationId) + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const fact = useQuery({ + ...transactionFactQueryOptions(projectId, environmentId, factId), + enabled: scopeReady, + }) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const applications = useQuery({ ...applicationsQueryOptions(projectId), enabled: scopeReady }) + const products = useQuery({ ...productsQueryOptions(projectId), enabled: scopeReady }) + const rawInputId = fact.data?.sourceRawInputId ?? "" + const attempts = useQuery({ + ...validationAttemptsQueryOptions(projectId, environmentId, rawInputId), + enabled: scopeReady && rawInputId.length > 0, + }) + const ledger = useQuery({ + ...billingLedgerQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + // Replay runs on a worker. Its job rows are what turn "Re-run validation" + // from a button that does nothing visible into an operation with feedback. + const replayJobs = useQuery({ + ...replayJobsQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + // Sibling facts share the source input; they are what a replay comparison + // reads to show which Mosaic Product each attempt resolved to. + const siblingFacts = useQuery({ + ...transactionFactsQueryOptions(projectId, environmentId, { + ...defaultTransactionFilters(), + limit: 100, + }), + enabled: scopeReady, + }) + const replay = useMutation(createReplayJobMutationOptions(projectId, environmentId, queryClient)) + + const record = fact.data + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const applicationName = + applications.data?.items.find((item) => item.id === record?.applicationId)?.name ?? + record?.applicationId ?? + "—" + // Scoped by the API to this input, so the list is the input's complete + // attempt history rather than whatever fell inside an Environment-wide page. + const relatedAttempts = attempts.data ?? [] + // Replay jobs for this input, plus window replays that could have touched it. + const relatedReplayJobs = (replayJobs.data ?? []).filter( + (job) => !job.rawInputId || job.rawInputId === rawInputId, + ) + const factsByAttemptId = new Map( + (siblingFacts.data?.items ?? []).flatMap((item) => + item.validationAttemptId ? [[item.validationAttemptId, item] as const] : [], + ), + ) + + const error = + project.error ?? + fact.error ?? + environments.error ?? + applications.error ?? + attempts.error ?? + ledger.error + const state = resolveHostedQueryState({ + emptyDescription: + "This Transaction Fact is not in the recent ledger window for this Mosaic Environment. Open the ledger and narrow the date range to find it.", + emptyTitle: "Transaction Fact unavailable", + error, + isEmpty: fact.isSuccess && !record, + isPending: + project.isPending || + (scopeReady && + (fact.isPending || + environments.isPending || + applications.isPending || + ledger.isPending || + (rawInputId.length > 0 && attempts.isPending))), + loadingDescription: "Loading the Transaction Fact, its attempts, and its ledger trail.", + onRetry: () => { + void fact.refetch() + void attempts.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read the Mosaic Billing ledger.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const projectBase = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}` + const billingBase = `${projectBase}/billing/${encodeURIComponent(environmentId)}` + + return ( + + Transaction ledger + + } + description="One store-confirmed transaction, the attempts that produced it, and the mapping version that resolved it." + eyebrow="Mosaic Billing · Transaction Fact" + title={record?.providerTransactionId ?? "Transaction Fact"} + > + + + + {record ? ( + <> +
+ + + + {record.isTestTransaction ? ( + + ) : null} +
+ + +
+ +
+
+ + + + + + + + + +
+

+ The period above is what the store stated about this transaction. Mosaic does not + interpret it, extend it, or use it to decide what anyone may do in your app. +

+
+ + + `${projectBase}/catalog/products/${encodeURIComponent(productId)}` + } + products={products.data?.items ?? []} + quarantineHref={`${billingBase}/quarantine`} + /> + + + + + + + replay.mutate({ + kind: "revalidation", + ...(record.sourceRawInputId ? { rawInputId: record.sourceRawInputId } : {}), + }) + } + quarantineHref={`${billingBase}/quarantine`} + {...(replay.error ? { replayError: replay.error.message } : {})} + {...(record.validatorVersion !== undefined + ? { validatorVersion: record.validatorVersion } + : {})} + /> + + ) : null} +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-filters.tsx b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-filters.tsx new file mode 100644 index 00000000..3b50ea47 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-filters.tsx @@ -0,0 +1,232 @@ +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + billingProviders, + ENVIRONMENT_DISTINCTION_NOTE, + providerLabel, + resolutionStateLabel, + resolutionStates, + storeEnvironmentLabel, + storeEnvironments, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { + clientAppliedFilterCount, + hasActiveTransactionFilters, + TRANSACTION_PAGE_SIZES, + type TransactionFilters, +} from "@/features/billing-ledger/types/transaction-filters" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import type { Application, 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" + +interface TransactionLedgerFiltersProps { + applications: readonly Application[] + environmentName: string + filters: TransactionFilters + onChange: (filters: TransactionFilters) => void + products: readonly Product[] +} + +export function TransactionLedgerFilters({ + applications, + environmentName, + filters, + onChange, + products, +}: TransactionLedgerFiltersProps) { + // Any filter change invalidates the cursor: a cursor is only meaningful for + // the query that produced it. + function update(patch: Partial) { + const { cursor: _cursor, ...rest } = filters + void _cursor + onChange({ ...rest, ...patch }) + } + + const clientFilters = clientAppliedFilterCount(filters) + + return ( + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ {hasActiveTransactionFilters(filters) ? ( + + ) : null} + {clientFilters > 0 ? ( +

+ {clientFilters} filter(s) are applied to the loaded page only. Store, dates, and paging + are applied by the API; Application, Product, Store Environment, resolution, and + reference narrow what is already on screen. +

+ ) : null} +
+
+ ) +} 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 new file mode 100644 index 00000000..8460eded --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-page.tsx @@ -0,0 +1,224 @@ +import { useQuery } from "@tanstack/react-query" + +import { Button } from "@/components/ui/button" +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 { 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" +import { BILLING_OPTIONAL_NOTE } from "@/features/billing-ledger/types/billing-vocabulary" +import { + applyClientTransactionFilters, + hasActiveTransactionFilters, + type TransactionFilters, +} from "@/features/billing-ledger/types/transaction-filters" +import { billingHealthQueryOptions } from "@/features/billing-operations/queries/billing-health-queries" +import { productsQueryOptions } from "@/features/catalog/queries/catalog-query" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" +import { storeConnectionsHref } from "@/lib/routing/workspace-hrefs" + +interface TransactionLedgerPageProps { + environmentId: string + filters: TransactionFilters + onFiltersChange: (filters: TransactionFilters) => void + organizationId: string + projectId: string +} + +export function TransactionLedgerPage({ + environmentId, + filters, + onFiltersChange, + organizationId, + projectId, +}: TransactionLedgerPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const applications = useQuery({ ...applicationsQueryOptions(projectId), enabled: scopeReady }) + const products = useQuery({ ...productsQueryOptions(projectId), enabled: scopeReady }) + const health = useQuery({ + ...billingHealthQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + const facts = useQuery({ + ...transactionFactsQueryOptions(projectId, environmentId, filters), + enabled: scopeReady, + }) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const loaded = facts.data?.items ?? [] + const visible = applyClientTransactionFilters(loaded, filters) + const billingEnabled = health.data?.billingEnabled !== false + + const error = project.error ?? environments.error ?? applications.error ?? facts.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: + project.isPending || + (scopeReady && (environments.isPending || applications.isPending || facts.isPending)), + loadingDescription: `Loading Transaction Facts for the ${environmentName} Mosaic Environment.`, + onRetry: () => { + void facts.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read the Mosaic Billing ledger.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const connectionsHref = storeConnectionsHref({ organizationId, projectId }) ?? "#" + const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + + return ( + + + + + + + {!billingEnabled ? ( + + Set up Mosaic Billing + + } + description={`Mosaic Billing is turned off for this Project, so no store input is accepted or recorded. ${BILLING_OPTIONAL_NOTE}`} + title="Mosaic Billing is not enabled for this Project" + /> + ) : loaded.length === 0 && !hasActiveTransactionFilters(filters) ? ( + <> + + Check intake health + + } + description="No Store Notification or Transaction Observation has produced a validated fact in this Mosaic Environment yet. Confirm the store-side notification setup, then watch billing health for the first accepted input." + title="No Transaction Facts recorded yet" + /> + onFiltersChange({ ...filters, cursor })} + /> + + ) : visible.length === 0 ? ( + <> + onFiltersChange({ limit: filters.limit })} type="button"> + Clear filters + + } + description="Facts exist in this Mosaic Environment, but none on the loaded page matches the current filters. Application, Product, Store Environment, resolution, and reference narrow the loaded page only — matches further back in the ledger are on later pages." + title="No Transaction Facts match these filters" + /> + {/* Paging has to survive the filtered-empty branch. Without it, an + operator filtering for an Application whose facts start on page + three has no way forward and discarding the filter is the only + exit. */} + onFiltersChange({ ...filters, cursor })} + /> + + ) : ( + + `${base}/transactions/${encodeURIComponent(factId)}`} + isPending={facts.isPending} + items={visible} + products={products.data?.items ?? []} + /> + onFiltersChange({ ...filters, cursor })} + /> + + )} + + + ) +} + +/** + * 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/components/transaction-ledger-table.tsx b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-table.tsx new file mode 100644 index 00000000..c1820fda --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/transaction-ledger-table.tsx @@ -0,0 +1,142 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + factKindLabel, + formatBillingTimestamp, + providerLabel, + resolutionStateLabel, + storeEnvironmentLabel, + transactionTypeLabel, + TIMESTAMP_DISTINCTION_NOTE, +} from "@/features/billing-ledger/types/billing-vocabulary" +import type { Application, Product, TransactionFact } from "@/generated/api" + +interface TransactionLedgerTableProps { + applications: readonly Application[] + environmentName: string + factHref: (factId: string) => string + isPending: boolean + items: readonly TransactionFact[] + products: readonly Product[] +} + +/** + * The Transaction Fact table. + * + * Every row is a statement that a store confirmed something happened. There is + * no row action, no bulk selection, and no editable cell: the ledger is + * append-only and nothing on this screen can change a recorded fact. + * + * Both timestamps are always visible and separately labelled, and Mosaic + * Environment and Store Environment occupy separate columns. + */ +export function TransactionLedgerTable({ + applications, + environmentName, + factHref, + isPending, + items, + products, +}: TransactionLedgerTableProps) { + function applicationName(applicationId: string | undefined) { + if (!applicationId) return "—" + return applications.find((item) => item.id === applicationId)?.name ?? applicationId + } + + function productName(productId: string | undefined) { + if (!productId) return undefined + return products.find((item) => item.id === productId)?.internalName ?? productId + } + + return ( + + + Store-confirmed Transaction Facts in the {environmentName} Mosaic Environment.{" "} + {TIMESTAMP_DISTINCTION_NOTE} + + + + Transaction + Store + Store Environment + Mosaic Environment + Application + Mosaic Product + Resolution + Fact + Occurred at + Recorded at + + + + {isPending + ? Array.from({ length: 5 }, (_, index) => ( + + {Array.from({ length: 10 }, (__, cell) => ( + + + + ))} + + )) + : items.map((fact) => { + const resolved = productName(fact.mosaicProductId) + return ( + + + + {fact.providerTransactionId ?? fact.id ?? "—"} + + + {transactionTypeLabel(fact.transactionType)} + + + {providerLabel(fact.provider)} + {storeEnvironmentLabel(fact.storeEnvironment)} + {environmentName} + {applicationName(fact.applicationId)} + + {resolved ?? Unresolved} + + {fact.providerProductIdentifier ?? "—"} + + + + + + {factKindLabel(fact.factKind)} + + {formatBillingTimestamp(fact.occurredAt)} + + + {formatBillingTimestamp(fact.recordedAt)} + + + ) + })} + +
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/components/validation-attempts-panel.tsx b/apps/dashboard/src/features/billing-ledger/components/validation-attempts-panel.tsx new file mode 100644 index 00000000..6f422c5e --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/components/validation-attempts-panel.tsx @@ -0,0 +1,122 @@ +import { RequestIdCopy } from "@/features/auth/components/hosted-resource-boundary" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { + formatBillingTimestamp, + storeEnvironmentLabel, + validationOutcomeLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import type { ValidationAttempt } from "@/generated/api" + +/** + * Validation Attempts, newest first. + * + * Every try is here, including the ones that failed and the ones a later replay + * superseded. Nothing is hidden, collapsed away, or removed: the value of an + * append-only attempt history is precisely that the earlier answer is still + * readable after the later one arrives. + * + * Failed attempts show Mosaic's own diagnostic code and a bounded store code. + * A store response body is never stored and so is never rendered. + */ +export function ValidationAttemptsPanel({ attempts }: { attempts: readonly ValidationAttempt[] }) { + const ordered = [...attempts].sort((a, b) => (b.attemptNumber ?? 0) - (a.attemptNumber ?? 0)) + const latest = ordered[0]?.attemptNumber + + return ( + + {ordered.length === 0 ? ( +

+ No Validation Attempt is recorded against this input yet. An attempt appears as soon as + the validation worker picks it up. +

+ ) : ( +
    + {ordered.map((attempt) => { + const superseded = + typeof latest === "number" && + typeof attempt.attemptNumber === "number" && + attempt.attemptNumber < latest + return ( +
  1. +
    +

    Attempt {attempt.attemptNumber ?? "—"}

    +
    + + {superseded ? ( + + ) : null} +
    +
    +
    + + + + + + + {attempt.replayOfAttemptId ? ( + + ) : null} +
    + {attempt.outcome && attempt.outcome !== "validated" ? ( +
    +

    + {attempt.diagnosticCode ?? "unclassified_failure"} + {attempt.failureCategory ? ` · ${attempt.failureCategory}` : ""} +

    +

    + {attempt.retryable + ? "Mosaic will try again with backoff. Each retry appends a new attempt; this one stays as it is." + : "This category never succeeds by retrying unchanged. Correct the underlying condition, then re-run validation from the quarantine record."} +

    + {attempt.providerCode || attempt.providerHttpStatus ? ( +

    + Store code {attempt.providerCode ?? "—"} + {attempt.providerHttpStatus ? ` · HTTP ${attempt.providerHttpStatus}` : ""} +

    + ) : null} +
    + ) : null} + {attempt.correlationId ? ( +
    + +
    + ) : null} +
  2. + ) + })} +
+ )} +
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-ledger/mutations/replay-mutations.ts b/apps/dashboard/src/features/billing-ledger/mutations/replay-mutations.ts new file mode 100644 index 00000000..071b1f58 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/mutations/replay-mutations.ts @@ -0,0 +1,37 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { createReplayJob, type CreateReplayJobRequest } from "@/generated/api" +import { replayKeys } from "@/features/billing-ledger/queries/replay-queries" +import { transactionKeys } from "@/features/billing-ledger/queries/transaction-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Replay and revalidation append. There is deliberately no mutation here that + * replaces, deletes, or accepts a previous result: the API exposes none, and + * the UI must not imply one exists. + */ +export function createReplayJobMutationOptions( + projectId: string, + environmentId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (body: CreateReplayJobRequest) => { + const result = await createReplayJob({ + body, + client: generatedDashboardClient, + path: { environmentId, projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: replayKeys.list(projectId, environmentId) }), + queryClient.invalidateQueries({ + queryKey: transactionKeys.attemptsScope(projectId, environmentId), + }), + ]) + }, + }) +} diff --git a/apps/dashboard/src/features/billing-ledger/queries/replay-queries.ts b/apps/dashboard/src/features/billing-ledger/queries/replay-queries.ts new file mode 100644 index 00000000..e97c6e93 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/queries/replay-queries.ts @@ -0,0 +1,34 @@ +import { queryOptions } from "@tanstack/react-query" + +import { listReplayJobs, type ReplayJob } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export const replayKeys = { + list: (projectId: string, environmentId: string) => + ["billing-ledger", projectId, environmentId, "replay-jobs"] as const, +} + +const TERMINAL_STATUSES: readonly ReplayJob["status"][] = ["completed", "failed"] + +export function replayJobsQueryOptions(projectId: string, environmentId: string) { + return queryOptions({ + queryKey: replayKeys.list(projectId, environmentId), + queryFn: async ({ signal }) => { + const result = await listReplayJobs({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: 25 }, + signal, + throwOnError: true, + }) + return result.data.data?.items ?? [] + }, + // Replay runs on a worker. Polling stops as soon as nothing is in flight, + // so an idle billing page makes no repeating requests. + refetchInterval: (query) => { + const items = query.state.data ?? [] + const running = items.some((job) => !TERMINAL_STATUSES.includes(job.status)) + return running ? 5000 : false + }, + }) +} diff --git a/apps/dashboard/src/features/billing-ledger/queries/transaction-queries.test.ts b/apps/dashboard/src/features/billing-ledger/queries/transaction-queries.test.ts new file mode 100644 index 00000000..a6366074 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/queries/transaction-queries.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const listValidationAttempts = vi.fn() + +vi.mock("@/generated/api", () => ({ + listBillingLedger: vi.fn(), + listTransactionFacts: vi.fn(), + listValidationAttempts, +})) + +const { transactionKeys, validationAttemptsQueryOptions } = + await import("@/features/billing-ledger/queries/transaction-queries") + +/** + * Risk: the attempt history is fetched for the whole Mosaic Environment and + * filtered in the browser. In an Environment with real traffic the attempts + * belonging to the record an operator opened fall outside that page, and the + * panel then asserts "no Validation Attempt is recorded against this input" — + * a factual claim about the audit trail that is wrong, and that contradicts the + * attempt count rendered directly above it on the quarantine screen. + * + * The whole promise of the phase is a preserved, readable attempt history, so a + * surface that reports "none" when three exist is worse than showing nothing. + */ +describe("validation attempt scoping", () => { + beforeEach(() => { + listValidationAttempts.mockReset() + listValidationAttempts.mockResolvedValue({ data: { data: { items: [] } } }) + }) + + it("asks the API for one input's attempts rather than filtering a page in the browser", async () => { + const options = validationAttemptsQueryOptions("proj_1", "env_1", "rawin_42") + await options.queryFn!({ signal: new AbortController().signal } as never) + + const [request] = listValidationAttempts.mock.calls[0] ?? [] + expect(request.query.rawInputId).toBe("rawin_42") + expect(request.path).toEqual({ environmentId: "env_1", projectId: "proj_1" }) + }) + + it("caches each input's history separately, so one record cannot answer for another", () => { + expect(transactionKeys.attempts("proj_1", "env_1", "rawin_42")).not.toEqual( + transactionKeys.attempts("proj_1", "env_1", "rawin_43"), + ) + // The scope key still covers both, so a replay invalidates every input's + // history rather than only the one the mutation named. + const scope = transactionKeys.attemptsScope("proj_1", "env_1") + expect(transactionKeys.attempts("proj_1", "env_1", "rawin_42").slice(0, scope.length)).toEqual([ + ...scope, + ]) + }) +}) diff --git a/apps/dashboard/src/features/billing-ledger/queries/transaction-queries.ts b/apps/dashboard/src/features/billing-ledger/queries/transaction-queries.ts new file mode 100644 index 00000000..e5620c73 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/queries/transaction-queries.ts @@ -0,0 +1,143 @@ +import { queryOptions } from "@tanstack/react-query" + +import { + listBillingLedger, + listTransactionFacts, + listValidationAttempts, + type TransactionFact, +} from "@/generated/api" +import { + transactionFactsQuery, + type TransactionFilters, +} from "@/features/billing-ledger/types/transaction-filters" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * The ledger is cursor-paged and append-only. Nothing here mutates, and no + * query key includes a store identifier or token — only Mosaic identifiers and + * the filter values that are already in the URL. + */ +export const transactionKeys = { + attempts: (projectId: string, environmentId: string, rawInputId: string) => + ["billing-ledger", projectId, environmentId, "validation-attempts", rawInputId] as const, + attemptsScope: (projectId: string, environmentId: string) => + ["billing-ledger", projectId, environmentId, "validation-attempts"] as const, + fact: (projectId: string, environmentId: string, factId: string) => + ["billing-ledger", projectId, environmentId, "fact", factId] as const, + facts: (projectId: string, environmentId: string, filters: Record) => + ["billing-ledger", projectId, environmentId, "facts", filters] as const, + ledger: (projectId: string, environmentId: string) => + ["billing-ledger", projectId, environmentId, "entries"] as const, + scope: (projectId: string, environmentId: string) => + ["billing-ledger", projectId, environmentId] as const, +} + +export function transactionFactsQueryOptions( + projectId: string, + environmentId: string, + filters: TransactionFilters, +) { + const query = transactionFactsQuery(filters) + return queryOptions({ + queryKey: transactionKeys.facts(projectId, environmentId, query), + queryFn: async ({ signal }) => { + const result = await listTransactionFacts({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + }) +} + +/** + * The contract exposes no `GET .../billing/facts/{factId}`, so a deep link to a + * detail page is resolved by walking the cursor-paged list. The walk is bounded + * so a stale link degrades into an explicit "not in the recent ledger" state + * rather than an unbounded fetch loop. + */ +const FACT_LOOKUP_MAX_PAGES = 10 +const FACT_LOOKUP_PAGE_SIZE = 100 + +export function transactionFactQueryOptions( + projectId: string, + environmentId: string, + factId: string, +) { + return queryOptions({ + queryKey: transactionKeys.fact(projectId, environmentId, factId), + queryFn: async ({ signal }): Promise => { + let cursor: string | undefined + for (let page = 0; page < FACT_LOOKUP_MAX_PAGES; page += 1) { + const result = await listTransactionFacts({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: FACT_LOOKUP_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, + signal, + throwOnError: true, + }) + const match = result.data.data?.items?.find((item) => item.id === factId) + if (match) return match + cursor = result.data.data?.nextCursor + if (!cursor) break + } + return null + }, + }) +} + +/** + * One input's complete attempt history. + * + * The `rawInputId` filter is applied by the API. Filtering an Environment-wide + * page client-side was wrong in a way that mattered: in an Environment with + * real traffic the attempts belonging to the opened record fall outside the + * window, and the panel then asserted "no attempt recorded" about a record + * whose own attempt count said otherwise. For a phase whose promise is a + * preserved, readable attempt history, that claim is worse than showing + * nothing. + * + * The list stays newest-first and is never trimmed: a superseded attempt is + * still part of the audit trail. + */ +export function validationAttemptsQueryOptions( + projectId: string, + environmentId: string, + rawInputId: string, +) { + return queryOptions({ + queryKey: transactionKeys.attempts(projectId, environmentId, rawInputId), + queryFn: async ({ signal }) => { + const result = await listValidationAttempts({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: 100, rawInputId }, + signal, + throwOnError: true, + }) + return result.data.data?.items ?? [] + }, + }) +} + +export function billingLedgerQueryOptions(projectId: string, environmentId: string) { + return queryOptions({ + queryKey: transactionKeys.ledger(projectId, environmentId), + queryFn: async ({ signal }) => { + const result = await listBillingLedger({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: 100 }, + signal, + throwOnError: true, + }) + return result.data.data?.items ?? [] + }, + }) +} diff --git a/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts b/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts new file mode 100644 index 00000000..3a67164a --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/types/billing-vocabulary.ts @@ -0,0 +1,359 @@ +import type { QuarantineRecord, TransactionFact, ValidationAttempt } from "@/generated/api" + +/** + * Shared Mosaic Billing vocabulary. + * + * Phase 9A froze the words these surfaces may use. Every label an operator + * reads is produced here rather than by humanising a raw enum member at the + * call site, so the boundary between "a store confirmed this happened" and + * "this person has access" cannot drift back in one component at a time. + * + * Three features render billing (`store-connections`, `billing-ledger`, + * `billing-operations`). The ledger owns the vocabulary because the ledger is + * where the recorded facts live; the other two import from here. + */ + +/** + * Rendered in the header of every billing surface. It states the phase + * boundary without using any of the words 9A forbids on new surfaces. + */ +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." + +/** Billing is per-Project opt-in. No empty state may read like a dead end. */ +export const BILLING_OPTIONAL_NOTE = + "Mosaic Billing is optional. Studio, Products, Paywalls, Placements, Analytics, and Experiments all work without it." + +/** + * Mosaic Environment and Store Environment are different things and are always + * rendered as two separate, separately labelled controls and badges. + */ +export const ENVIRONMENT_DISTINCTION_NOTE = + "Mosaic Environment is your own workspace scope. Store Environment is sandbox or production as the store itself reported it. They are separate values on every record." + +export const TIMESTAMP_DISTINCTION_NOTE = + "Occurred at is the moment the store reports. Recorded at is the moment Mosaic durably accepted the input. They are never the same clock." + +export type BillingProvider = NonNullable +export type StoreEnvironment = "production" | "sandbox" +export type ValidationOutcome = NonNullable +export type ResolutionState = NonNullable +export type QuarantineReasonCode = NonNullable +export type QuarantineStatus = NonNullable + +export const billingProviders = [ + "app_store", + "google_play", +] as const satisfies readonly BillingProvider[] + +export const storeEnvironments = [ + "sandbox", + "production", +] as const satisfies readonly StoreEnvironment[] + +export const validationOutcomes = [ + "validated", + "recorded_no_fact", + "quarantined", + "retryable_failure", + "permanently_failed", +] as const satisfies readonly ValidationOutcome[] + +export const resolutionStates = [ + "active_mapping", + "archived_mapping", + "replacement_chain", + "unresolved", +] as const satisfies readonly ResolutionState[] + +const PROVIDER_LABELS: Record = { + app_store: "App Store", + google_play: "Google Play", +} + +const STORE_ENVIRONMENT_LABELS: Record = { + production: "Production", + sandbox: "Sandbox", + unclassified: "Unclassified", +} + +const VALIDATION_OUTCOME_LABELS: Record = { + permanently_failed: "Permanently failed", + quarantined: "Quarantined", + recorded_no_fact: "Recorded · no fact", + retryable_failure: "Retryable failure", + validated: "Validated", +} + +/** + * Resolution vocabulary stays ingestion-shaped. `archived_mapping` and + * `replacement_chain` are correct historical outcomes, not defects, so their + * copy says so. + */ +const RESOLUTION_STATE_LABELS: Record = { + active_mapping: "Resolved · active mapping", + archived_mapping: "Resolved · archived mapping", + replacement_chain: "Resolved · replacement chain", + unresolved: "Unresolved", +} + +const RESOLUTION_STATE_EXPLANATIONS: Record = { + active_mapping: "The provider Product matched a mapping that is active today.", + archived_mapping: + "The provider Product matched the mapping that was live when the transaction occurred. Resolving through history is correct, not a defect.", + replacement_chain: + "The matched mapping had been replaced, so Mosaic followed the recorded replacement chain to the current Mosaic Product.", + unresolved: + "The store confirmed a real purchase of a Product Mosaic does not recognise. The input is kept as evidence; repair the mapping and re-run validation.", +} + +/** The store's own transaction classification. Never a claim about access. */ +const TRANSACTION_TYPE_LABELS: Record = { + auto_renewable_subscription: "Auto-renewable", + non_consumable: "Non-consumable", +} + +const QUARANTINE_REASON_LABELS: Record = { + application_mismatch: "Application mismatch", + credential_revoked: "Store Server Credential revoked", + credential_unavailable: "Store Server Credential unavailable", + cross_environment_mismatch: "Cross-Environment mismatch", + environment_mismatch: "Mosaic Environment mismatch", + input_content_conflict: "Conflicting content for a known key", + malformed_reference: "Malformed transaction reference", + missing_validation_credential: "No Store Server Credential for this scope", + product_ambiguous: "Ambiguous Product mapping", + product_unknown: "Unknown provider Product", + provider_permanently_failed: "Store rejected permanently", + replay_conflict: "Replay produced a conflicting result", + signature_invalid: "Signature verification failed", + store_environment_mismatch: "Store Environment mismatch", + unsupported_product_type: "Unsupported Product type", + unsupported_transaction_type: "Unsupported transaction type", + validation_exhausted: "Retries exhausted", +} + +const QUARANTINE_REASON_EXPLANATIONS: Record = { + application_mismatch: + "The verified bundle or package identifier in the store payload is not in this credential's Application scope. Mosaic refuses to attribute it to a tenant that merely owns the endpoint.", + credential_revoked: + "The Store Server Credential this input needs has been revoked, so no store lookup can be made.", + credential_unavailable: + "The Store Server Credential could not be used. It may be expired, rejected by the store, or unreadable under the current keyring.", + cross_environment_mismatch: + "The candidate mapping belongs to a different Mosaic Environment. Resolution never crosses Environments.", + environment_mismatch: + "The input arrived on a credential registered for a different Mosaic Environment.", + input_content_conflict: + "A second input reused an existing idempotency key with different content. The original record was preserved untouched and this one was held for review.", + malformed_reference: + "The transaction reference did not have a shape the store could be asked about.", + missing_validation_credential: + "No Store Server Credential exists for this provider and Mosaic Environment, so no store lookup can be made. Add a credential for the scope, then retry validation.", + product_ambiguous: + "More than one mapping matched. Mosaic never picks by display name, price, billing period, or approximate match.", + product_unknown: + "The store confirmed a Product that has no mapping in this Project. Add or repair the mapping, then re-run validation.", + provider_permanently_failed: + "The store answered with a permanent failure. Retrying without changing anything would produce the same answer.", + replay_conflict: + "A replay produced a result that contradicts the earlier attempt. Both are retained; nothing was overwritten.", + signature_invalid: + "The notification signature did not verify against the pinned store root certificate. Treat this as a possible forged or misdirected delivery.", + store_environment_mismatch: + "The Store Environment the store reported does not match the one this credential is registered for. Sandbox and production never mix.", + unsupported_product_type: "Phase 9A models auto-renewable and non-consumable Products only.", + unsupported_transaction_type: + "Consumables and non-renewing purchases are not modelled in this phase and are recorded rather than coerced into a type Mosaic cannot represent.", + validation_exhausted: + "Every retryable attempt was used without a definitive store answer. Each attempt is preserved.", +} + +const QUARANTINE_STATUS_LABELS: Record = { + closed_after_success: "Closed after a successful attempt", + closed_superseded: "Closed as superseded", + open: "Open", + retrying: "Retrying", +} + +const QUARANTINE_SEVERITY_LABELS: Record = { + error: "Error", + security: "Security", + warning: "Warning", +} + +/** + * Worker-queue states an operator should never have to decode. "Leased" means a + * worker has picked the job up, which is "Running" from the outside. + */ +const RUN_STATUS_LABELS: Record = { + completed: "Completed", + failed: "Failed", + leased: "Running", + partial: "Completed with failures", + queued: "Queued", +} + +const RUN_TRIGGER_LABELS: Record = { + manual: "Started by an operator", + scheduled: "Scheduled", +} + +const RECONCILIATION_STRATEGY_LABELS: Record = { + apple_notification_history: "App Store · notification history", + apple_transaction_history: "App Store · transaction history", + google_token_requery: "Google Play · re-query known purchases", +} + +const REPLAY_KIND_LABELS: Record = { + replay: "Replay", + revalidation: "Revalidation", +} + +const REPLAY_COMPARISON_LABELS: Record = { + conflicting: "Conflicting with recorded facts", + identical: "Identical to the recorded result", + new_facts: "New facts recorded", + still_failing: "Still failing", +} + +const REPLAY_COMPARISON_EXPLANATIONS: Record = { + conflicting: + "At least one input produced a result that contradicts the fact already on record. Both are retained and nothing was overwritten; each conflict also opens a quarantine record.", + identical: + "Every input recomputed the same fact digest, so nothing was written. This is the expected outcome of a replay against unchanged mappings.", + new_facts: + "The store answered with something Mosaic had not recorded before, so new facts were appended beside the existing ones.", + still_failing: + "The store still could not confirm these inputs. Every attempt is preserved, and permanently failing inputs stay quarantined.", +} + +const CREDENTIAL_STATUS_LABELS: Record = { + active: "Active", + revoked: "Revoked", +} + +function humanize(value: string) { + const spaced = value.replaceAll("_", " ") + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +export function providerLabel(value: string | undefined) { + if (!value) return "Unknown provider" + return PROVIDER_LABELS[value as BillingProvider] ?? humanize(value) +} + +export function storeEnvironmentLabel(value: string | undefined) { + if (!value) return "Unclassified" + return STORE_ENVIRONMENT_LABELS[value] ?? humanize(value) +} + +export function validationOutcomeLabel(value: string | undefined) { + if (!value) return "Pending" + return VALIDATION_OUTCOME_LABELS[value as ValidationOutcome] ?? humanize(value) +} + +export function resolutionStateLabel(value: string | undefined) { + if (!value) return "Unresolved" + return RESOLUTION_STATE_LABELS[value as ResolutionState] ?? humanize(value) +} + +export function resolutionStateExplanation(value: string | undefined) { + if (!value) return RESOLUTION_STATE_EXPLANATIONS.unresolved + return RESOLUTION_STATE_EXPLANATIONS[value as ResolutionState] ?? humanize(value) +} + +export function transactionTypeLabel(value: string | undefined) { + if (!value) return "Unclassified" + return TRANSACTION_TYPE_LABELS[value] ?? humanize(value) +} + +export function factKindLabel(value: string | undefined) { + return value ? humanize(value) : "Unclassified" +} + +export function ledgerEntryTypeLabel(value: string | undefined) { + return value ? humanize(value) : "Unclassified" +} + +export function quarantineReasonLabel(value: string | undefined) { + if (!value) return "Unclassified reason" + return QUARANTINE_REASON_LABELS[value as QuarantineReasonCode] ?? humanize(value) +} + +export function quarantineReasonExplanation(value: string | undefined) { + if (!value) return "Mosaic held this input because it could not safely proceed." + return ( + QUARANTINE_REASON_EXPLANATIONS[value as QuarantineReasonCode] ?? + "Mosaic held this input because it could not safely proceed." + ) +} + +export function quarantineSeverityLabel(value: string | undefined) { + if (!value) return "Warning" + return QUARANTINE_SEVERITY_LABELS[value] ?? humanize(value) +} + +/** Shared by reconciliation runs and replay jobs: both are worker-queue jobs. */ +export function runStatusLabel(value: string | undefined) { + if (!value) return "Queued" + return RUN_STATUS_LABELS[value] ?? humanize(value) +} + +export function runTriggerLabel(value: string | undefined) { + if (!value) return "—" + return RUN_TRIGGER_LABELS[value] ?? humanize(value) +} + +export function reconciliationStrategyLabel(value: string | undefined) { + if (!value) return "—" + return RECONCILIATION_STRATEGY_LABELS[value] ?? humanize(value) +} + +export function replayKindLabel(value: string | undefined) { + if (!value) return "Replay" + return REPLAY_KIND_LABELS[value] ?? humanize(value) +} + +export function replayComparisonLabel(value: string | undefined) { + if (!value) return "Not compared yet" + return REPLAY_COMPARISON_LABELS[value] ?? humanize(value) +} + +export function replayComparisonExplanation(value: string | undefined) { + if (!value) { + return "Mosaic has not finished re-running these inputs. Every attempt it makes is appended; nothing already recorded changes." + } + return ( + REPLAY_COMPARISON_EXPLANATIONS[value] ?? + "Every attempt is appended; nothing already recorded changes." + ) +} + +export function credentialStatusLabel(value: string | undefined) { + if (!value) return "—" + return CREDENTIAL_STATUS_LABELS[value] ?? humanize(value) +} + +export function quarantineStatusLabel(value: string | undefined) { + if (!value) return "Open" + return QUARANTINE_STATUS_LABELS[value as QuarantineStatus] ?? humanize(value) +} + +/** + * Renders a timestamp as UTC with the raw ISO value available for `title`. + * Billing correctness arguments are made in UTC, so the view never localises. + */ +export function formatBillingTimestamp(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` +} + +export function formatDurationSeconds(seconds: number | undefined) { + if (seconds === undefined || !Number.isFinite(seconds)) return "—" + if (seconds < 90) return `${Math.round(seconds)}s` + if (seconds < 5400) return `${Math.round(seconds / 60)} min` + return `${Math.round(seconds / 3600)} h` +} diff --git a/apps/dashboard/src/features/billing-ledger/types/replay-comparison.test.ts b/apps/dashboard/src/features/billing-ledger/types/replay-comparison.test.ts new file mode 100644 index 00000000..36395f96 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/types/replay-comparison.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest" + +import { + compareReplayAttempts, + selectComparableAttempts, +} from "@/features/billing-ledger/types/replay-comparison" +import type { TransactionFact, ValidationAttempt } from "@/generated/api" + +/** + * Risk: a replay silently replaces or hides the earlier Validation Attempt, or + * a contradicting result is presented as a plain update. Either destroys the + * audit history the ledger exists to hold, and the second one lets an operator + * act on a changed answer without knowing it changed. + */ + +function attempt(overrides: Partial): ValidationAttempt { + return { + attemptNumber: 1, + id: "attempt_1", + outcome: "validated", + startedAt: "2026-07-01T00:00:00Z", + storeEnvironment: "production", + validatorVersion: 1, + ...overrides, + } +} + +describe("replay comparison", () => { + it("keeps both attempts, whichever order the caller supplies", () => { + const earlier = attempt({ attemptNumber: 4, id: "attempt_4", outcome: "quarantined" }) + const later = attempt({ attemptNumber: 5, id: "attempt_5", outcome: "validated" }) + + const forwards = compareReplayAttempts({ attempt: earlier }, { attempt: later }) + const backwards = compareReplayAttempts({ attempt: later }, { attempt: earlier }) + + expect(forwards.earlierAttemptNumber).toBe(4) + expect(forwards.latestAttemptNumber).toBe(5) + // Supplying the pair in the other order must not relabel history. + expect(backwards.earlierAttemptNumber).toBe(4) + expect(backwards.latestAttemptNumber).toBe(5) + + const outcomeRow = forwards.rows.find((row) => row.label === "Outcome") + expect(outcomeRow?.earlier).toBe("Quarantined") + expect(outcomeRow?.latest).toBe("Validated") + }) + + it("flags a conflict when the outcome or the resolved Product changes", () => { + const earlier = attempt({ attemptNumber: 1, id: "attempt_1", outcome: "validated" }) + const later = attempt({ attemptNumber: 2, id: "attempt_2", outcome: "quarantined" }) + + const outcomeConflict = compareReplayAttempts({ attempt: earlier }, { attempt: later }) + expect(outcomeConflict.hasConflict).toBe(true) + expect(outcomeConflict.conflicts).toContain("outcome_changed") + + const productConflict = compareReplayAttempts( + { attempt: earlier, fact: { mosaicProductId: "prod_a" } as TransactionFact }, + { + attempt: attempt({ attemptNumber: 2, id: "attempt_2" }), + fact: { mosaicProductId: "prod_b" } as TransactionFact, + }, + ) + expect(productConflict.conflicts).toEqual(["resolved_product_changed"]) + }) + + it("reports no conflict, and marks fields unchanged, when the replay reproduces the result", () => { + const fact = { mosaicProductId: "prod_a", resolutionState: "active_mapping" } as TransactionFact + const comparison = compareReplayAttempts( + { attempt: attempt({ attemptNumber: 1, id: "attempt_1" }), fact }, + { attempt: attempt({ attemptNumber: 2, id: "attempt_2" }), fact }, + ) + + expect(comparison.hasConflict).toBe(false) + expect(comparison.rows.find((row) => row.label === "Outcome")?.changed).toBe(false) + expect(comparison.rows.find((row) => row.label === "Resolved Mosaic Product")?.changed).toBe( + false, + ) + }) + + it("compares the newest attempt against the one before it and leaves older ones intact", () => { + const attempts = [ + attempt({ attemptNumber: 1, id: "attempt_1" }), + attempt({ attemptNumber: 3, id: "attempt_3" }), + attempt({ attemptNumber: 2, id: "attempt_2" }), + ] + + const pair = selectComparableAttempts(attempts) + + expect(pair?.[0].attempt.id).toBe("attempt_2") + expect(pair?.[1].attempt.id).toBe("attempt_3") + // The input array is the attempt history and must not be reordered in place. + expect(attempts.map((item) => item.id)).toEqual(["attempt_1", "attempt_3", "attempt_2"]) + }) + + it("produces no comparison from a single attempt rather than inventing a baseline", () => { + expect(selectComparableAttempts([attempt({})])).toBeUndefined() + }) +}) diff --git a/apps/dashboard/src/features/billing-ledger/types/replay-comparison.ts b/apps/dashboard/src/features/billing-ledger/types/replay-comparison.ts new file mode 100644 index 00000000..e2b1ec30 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/types/replay-comparison.ts @@ -0,0 +1,199 @@ +import type { TransactionFact, ValidationAttempt } from "@/generated/api" +import { + formatBillingTimestamp, + resolutionStateLabel, + storeEnvironmentLabel, + validationOutcomeLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" + +/** + * Replay comparison. + * + * Replay appends a Validation Attempt; it never rewrites one. This module turns + * two attempts (and the facts they produced, when the store answered) into a + * two-column, append-only comparison. There is deliberately no operation here + * that merges, chooses, supersedes, or discards a column: an "apply the new + * result" affordance cannot be built from this shape. + */ + +export interface ReplayComparisonRow { + changed: boolean + earlier: string + label: string + latest: string +} + +export type ReplayConflictKind = + "outcome_changed" | "resolved_product_changed" | "store_environment_changed" + +export interface ReplayComparison { + /** Both attempts are always present in the result. */ + earlierAttemptNumber?: number + conflicts: readonly ReplayConflictKind[] + hasConflict: boolean + latestAttemptNumber?: number + rows: readonly ReplayComparisonRow[] +} + +export interface ReplayComparisonInput { + attempt: ValidationAttempt + fact?: TransactionFact +} + +const CONFLICT_EXPLANATIONS: Record = { + outcome_changed: + "The store answered differently this time. Both attempts are retained and neither result was overwritten.", + resolved_product_changed: + "The same transaction now resolves to a different Mosaic Product. Review the Product mapping history before relying on either resolution.", + store_environment_changed: + "The Store Environment differs between attempts. Sandbox and production facts must never be treated as one series.", +} + +export function describeReplayConflict(kind: ReplayConflictKind) { + return CONFLICT_EXPLANATIONS[kind] +} + +/** + * Orders two attempts by attempt number so a caller cannot mislabel which one + * is the history and which is the replay. Ties fall back to `startedAt`, and + * an unorderable pair keeps the caller's order. + */ +export function orderReplayAttempts( + a: ReplayComparisonInput, + b: ReplayComparisonInput, +): [ReplayComparisonInput, ReplayComparisonInput] { + const left = a.attempt.attemptNumber + const right = b.attempt.attemptNumber + if (typeof left === "number" && typeof right === "number" && left !== right) { + return left < right ? [a, b] : [b, a] + } + const leftStarted = Date.parse(a.attempt.startedAt ?? "") + const rightStarted = Date.parse(b.attempt.startedAt ?? "") + if (!Number.isNaN(leftStarted) && !Number.isNaN(rightStarted) && leftStarted !== rightStarted) { + return leftStarted < rightStarted ? [a, b] : [b, a] + } + return [a, b] +} + +function row(label: string, earlier: string, latest: string): ReplayComparisonRow { + return { changed: earlier !== latest, earlier, label, latest } +} + +const UNSET = "—" + +function text(value: string | number | undefined) { + return value === undefined || value === "" ? UNSET : String(value) +} + +export function compareReplayAttempts( + first: ReplayComparisonInput, + second: ReplayComparisonInput, +): ReplayComparison { + const [earlier, latest] = orderReplayAttempts(first, second) + + const rows: ReplayComparisonRow[] = [ + row("Attempt", text(earlier.attempt.attemptNumber), text(latest.attempt.attemptNumber)), + row( + "Outcome", + validationOutcomeLabel(earlier.attempt.outcome), + validationOutcomeLabel(latest.attempt.outcome), + ), + row( + "Store Environment", + storeEnvironmentLabel(earlier.attempt.storeEnvironment), + storeEnvironmentLabel(latest.attempt.storeEnvironment), + ), + row( + "Diagnostic code", + text(earlier.attempt.diagnosticCode), + text(latest.attempt.diagnosticCode), + ), + row("Store code", text(earlier.attempt.providerCode), text(latest.attempt.providerCode)), + row( + "Store HTTP status", + text(earlier.attempt.providerHttpStatus), + text(latest.attempt.providerHttpStatus), + ), + row( + "Validator version", + text(earlier.attempt.validatorVersion), + text(latest.attempt.validatorVersion), + ), + row( + "Completed at", + formatBillingTimestamp(earlier.attempt.completedAt), + formatBillingTimestamp(latest.attempt.completedAt), + ), + row( + "Resolved Mosaic Product", + text(earlier.fact?.mosaicProductId), + text(latest.fact?.mosaicProductId), + ), + row( + "Resolution", + earlier.fact ? resolutionStateLabel(earlier.fact.resolutionState) : UNSET, + latest.fact ? resolutionStateLabel(latest.fact.resolutionState) : UNSET, + ), + row( + "Mapping version", + text(earlier.fact?.resolvedMappingVersion), + text(latest.fact?.resolvedMappingVersion), + ), + ] + + const conflicts: ReplayConflictKind[] = [] + if ( + earlier.attempt.outcome !== undefined && + latest.attempt.outcome !== undefined && + earlier.attempt.outcome !== latest.attempt.outcome + ) { + conflicts.push("outcome_changed") + } + if ( + earlier.fact?.mosaicProductId !== undefined && + latest.fact?.mosaicProductId !== undefined && + earlier.fact.mosaicProductId !== latest.fact.mosaicProductId + ) { + conflicts.push("resolved_product_changed") + } + if ( + earlier.attempt.storeEnvironment !== undefined && + latest.attempt.storeEnvironment !== undefined && + earlier.attempt.storeEnvironment !== latest.attempt.storeEnvironment + ) { + conflicts.push("store_environment_changed") + } + + return { + conflicts, + ...(earlier.attempt.attemptNumber !== undefined + ? { earlierAttemptNumber: earlier.attempt.attemptNumber } + : {}), + hasConflict: conflicts.length > 0, + ...(latest.attempt.attemptNumber !== undefined + ? { latestAttemptNumber: latest.attempt.attemptNumber } + : {}), + rows, + } +} + +/** + * Picks the two attempts a comparison should show: the newest attempt against + * the newest one before it. Earlier attempts stay in the attempt history panel; + * none is ever removed. + */ +export function selectComparableAttempts( + attempts: readonly ValidationAttempt[], + factsByAttemptId: ReadonlyMap = new Map(), +): [ReplayComparisonInput, ReplayComparisonInput] | undefined { + if (attempts.length < 2) return undefined + const ordered = [...attempts].sort((a, b) => (a.attemptNumber ?? 0) - (b.attemptNumber ?? 0)) + const latest = ordered[ordered.length - 1] + const earlier = ordered[ordered.length - 2] + if (!latest || !earlier) return undefined + const withFact = (attempt: ValidationAttempt): ReplayComparisonInput => { + const fact = attempt.id ? factsByAttemptId.get(attempt.id) : undefined + return fact ? { attempt, fact } : { attempt } + } + return [withFact(earlier), withFact(latest)] +} diff --git a/apps/dashboard/src/features/billing-ledger/types/transaction-filters.test.ts b/apps/dashboard/src/features/billing-ledger/types/transaction-filters.test.ts new file mode 100644 index 00000000..7801c7d3 --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/types/transaction-filters.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest" + +import { + applyClientTransactionFilters, + DEFAULT_TRANSACTION_PAGE_SIZE, + parseTransactionFilters, +} from "@/features/billing-ledger/types/transaction-filters" +import type { TransactionFact } from "@/generated/api" + +/** + * Risk: a crafted, shared, or stale ledger URL retargets the view at another + * Mosaic Environment, silently blends sandbox and production facts, or throws + * inside `validateSearch` and replaces a recoverable page with a route error. + */ +describe("transaction ledger search-parameter validation", () => { + it("never lets the search string carry a Mosaic Environment", () => { + const filters = parseTransactionFilters({ + environmentId: "env_other_tenant", + mosaicEnvironmentId: "env_other_tenant", + storeEnvironment: "production", + }) + + // The Mosaic Environment comes from the route path and the API enforces the + // tenant boundary. Nothing Environment-shaped may survive parsing. + expect(filters).not.toHaveProperty("environmentId") + expect(filters).not.toHaveProperty("mosaicEnvironmentId") + expect(JSON.stringify(filters)).not.toContain("env_other_tenant") + // Store Environment is a different value and is kept. + expect(filters.storeEnvironment).toBe("production") + }) + + it("drops unknown and hostile values instead of throwing", () => { + const filters = parseTransactionFilters({ + cursor: { toString: () => "not-a-cursor" }, + from: "not-a-date", + limit: 100000, + provider: "stripe", + productId: "", + reference: "x".repeat(500), + resolutionState: "entitled", + storeEnvironment: "PRODUCTION", + }) + + expect(filters.provider).toBeUndefined() + expect(filters.storeEnvironment).toBeUndefined() + expect(filters.resolutionState).toBeUndefined() + expect(filters.productId).toBeUndefined() + expect(filters.reference).toBeUndefined() + expect(filters.cursor).toBeUndefined() + expect(filters.from).toBeUndefined() + expect(filters.limit).toBe(DEFAULT_TRANSACTION_PAGE_SIZE) + }) + + it("drops an inverted date range rather than returning an always-empty ledger", () => { + const inverted = parseTransactionFilters({ + from: "2026-07-01T00:00:00Z", + to: "2026-06-01T00:00:00Z", + }) + expect(inverted.from).toBeUndefined() + expect(inverted.to).toBeUndefined() + + const ordered = parseTransactionFilters({ + from: "2026-06-01T00:00:00Z", + to: "2026-07-01T00:00:00Z", + }) + expect(ordered.from).toBe("2026-06-01T00:00:00Z") + expect(ordered.to).toBe("2026-07-01T00:00:00Z") + }) + + it("keeps sandbox and production facts separate when filtering the loaded page", () => { + const facts = [ + { id: "fact_sandbox", storeEnvironment: "sandbox" }, + { id: "fact_production", storeEnvironment: "production" }, + ] as TransactionFact[] + + const filters = parseTransactionFilters({ storeEnvironment: "production" }) + const visible = applyClientTransactionFilters(facts, filters) + + expect(visible.map((fact) => fact.id)).toEqual(["fact_production"]) + }) +}) diff --git a/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts b/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts new file mode 100644 index 00000000..472581fb --- /dev/null +++ b/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts @@ -0,0 +1,158 @@ +import type { TransactionFact } from "@/generated/api" +import { + billingProviders, + resolutionStates, + storeEnvironments, + type BillingProvider, + type ResolutionState, + type StoreEnvironment, +} from "@/features/billing-ledger/types/billing-vocabulary" + +/** + * Transaction ledger filters, carried in the URL so a view is shareable and + * reloadable. + * + * Two rules drive this module. + * + * 1. **The Mosaic Environment is never a filter.** It is a route path segment + * and the tenant boundary the API enforces. Any Environment-shaped value in + * the search string is deliberately ignored, so a crafted or stale URL can + * never redirect a ledger view at another Environment's records. + * 2. **Store Environment is a filter and is a different thing.** It is what the + * store itself reported about the transaction. The two are never merged. + * + * Hostile or stale values fall back to the documented default instead of + * throwing: `validateSearch` runs before the route renders, so throwing here + * would replace a recoverable page with a route error. + */ +export interface TransactionFilters { + applicationId?: string + cursor?: string + /** Inclusive lower bound on the store-reported `occurredAt`. */ + from?: string + limit: number + productId?: string + provider?: BillingProvider + reference?: string + resolutionState?: ResolutionState + storeEnvironment?: StoreEnvironment + /** Inclusive upper bound on the store-reported `occurredAt`. */ + to?: string +} + +export const TRANSACTION_PAGE_SIZES = [25, 50, 100] as const +export const DEFAULT_TRANSACTION_PAGE_SIZE = 50 + +/** Matches the `safeProviderCode` bound the ingestion contract applies. */ +const SAFE_REFERENCE = /^[\x20-\x7E]{1,128}$/ +const IDENTIFIER = /^[A-Za-z0-9_-]{1,64}$/ + +function safeString(value: unknown, pattern: RegExp) { + return typeof value === "string" && pattern.test(value) ? value : undefined +} + +function safeTimestamp(value: unknown) { + if (typeof value !== "string" || value.length === 0 || value.length > 40) return undefined + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : value +} + +function member(value: unknown, allowed: readonly T[]) { + return typeof value === "string" && allowed.some((item) => item === value) + ? (value as T) + : undefined +} + +export function defaultTransactionFilters(): TransactionFilters { + return { limit: DEFAULT_TRANSACTION_PAGE_SIZE } +} + +export function parseTransactionFilters(search: Record): TransactionFilters { + const from = safeTimestamp(search.from) + const to = safeTimestamp(search.to) + // An inverted range would silently return nothing and read as data loss. + // Dropping both bounds returns the documented unfiltered default instead. + const orderedRange = from && to && Date.parse(from) > Date.parse(to) ? {} : { from, to } + const limit = TRANSACTION_PAGE_SIZES.find((size) => size === Number(search.limit)) + + return { + applicationId: safeString(search.applicationId, IDENTIFIER), + cursor: safeString(search.cursor, /^[\x20-\x7E]{1,512}$/), + ...orderedRange, + limit: limit ?? DEFAULT_TRANSACTION_PAGE_SIZE, + productId: safeString(search.productId, IDENTIFIER), + provider: member(search.provider, billingProviders), + reference: safeString(search.reference, SAFE_REFERENCE), + resolutionState: member(search.resolutionState, resolutionStates), + // Deliberately independent of the Mosaic Environment, which is a path + // segment and is never read from the search string. + storeEnvironment: member(search.storeEnvironment, storeEnvironments), + } +} + +/** Drops empty values so a cleared filter leaves the URL rather than sitting in it. */ +export function serializeTransactionFilters(filters: TransactionFilters): TransactionFilters { + const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== "") + return { ...(Object.fromEntries(entries) as TransactionFilters), limit: filters.limit } +} + +/** + * The subset the API accepts today. Everything else is applied client-side over + * the returned page, and the view says so rather than implying the whole ledger + * was searched. + */ +export function transactionFactsQuery(filters: TransactionFilters) { + return { + ...(filters.cursor ? { cursor: filters.cursor } : {}), + ...(filters.from ? { from: filters.from } : {}), + limit: filters.limit, + ...(filters.provider ? { provider: filters.provider } : {}), + ...(filters.to ? { to: filters.to } : {}), + } +} + +export const CLIENT_APPLIED_FILTER_KEYS = [ + "applicationId", + "productId", + "reference", + "resolutionState", + "storeEnvironment", +] as const satisfies readonly (keyof TransactionFilters)[] + +export function clientAppliedFilterCount(filters: TransactionFilters) { + return CLIENT_APPLIED_FILTER_KEYS.filter((key) => filters[key] !== undefined).length +} + +export function hasActiveTransactionFilters(filters: TransactionFilters) { + return ( + clientAppliedFilterCount(filters) > 0 || + filters.provider !== undefined || + filters.from !== undefined || + filters.to !== undefined + ) +} + +export function applyClientTransactionFilters( + items: readonly TransactionFact[], + filters: TransactionFilters, +): TransactionFact[] { + const reference = filters.reference?.toLowerCase() + return items.filter((item) => { + if (filters.applicationId && item.applicationId !== filters.applicationId) return false + if (filters.productId && item.mosaicProductId !== filters.productId) return false + if (filters.storeEnvironment && item.storeEnvironment !== filters.storeEnvironment) return false + if (filters.resolutionState && item.resolutionState !== filters.resolutionState) return false + if (reference) { + const haystack = [ + item.providerTransactionId, + item.providerOriginalTransactionId, + item.providerProductIdentifier, + ] + .filter((value): value is string => typeof value === "string") + .join(" ") + .toLowerCase() + if (!haystack.includes(reference)) return false + } + return true + }) +} 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 new file mode 100644 index 00000000..01f560b4 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/billing-health-page.tsx @@ -0,0 +1,288 @@ +import { useQuery } 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 { + BillingBoundaryNote, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { + BILLING_OPTIONAL_NOTE, + formatBillingTimestamp, + formatDurationSeconds, + providerLabel, + storeEnvironmentLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { billingHealthQueryOptions } from "@/features/billing-operations/queries/billing-health-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { storeCredentialsQueryOptions } from "@/features/store-connections/queries/store-connection-queries" +import { + storeCredentialHealthExplanation, + storeCredentialHealthLabel, + storeCredentialIsUnhealthy, +} from "@/features/store-connections/types/store-connection-view" +import { storeConnectionHref, storeConnectionsHref } from "@/lib/routing/workspace-hrefs" + +interface BillingHealthPageProps { + environmentId: string + organizationId: string + projectId: string +} + +/** + * Operational billing health. + * + * Every unhealthy signal names exactly one next step and links to it. A metric + * an operator cannot act on is worse than no metric, because it costs attention + * without buying a decision. + */ +export function BillingHealthPage({ + environmentId, + organizationId, + projectId, +}: BillingHealthPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const health = useQuery({ + ...billingHealthQueryOptions(projectId, environmentId), + enabled: scopeReady, + }) + const credentials = useQuery({ + ...storeCredentialsQueryOptions(projectId), + enabled: scopeReady, + }) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const data = health.data + const environmentCredentials = (credentials.data ?? []).filter( + (credential) => credential.environmentId === environmentId, + ) + + const error = project.error ?? environments.error ?? health.error ?? credentials.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: + project.isPending || + (scopeReady && (environments.isPending || health.isPending || credentials.isPending)), + loadingDescription: `Loading Mosaic Billing health for the ${environmentName} Mosaic Environment.`, + onRetry: () => { + void health.refetch() + void credentials.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read Mosaic Billing health.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + const connectionsHref = storeConnectionsHref({ organizationId, projectId }) ?? "#" + const backlogSeconds = data?.oldestQueuedAgeSeconds + const backlogUnhealthy = typeof backlogSeconds === "number" && backlogSeconds > 900 + const quarantineOpen = data?.openQuarantineCount ?? 0 + const unhealthyCredentials = data?.unhealthyCredentials ?? 0 + + return ( + + + + + {data?.billingEnabled === false ? ( + +

+ No store input is accepted or recorded while billing is off. Observations from SDKs + are permanently rejected so their queues drain rather than retrying forever. +

+

{BILLING_OPTIONAL_NOTE}

+ + Open Mosaic Billing setup + +
+ ) : null} + +
+ + The oldest queued input has waited {formatDurationSeconds(backlogSeconds)}. + Validation is falling behind or a worker is stalled. + + ) : undefined + } + tone={backlogUnhealthy ? "attention" : "neutral"} + value={`${data?.queueDepth ?? 0} queued`} + /> + + 0 ? "attention" : "positive"} + value={String(quarantineOpen)} + /> + 0 ? "negative" : "positive"} + value={`${unhealthyCredentials} of ${data?.credentialCount ?? 0}`} + /> +
+ +
+ + + +
+ + + {environmentCredentials.length === 0 ? ( +
+

No Store Server Credential is registered for the {environmentName} Environment.

+ + Add a Store Server Credential + +
+ ) : ( +
    + {environmentCredentials.map((credential) => ( +
  • +
    +

    {credential.name}

    + +
    +

    + {providerLabel(credential.provider)} · Store Environment{" "} + {storeEnvironmentLabel(credential.storeEnvironment)} · last tested{" "} + {formatBillingTimestamp(credential.lastTestedAt)} +

    +

    + {storeCredentialHealthExplanation(credential.healthStatus)} +

    + {storeCredentialIsUnhealthy(credential) || credential.status === "revoked" ? ( + + Test or rotate this credential + + ) : null} +
  • + ))} +
+ )} +
+ + +
    +
  • + Per-credential intake counts, the timestamp of the last accepted Store Notification, + and signature-verification failure counts are recorded as server telemetry and are not + exposed by the billing health resource. Use the deployment’s metrics for those + until the contract carries them. +
  • +
  • + A validation failure rate is not published; the queue depth, the open quarantine + count, and the attempt history on each fact are the signals available here. +
  • +
+
+
+
+ ) +} + +function Metric({ + href, + hrefLabel, + label, + recovery, + tone, + value, +}: { + href?: string + hrefLabel?: string + label: string + recovery?: ReactNode + tone: "attention" | "negative" | "neutral" | "positive" + value: string +}) { + return ( +
+

{label}

+

{value}

+ {tone !== "neutral" && tone !== "positive" ? ( +
+ +
+ ) : null} + {recovery ?

{recovery}

: null} + {href && hrefLabel ? ( + + {hrefLabel} + + ) : null} +
+ ) +} diff --git a/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx b/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx new file mode 100644 index 00000000..1af9bacf --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/create-reconciliation-run-sheet.tsx @@ -0,0 +1,296 @@ +import { useForm, useStore } from "@tanstack/react-form" +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 { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" +import { + providerLabel, + reconciliationStrategyLabel, + storeEnvironmentLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { + defaultReconciliationWindow, + describeReconciliationRangeIssue, + MAX_RECONCILIATION_WINDOW_DAYS, + toIsoInstant, + validateReconciliationRange, +} from "@/features/billing-operations/types/reconciliation-range" +import type { CreateReconciliationRunRequest, StoreServerCredential } from "@/generated/api" + +const fieldClass = + "border-input bg-background focus-visible:border-ring focus-visible:ring-ring/40 h-9 w-full rounded border px-3 text-sm outline-none focus-visible:ring-3" + +/** + * The strategies the API accepts today. + * + * `apple_transaction_history` is deliberately absent: the worker has no run + * loop for it, so offering it produced a queued run that failed later with + * `unsupported_strategy` and no explanation anywhere the operator could see. + * It stays in the stored enumeration for forward compatibility, which is why + * the read side still labels it on historical runs. + */ +type Strategy = NonNullable + +function strategiesFor(provider: string | undefined): readonly Strategy[] { + return provider === "google_play" ? ["google_token_requery"] : ["apple_notification_history"] +} + +interface CreateReconciliationRunSheetProps { + credentials: readonly StoreServerCredential[] + environmentName: string + onCreate: (request: CreateReconciliationRunRequest) => Promise + storeConnectionsHref: string +} + +/** + * Starting a reconciliation pass. + * + * The window is bounded before submission because an unbounded or inverted + * range either floods ingestion or quietly examines nothing. The ceiling is the + * contract's own limit and matches Apple's notification-history retention. + */ +export function CreateReconciliationRunSheet({ + credentials, + environmentName, + onCreate, + storeConnectionsHref, +}: CreateReconciliationRunSheetProps) { + const [open, setOpen] = useState(false) + const [submitError, setSubmitError] = useState(null) + const usable = credentials.filter((credential) => credential.status !== "revoked") + const initialWindow = defaultReconciliationWindow() + + const form = useForm({ + defaultValues: { + credentialId: usable[0]?.id ?? "", + strategy: strategiesFor(usable[0]?.provider)[0] as Strategy, + windowEnd: initialWindow.windowEnd, + windowStart: initialWindow.windowStart, + }, + onSubmit: async ({ value }) => { + setSubmitError(null) + const credential = usable.find((item) => item.id === value.credentialId) + if (!credential?.provider) { + setSubmitError("Choose a Store Server Credential to reconcile against.") + return + } + try { + await onCreate({ + credentialId: value.credentialId, + provider: credential.provider, + strategy: value.strategy, + windowEnd: toIsoInstant(value.windowEnd), + windowStart: toIsoInstant(value.windowStart), + }) + form.reset() + setOpen(false) + } catch (error) { + setSubmitError( + error instanceof Error + ? error.message + : "Mosaic could not queue this reconciliation run.", + ) + } + }, + }) + + const credentialId = useStore(form.store, (state) => state.values.credentialId) + const selected = usable.find((item) => item.id === credentialId) + + return ( + { + setOpen(nextOpen) + setSubmitError(null) + if (!nextOpen) form.reset() + }} + open={open} + > + }>Start reconciliation + + + Start a reconciliation run + + Reconciliation re-reads store history to find inputs Mosaic never received. It validates + store-confirmed facts; it does not calculate anyone’s access to your app. + + +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} + > +
+ {usable.length === 0 ? ( +
+

+ Reconciliation needs an active Store Server Credential to authenticate with. +

+ + Add a Store Server Credential + +
+ ) : null} + + + value.length === 0 ? "Choose a Store Server Credential." : undefined, + }} + > + {(field) => ( + 0}> + + Store Server Credential + + + + The credential fixes both the store and the Store Environment. The Mosaic + Environment is {environmentName} and comes from the address. + + ({ message }))} /> + + )} + + + + {(field) => ( + + Strategy + + + )} + + + { + const issues = validateReconciliationRange({ + windowEnd: form.getFieldValue("windowEnd"), + windowStart: value, + }) + return issues.length > 0 + ? describeReconciliationRangeIssue(issues[0]!) + : undefined + }, + }} + > + {(field) => ( + 0}> + Window start (local time) + 0} + id="reconciliation-start" + onChange={(event) => field.handleChange(event.currentTarget.value)} + type="datetime-local" + value={field.state.value} + /> + ({ message }))} /> + + )} + + + { + const issues = validateReconciliationRange({ + windowEnd: value, + windowStart: form.getFieldValue("windowStart"), + }) + return issues.length > 0 + ? describeReconciliationRangeIssue(issues[0]!) + : undefined + }, + }} + > + {(field) => ( + 0}> + Window end (local time) + 0} + id="reconciliation-end" + onChange={(event) => field.handleChange(event.currentTarget.value)} + type="datetime-local" + value={field.state.value} + /> + + Bounded to {MAX_RECONCILIATION_WINDOW_DAYS} days. Run consecutive windows to + cover a longer period. + + ({ message }))} /> + + )} + + + {submitError ? ( +

+ {submitError} +

+ ) : null} +
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +

+ Reconciliation only appends. Anything it discovers enters the same deduplication + pipeline as a live notification, so re-running an overlapping window records nothing + twice. +

+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx b/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx new file mode 100644 index 00000000..eb4a02ed --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/quarantine-detail-page.tsx @@ -0,0 +1,263 @@ +import { ArrowLeftIcon } from "@phosphor-icons/react/dist/ssr/ArrowLeft" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +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 { + BillingBoundaryNote, + DefinitionRow, + EnvironmentBadges, + ProviderBadge, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { ValidationAttemptsPanel } from "@/features/billing-ledger/components/validation-attempts-panel" +import { validationAttemptsQueryOptions } from "@/features/billing-ledger/queries/transaction-queries" +import { + formatBillingTimestamp, + quarantineReasonExplanation, + quarantineReasonLabel, + quarantineSeverityLabel, + quarantineStatusLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { QuarantineRecoveryActionsPanel } from "@/features/billing-operations/components/quarantine-recovery-actions" +import { + closeQuarantineSupersededMutationOptions, + retryQuarantinedInputMutationOptions, +} from "@/features/billing-operations/mutations/quarantine-mutations" +import { quarantineRecordQueryOptions } from "@/features/billing-operations/queries/quarantine-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { applicationsQueryOptions } from "@/features/projects/queries/projects-query" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { appendSearch, storeConnectionsHref } from "@/lib/routing/workspace-hrefs" + +interface QuarantineDetailPageProps { + environmentId: string + organizationId: string + projectId: string + recordId: string +} + +export function QuarantineDetailPage({ + environmentId, + organizationId, + projectId, + recordId, +}: QuarantineDetailPageProps) { + const queryClient = useQueryClient() + const access = useOrganizationAccess(organizationId) + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const record = useQuery({ + ...quarantineRecordQueryOptions(projectId, recordId), + enabled: scopeReady, + }) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const applications = useQuery({ ...applicationsQueryOptions(projectId), enabled: scopeReady }) + const rawInputId = record.data?.rawInputId ?? "" + const attempts = useQuery({ + ...validationAttemptsQueryOptions(projectId, environmentId, rawInputId), + enabled: scopeReady && rawInputId.length > 0, + }) + const retry = useMutation( + retryQuarantinedInputMutationOptions(projectId, environmentId, recordId, queryClient), + ) + const closeSuperseded = useMutation( + closeQuarantineSupersededMutationOptions(projectId, environmentId, recordId, queryClient), + ) + + const data = record.data + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + // Scoped by the API to this record's input, so the panel shows the input's + // real attempt history rather than whatever fell inside an Environment page. + const relatedAttempts = attempts.data ?? [] + const applicationName = + applications.data?.items.find((item) => item.id === data?.applicationId)?.name ?? + data?.applicationId ?? + "—" + + const error = project.error ?? record.error ?? environments.error ?? attempts.error + const state = resolveHostedQueryState({ + emptyDescription: "Return to quarantine and choose an existing record.", + emptyTitle: "Quarantine record unavailable", + error, + isEmpty: record.isSuccess && !data, + isPending: + project.isPending || + (scopeReady && + (record.isPending || + environments.isPending || + applications.isPending || + (rawInputId.length > 0 && attempts.isPending))), + loadingDescription: "Loading the quarantine record and its attempt history.", + onRetry: () => { + void record.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read quarantine records.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const projectBase = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}` + const billingBase = `${projectBase}/billing/${encodeURIComponent(environmentId)}` + + return ( + + All quarantine records + + } + description="Why this input could not proceed, everything already attempted, and the recovery actions that exist." + eyebrow="Mosaic Billing · Quarantine record" + title={quarantineReasonLabel(data?.reasonCode)} + > + + + + {data ? ( + <> +
+ + + + +
+ + +
+ + + {/* The store Product the input named. For product_unknown it is + the single value the operator has to create a mapping for. */} + + + + + + + + + + + {data.supersededByRecordId} + + ) : ( + "—" + ) + } + /> +
+ {data.status === "closed_after_success" ? ( +

+ This record closed because a later Validation Attempt succeeded against the store. + That attempt is recorded above as the justification; no operator declared the + input valid. +

+ ) : null} +
+ + + closeSuperseded.mutate({ supersededByRecordId }) + } + onRetryValidation={() => retry.mutate()} + {...(data.providerProductIdentifier + ? { providerProductIdentifier: data.providerProductIdentifier } + : {})} + // Carries the store Product identifier into the Product search and + // a return path back to this record, so the repair loop — + // quarantine → map the Product → re-run validation — can be walked + // without navigating back from memory. + productMappingHref={appendSearch(`${projectBase}/catalog/products`, { + returnTo: `${billingBase}/quarantine/${encodeURIComponent(recordId)}`, + search: data.providerProductIdentifier, + })} + record={data} + {...(retry.error ? { retryError: retry.error.message } : {})} + storeConnectionsHref={storeConnectionsHref({ organizationId, projectId }) ?? "#"} + /> + + {retry.isSuccess ? ( +

+ The input was re-queued for validation. A new Validation Attempt appears below once + the worker has asked the store; the record closes only if that attempt succeeds. +

+ ) : null} + + + + ) : null} +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx b/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx new file mode 100644 index 00000000..e6e639a0 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/quarantine-page.tsx @@ -0,0 +1,355 @@ +import { useQuery } from "@tanstack/react-query" + +import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button-variants" +import { EmptyState } from "@/components/feedback/empty-state" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { + BillingBoundaryNote, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { + formatBillingTimestamp, + providerLabel, + quarantineReasonLabel, + quarantineSeverityLabel, + quarantineStatusLabel, + storeEnvironmentLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { + quarantineRecordsQueryOptions, + type QuarantineListFilters, +} from "@/features/billing-operations/queries/quarantine-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" + +const fieldClass = + "border-input bg-background focus-visible:border-ring focus-visible:ring-ring/40 h-9 rounded border px-3 text-sm outline-none focus-visible:ring-3" + +interface QuarantinePageProps { + environmentId: string + filters: QuarantineListFilters + onFiltersChange: (filters: QuarantineListFilters) => void + organizationId: string + projectId: string +} + +export function QuarantinePage({ + environmentId, + filters, + onFiltersChange, + organizationId, + projectId, +}: QuarantinePageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const records = useQuery({ + ...quarantineRecordsQueryOptions(projectId, environmentId, filters), + enabled: scopeReady, + }) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const items = records.data?.items ?? [] + const nextCursor = records.data?.nextCursor + const { cursor: _cursor, ...activeFilters } = filters + void _cursor + const filtered = Object.values(activeFilters).some(Boolean) + + // Any filter change invalidates the cursor: a cursor is only meaningful for + // the query that produced it. + function updateFilters(patch: Partial) { + onFiltersChange({ ...activeFilters, ...patch }) + } + + const error = project.error ?? environments.error ?? records.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: project.isPending || (scopeReady && (environments.isPending || records.isPending)), + loadingDescription: `Loading quarantine records for the ${environmentName} Mosaic Environment.`, + onRetry: () => { + void records.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read quarantine records.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + + return ( + + + + +
+ + + {/* A reason-code filter can arrive from a health or ledger recovery + link. Without a visible control it would filter invisibly. */} + {filters.reasonCode ? ( + + ) : null} + +
+ {filtered ? ( + + ) : null} +
+ + + {items.length === 0 ? ( + <> + onFiltersChange({})} + type="button" + > + Clear filters + + ) : ( + + Open the transaction ledger + + ) + } + description={ + filters.cursor + ? "This page of the quarantine history is empty. Return to the first page, or continue forward." + : filtered + ? "Quarantine records exist in this Mosaic Environment, but none matches the current filters." + : "Every input Mosaic has accepted in this Mosaic Environment either produced a fact or is still being validated." + } + title={ + filtered ? "No quarantine records match these filters" : "Nothing is quarantined" + } + /> + onFiltersChange({ ...activeFilters, cursor })} + /> + + ) : ( + + + + Quarantine records in the {environmentName} Mosaic Environment. + + + + Reason + Store + Store Environment + Store Product + Severity + Status + Attempts + First seen + Last attempt + + + + {items.map((record) => ( + + + + {quarantineReasonLabel(record.reasonCode)} + + + {providerLabel(record.provider)} + {storeEnvironmentLabel(record.storeEnvironment)} + + {record.providerProductIdentifier ?? ( + + )} + + + + + {quarantineStatusLabel(record.status)} + {record.attemptCount ?? 0} + + {formatBillingTimestamp(record.firstSeenAt)} + + + {formatBillingTimestamp(record.lastAttemptAt)} + + + ))} + +
+ onFiltersChange({ ...activeFilters, cursor })} + /> +
+ )} +
+
+ ) +} + +/** + * Forward paging over the quarantine history. + * + * A page is never presented as a total. Without this control an Environment + * with more open records than one page holds would report the page size as the + * count, on the surface whose entire job is "what needs attention". + */ +function QuarantinePaging({ + 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 quarantine history.

+ )} +
+ ) +} diff --git a/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx b/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx new file mode 100644 index 00000000..c4eb4d4d --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/quarantine-recovery-actions.tsx @@ -0,0 +1,219 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { + quarantineNoActionExplanation, + quarantineRecoveryActions, + type QuarantineRecoveryAction, +} from "@/features/billing-operations/types/quarantine-recovery" +import type { QuarantineRecord } from "@/generated/api" + +interface QuarantineRecoveryActionsProps { + canManage: boolean + closeError?: string + isClosing: boolean + isRetrying: boolean + membersHref: string + onCloseSuperseded: (supersededByRecordId: string) => void + onRetryValidation: () => void + productMappingHref: string + /** The store Product this input named; absent when it never got that far. */ + providerProductIdentifier?: string + record: QuarantineRecord + retryError?: string + storeConnectionsHref: string +} + +/** + * The recovery controls for one quarantine record. + * + * The set is derived from `quarantineRecoveryActions`, not assembled here, so + * the prohibition on asserting validity lives in one testable place. Note what + * is missing and must stay missing: no "Mark as valid", "Force resolve", + * "Accept anyway", or "Ignore". The only route to a Transaction Fact is asking + * the store again. + */ +export function QuarantineRecoveryActionsPanel({ + canManage, + closeError, + isClosing, + isRetrying, + membersHref, + onCloseSuperseded, + onRetryValidation, + productMappingHref, + providerProductIdentifier, + record, + retryError, + storeConnectionsHref, +}: QuarantineRecoveryActionsProps) { + const [supersededBy, setSupersededBy] = useState("") + const actions = quarantineRecoveryActions(record) + + return ( + + {!canManage ? ( +

+ Organization owner or admin permission is required to run a recovery action.{" "} + + Ask an Owner or Admin + +

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

No recovery action applies to this record.

+

+ {quarantineNoActionExplanation(record.reasonCode)} +

+ + Review Store Server Credentials + +
+ ) : ( +
    + {actions.map((action) => ( +
  • +

    {action.label}

    +

    {action.description}

    + onCloseSuperseded(supersededBy.trim())} + onRetryValidation={onRetryValidation} + productMappingHref={productMappingHref} + {...(providerProductIdentifier ? { providerProductIdentifier } : {})} + retryError={retryError} + setSupersededBy={setSupersededBy} + storeConnectionsHref={storeConnectionsHref} + supersededBy={supersededBy} + /> +
  • + ))} +
+ )} +
+ ) +} + +function ActionControl({ + action, + closeError, + isClosing, + isRetrying, + onCloseSuperseded, + onRetryValidation, + productMappingHref, + providerProductIdentifier, + retryError, + setSupersededBy, + storeConnectionsHref, + supersededBy, +}: { + action: QuarantineRecoveryAction + closeError?: string + isClosing: boolean + isRetrying: boolean + onCloseSuperseded: () => void + onRetryValidation: () => void + productMappingHref: string + providerProductIdentifier?: string + retryError?: string + setSupersededBy: (value: string) => void + storeConnectionsHref: string + supersededBy: string +}) { + switch (action.kind) { + case "retry_provider_validation": + return ( +
+ + {retryError ? ( +

+ {retryError} +

+ ) : null} +
+ ) + case "close_superseded": + return ( +
+ + +

+ Closing asserts nothing about the original input and produces no Transaction Fact. The + record and its history stay readable. +

+ {closeError ? ( +

+ {closeError} +

+ ) : null} +
+ ) + case "repair_product_mapping": + return ( +
+ {providerProductIdentifier ? ( +

+ Map the store Product{" "} + + {providerProductIdentifier} + {" "} + to a Mosaic Product. Replacing a mapping keeps the previous one in history, so past + resolutions stay reproducible. +

+ ) : ( +

+ This input never got far enough to name a store Product, so there is nothing to map + yet. Confirm the credential and Application scope first. +

+ )} + + {providerProductIdentifier + ? "Find the Mosaic Product for this store Product" + : "Open Products"} + +

+ Mosaic brings you back to this record when you are done, so you can re-run validation + without navigating from memory. +

+
+ ) + default: + return ( + + Open Store Server Credentials + + ) + } +} diff --git a/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx b/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx new file mode 100644 index 00000000..d35eab78 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/reconciliation-page.tsx @@ -0,0 +1,295 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { EmptyState } from "@/components/feedback/empty-state" +import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button-variants" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" +import { + BillingBoundaryNote, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { + formatBillingTimestamp, + providerLabel, + runStatusLabel, + runTriggerLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { CreateReconciliationRunSheet } from "@/features/billing-operations/components/create-reconciliation-run-sheet" +import { createReconciliationRunMutationOptions } from "@/features/billing-operations/mutations/reconciliation-mutations" +import { + reconciliationRunIsTerminal, + reconciliationRunsQueryOptions, +} from "@/features/billing-operations/queries/reconciliation-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { storeCredentialsQueryOptions } from "@/features/store-connections/queries/store-connection-queries" +import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { storeConnectionsHref } from "@/lib/routing/workspace-hrefs" + +interface ReconciliationPageProps { + cursor?: string + environmentId: string + onCursorChange: (cursor: string | undefined) => void + organizationId: string + projectId: string +} + +export function ReconciliationPage({ + cursor, + environmentId, + onCursorChange, + organizationId, + projectId, +}: ReconciliationPageProps) { + const queryClient = useQueryClient() + const access = useOrganizationAccess(organizationId) + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const credentials = useQuery({ + ...storeCredentialsQueryOptions(projectId), + enabled: scopeReady, + }) + const runs = useQuery({ + ...reconciliationRunsQueryOptions(projectId, environmentId, cursor ?? ""), + enabled: scopeReady, + }) + const create = useMutation( + createReconciliationRunMutationOptions(projectId, environmentId, queryClient), + ) + + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const items = runs.data?.items ?? [] + const nextCursor = runs.data?.nextCursor + const environmentCredentials = (credentials.data ?? []).filter( + (credential) => credential.environmentId === environmentId, + ) + function credentialName(credentialId: string | undefined) { + if (!credentialId) return "—" + return environmentCredentials.find((item) => item.id === credentialId)?.name ?? credentialId + } + + const error = project.error ?? environments.error ?? credentials.error ?? runs.error + const state = resolveHostedQueryState({ + error, + isEmpty: false, + isPending: + project.isPending || + (scopeReady && (environments.isPending || credentials.isPending || runs.isPending)), + loadingDescription: `Loading reconciliation runs for the ${environmentName} Mosaic Environment.`, + onRetry: () => { + void runs.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read reconciliation runs.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + + return ( + { + await create.mutateAsync(request) + }} + storeConnectionsHref={storeConnectionsHref({ organizationId, projectId }) ?? "#"} + /> + ) : null + } + description="Reconciliation re-reads store history to find inputs Mosaic never received. It validates store-confirmed facts and does not calculate anyone's access to your app." + eyebrow="Mosaic Billing · Reconciliation" + title="Reconciliation" + > + + + {create.error ? ( +

+ {create.error.message} +

+ ) : null} + + + {items.length === 0 ? ( + <> + onCursorChange(undefined)} type="button" variant="outline"> + Back to the most recent runs + + ) : ( + + Check billing health first + + ) + } + description={ + cursor + ? "This page of the reconciliation history is empty." + : "No reconciliation pass has run in this Mosaic Environment. Scheduled passes appear here alongside any you start with Start reconciliation above." + } + title="No reconciliation runs yet" + /> + + + ) : ( + + + + Reconciliation history for the {environmentName} Mosaic Environment, newest first. + + + + Window + Store + Store Server Credential + Trigger + Status + Examined + Discovered + Duplicates + Conflicts + Failures + Completed + + + + {items.map((run) => ( + + + + {formatBillingTimestamp(run.windowStart)} + + + → {formatBillingTimestamp(run.windowEnd)} + + + {providerLabel(run.provider)} + {credentialName(run.credentialId)} + {runTriggerLabel(run.trigger)} + + + + {run.examinedCount ?? 0} + {run.discoveredCount ?? 0} + {run.duplicateCount ?? 0} + + {(run.conflictCount ?? 0) > 0 ? ( + {run.conflictCount} + ) : ( + 0 + )} + + {run.failureCount ?? 0} + + {reconciliationRunIsTerminal(run) + ? formatBillingTimestamp(run.completedAt) + : "In progress"} + + + ))} + +
+ +
+ )} +
+
+ ) +} + +/** Forward paging, so a page of runs is never presented as the whole history. */ +function ReconciliationPaging({ + 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 reconciliation history.

+ )} +
+ ) +} diff --git a/apps/dashboard/src/features/billing-operations/components/reconciliation-run-detail-page.tsx b/apps/dashboard/src/features/billing-operations/components/reconciliation-run-detail-page.tsx new file mode 100644 index 00000000..f0c02270 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/components/reconciliation-run-detail-page.tsx @@ -0,0 +1,271 @@ +import { ArrowLeftIcon } from "@phosphor-icons/react/dist/ssr/ArrowLeft" +import { useQuery } from "@tanstack/react-query" + +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 { + BillingBoundaryNote, + DefinitionRow, + EnvironmentBadges, + ProviderBadge, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome" +import { + formatBillingTimestamp, + reconciliationStrategyLabel, + runStatusLabel, + runTriggerLabel, +} from "@/features/billing-ledger/types/billing-vocabulary" +import { + reconciliationRunIsTerminal, + reconciliationRunQueryOptions, +} from "@/features/billing-operations/queries/reconciliation-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" +import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { storeCredentialsQueryOptions } from "@/features/store-connections/queries/store-connection-queries" +import { storeConnectionHref } from "@/lib/routing/workspace-hrefs" + +interface ReconciliationRunDetailPageProps { + environmentId: string + organizationId: string + projectId: string + runId: string +} + +export function ReconciliationRunDetailPage({ + environmentId, + organizationId, + projectId, + runId, +}: ReconciliationRunDetailPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) + const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) + const run = useQuery({ + ...reconciliationRunQueryOptions(projectId, environmentId, runId), + enabled: scopeReady, + }) + const credentials = useQuery({ + ...storeCredentialsQueryOptions(projectId), + enabled: scopeReady, + }) + + const data = run.data + const environmentName = + environments.data?.items.find((item) => item.id === environmentId)?.name ?? environmentId + const terminal = reconciliationRunIsTerminal(data ?? undefined) + // The run carries no Store Environment of its own; it is fixed by the + // credential the run authenticated with, so it is read from there rather + // than rendered as "Unclassified" on an operator surface. + const credential = (credentials.data ?? []).find((item) => item.id === data?.credentialId) + + const error = project.error ?? environments.error ?? run.error ?? credentials.error + const state = resolveHostedQueryState({ + emptyDescription: + "This run is no longer in the recent reconciliation history for this Mosaic Environment.", + emptyTitle: "Reconciliation run unavailable", + error, + isEmpty: run.isSuccess && !data, + isPending: + project.isPending || + (scopeReady && (environments.isPending || run.isPending || credentials.isPending)), + loadingDescription: "Loading the reconciliation run.", + onRetry: () => { + void run.refetch() + }, + permissionDescription: + "Organization owner or admin permission is required to read reconciliation runs.", + scope: { environmentId, organizationId, projectId }, + }) + + if (scopeMismatch) { + return ( + + + + ) + } + + const base = `/organizations/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/${encodeURIComponent(environmentId)}` + + return ( + + All runs + + } + description="Progress and outcome for one bounded reconciliation pass." + eyebrow="Mosaic Billing · Reconciliation run" + title={data ? `Run ${data.id ?? runId}` : "Reconciliation run"} + > + + + + {data ? ( + <> +
+ + + +
+ + {/* The worker owns progress; the view polls on a bounded interval + and stops as soon as the run reaches a terminal state. */} +
+

+ {terminal ? "Run finished" : "Run in progress"} +

+

+ {terminal + ? `Examined ${data.examinedCount ?? 0} store record(s): ${data.discoveredCount ?? 0} newly ingested, ${data.duplicateCount ?? 0} already recorded, ${data.conflictCount ?? 0} conflicting with a recorded fact, ${data.failureCount ?? 0} failed.` + : `Mosaic is walking store history for this window. Examined ${data.examinedCount ?? 0} record(s) so far. This status refreshes automatically.`} +

+ {(data.conflictCount ?? 0) > 0 ? ( +
+

+ {data.conflictCount} discovery contradicted a fact already on record +

+

+ Nothing was overwritten — both facts stand — and each conflict also opened a + quarantine record for an operator to judge. +

+ + Review the conflicts + +
+ ) : null} + {data.status === "failed" || data.status === "partial" ? ( +
+

+ The run stopped with code {data.lastErrorCode ?? "unknown_error"}. +

+

+ Recovery is a new run, not a restart of this one: the original stays as the + record of what was examined. Re-running the same window is safe — reconciliation + is idempotent, so everything discovered twice is deduplicated rather than + recorded again — and the run does not report how far through the window it got, + so covering the whole window again is also the only reliable option. +

+ + Start a new run over{" "} + {`${formatBillingTimestamp(data.windowStart)} → ${formatBillingTimestamp(data.windowEnd)}`} + +
+ ) : null} +
+ + +
+ + + + {credential?.name ?? data.credentialId} + + ) : ( + "—" + ) + } + /> + + + + {data.discoveredCount ?? 0} · open the ledger + + } + /> + + {/* Distinct from "discovered": a conflict contradicts a fact + already on record, which is what Gate 9A asks reconciliation + to detect. Both facts stand. */} + 0 ? ( + + {data.conflictCount} · open quarantine + + ) : ( + "0" + ) + } + /> + + {data.failureCount ?? 0} · open quarantine + + } + /> + + + +
+
+ + ) : null} +
+
+ ) +} diff --git a/apps/dashboard/src/features/billing-operations/mutations/quarantine-mutations.ts b/apps/dashboard/src/features/billing-operations/mutations/quarantine-mutations.ts new file mode 100644 index 00000000..6843d6e5 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/mutations/quarantine-mutations.ts @@ -0,0 +1,67 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { closeQuarantineRecordSuperseded, retryQuarantinedInput } from "@/generated/api" +import { transactionKeys } from "@/features/billing-ledger/queries/transaction-queries" +import { quarantineKeys } from "@/features/billing-operations/queries/quarantine-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * The two audited quarantine operations, and only those two. + * + * `retryQuarantinedInput` asks the store again and appends a Validation + * Attempt; the record closes only if that attempt succeeds. + * `closeQuarantineRecordSuperseded` closes bookkeeping and asserts nothing + * about the original input. There is no third mutation, because the API + * exposes no endpoint that marks a quarantined input valid. + */ +async function invalidateQuarantine( + queryClient: QueryClient, + projectId: string, + environmentId: string, + recordId: string, +) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: quarantineKeys.scope(projectId) }), + queryClient.invalidateQueries({ queryKey: quarantineKeys.detail(projectId, recordId) }), + queryClient.invalidateQueries({ queryKey: transactionKeys.scope(projectId, environmentId) }), + ]) +} + +export function retryQuarantinedInputMutationOptions( + projectId: string, + environmentId: string, + recordId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async () => { + const result = await retryQuarantinedInput({ + client: generatedDashboardClient, + path: { projectId, recordId }, + throwOnError: true, + }) + return result.data.data + }, + onSettled: async () => invalidateQuarantine(queryClient, projectId, environmentId, recordId), + }) +} + +export function closeQuarantineSupersededMutationOptions( + projectId: string, + environmentId: string, + recordId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ({ supersededByRecordId }: { supersededByRecordId: string }) => { + const result = await closeQuarantineRecordSuperseded({ + body: { supersededByRecordId }, + client: generatedDashboardClient, + path: { projectId, recordId }, + throwOnError: true, + }) + return result.data.data + }, + onSettled: async () => invalidateQuarantine(queryClient, projectId, environmentId, recordId), + }) +} diff --git a/apps/dashboard/src/features/billing-operations/mutations/reconciliation-mutations.ts b/apps/dashboard/src/features/billing-operations/mutations/reconciliation-mutations.ts new file mode 100644 index 00000000..68fb186a --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/mutations/reconciliation-mutations.ts @@ -0,0 +1,35 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query" + +import { createReconciliationRun, type CreateReconciliationRunRequest } from "@/generated/api" +import { reconciliationKeys } from "@/features/billing-operations/queries/reconciliation-queries" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +/** + * Reconciliation is restart-safe and idempotent: discovered inputs enter the + * same deduplication pipeline as live ones. Recovery from a partial run is + * therefore a *new* run over the remaining window, never a mutation of the + * previous one — and the API offers no update or cancel operation to build one + * from. + */ +export function createReconciliationRunMutationOptions( + projectId: string, + environmentId: string, + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (body: CreateReconciliationRunRequest) => { + const result = await createReconciliationRun({ + body, + client: generatedDashboardClient, + path: { environmentId, projectId }, + throwOnError: true, + }) + return result.data.data + }, + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: reconciliationKeys.scope(projectId, environmentId), + }) + }, + }) +} diff --git a/apps/dashboard/src/features/billing-operations/queries/billing-health-queries.ts b/apps/dashboard/src/features/billing-operations/queries/billing-health-queries.ts new file mode 100644 index 00000000..4ded7d40 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/queries/billing-health-queries.ts @@ -0,0 +1,29 @@ +import { queryOptions } from "@tanstack/react-query" + +import { getBillingHealth } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export const billingHealthKeys = { + detail: (projectId: string, environmentId: string) => + ["billing-health", projectId, environmentId] as const, + scope: (projectId: string) => ["billing-health", projectId] as const, +} + +export function billingHealthQueryOptions(projectId: string, environmentId: string) { + return queryOptions({ + queryKey: billingHealthKeys.detail(projectId, environmentId), + queryFn: async ({ signal }) => { + const result = await getBillingHealth({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + // Health is an operational view of a live queue. It refreshes on a bounded + // interval so an operator watching a validation backlog is not reloading + // the page to find out whether it is draining. + refetchInterval: 30_000, + }) +} 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 new file mode 100644 index 00000000..8d957cda --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/queries/billing-list-paging.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const listBillingQuarantine = vi.fn() +const listReconciliationRuns = vi.fn() + +vi.mock("@/generated/api", () => ({ + getQuarantineRecord: vi.fn(), + listBillingQuarantine, + listReconciliationRuns, +})) + +const { quarantineRecordsQueryOptions } = + await import("@/features/billing-operations/queries/quarantine-queries") +const { reconciliationRunsQueryOptions } = + await import("@/features/billing-operations/queries/reconciliation-queries") + +/** + * Risk: a page of records is presented as the total. + * + * Both lists used to fetch a fixed page, drop the cursor, and render + * `items.length` as the count. An Environment with 60 open quarantine records + * showed 50 and said "50 quarantine record(s)" — on the surface whose entire + * job is "security-relevant inputs that need attention". Silent truncation + * reported as an exact count is a correctness problem, not a paging nicety, + * and there was no way to reach the rest. + */ +describe("billing list paging", () => { + beforeEach(() => { + listBillingQuarantine.mockReset() + listReconciliationRuns.mockReset() + }) + + it("returns the quarantine cursor instead of swallowing it", async () => { + listBillingQuarantine.mockResolvedValue({ + data: { data: { items: [{ id: "quar_1" }], nextCursor: "cursor_page_2" } }, + }) + + const options = quarantineRecordsQueryOptions("proj_1", "env_1", { status: "open" }) + const page = await options.queryFn!({ signal: new AbortController().signal } as never) + + expect(page.items).toHaveLength(1) + expect(page.nextCursor).toBe("cursor_page_2") + }) + + it("forwards a quarantine cursor and keeps each page in its own cache entry", async () => { + listBillingQuarantine.mockResolvedValue({ data: { data: { items: [] } } }) + + const options = quarantineRecordsQueryOptions("proj_1", "env_1", { + cursor: "cursor_page_2", + status: "open", + }) + await options.queryFn!({ signal: new AbortController().signal } as never) + + const [request] = listBillingQuarantine.mock.calls[0] ?? [] + expect(request.query.cursor).toBe("cursor_page_2") + expect(request.query.status).toBe("open") + + expect(options.queryKey).not.toEqual( + quarantineRecordsQueryOptions("proj_1", "env_1", { status: "open" }).queryKey, + ) + }) + + it("returns the reconciliation cursor and forwards it on the next page", async () => { + listReconciliationRuns.mockResolvedValue({ + data: { data: { items: [{ id: "run_1" }], nextCursor: "cursor_older" } }, + }) + + const first = reconciliationRunsQueryOptions("proj_1", "env_1") + const firstPage = await first.queryFn!({ signal: new AbortController().signal } as never) + expect(firstPage.nextCursor).toBe("cursor_older") + // The first page must not send an empty cursor the API would reject. + expect(listReconciliationRuns.mock.calls[0]?.[0].query.cursor).toBeUndefined() + + const second = reconciliationRunsQueryOptions("proj_1", "env_1", "cursor_older") + await second.queryFn!({ signal: new AbortController().signal } as never) + expect(listReconciliationRuns.mock.calls[1]?.[0].query.cursor).toBe("cursor_older") + expect(second.queryKey).not.toEqual(first.queryKey) + }) +}) diff --git a/apps/dashboard/src/features/billing-operations/queries/quarantine-queries.ts b/apps/dashboard/src/features/billing-operations/queries/quarantine-queries.ts new file mode 100644 index 00000000..f3efd0e2 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/queries/quarantine-queries.ts @@ -0,0 +1,66 @@ +import { queryOptions } from "@tanstack/react-query" + +import { getQuarantineRecord, listBillingQuarantine } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export interface QuarantineListFilters { + cursor?: string + provider?: "app_store" | "google_play" + reasonCode?: string + status?: "closed_after_success" | "closed_superseded" | "open" | "retrying" +} + +export const QUARANTINE_PAGE_SIZE = 50 + +export const quarantineKeys = { + detail: (projectId: string, recordId: string) => + ["billing-quarantine", projectId, "detail", recordId] as const, + list: (projectId: string, environmentId: string, filters: QuarantineListFilters) => + ["billing-quarantine", projectId, environmentId, "list", filters] as const, + scope: (projectId: string) => ["billing-quarantine", projectId] as const, +} + +/** + * One page of quarantine records, plus the cursor for the next. + * + * The cursor is returned rather than swallowed: a page of 50 presented as a + * total is a correctness problem on the surface whose job is "inputs that need + * attention". An Environment with 60 open records must not report 50. + */ +export function quarantineRecordsQueryOptions( + projectId: string, + environmentId: string, + filters: QuarantineListFilters = {}, +) { + return queryOptions({ + queryKey: quarantineKeys.list(projectId, environmentId, filters), + queryFn: async ({ signal }) => { + const result = await listBillingQuarantine({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: QUARANTINE_PAGE_SIZE, ...filters }, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + }) +} + +export function quarantineRecordQueryOptions(projectId: string, recordId: string) { + return queryOptions({ + queryKey: quarantineKeys.detail(projectId, recordId), + queryFn: async ({ signal }) => { + const result = await getQuarantineRecord({ + client: generatedDashboardClient, + path: { projectId, recordId }, + signal, + throwOnError: true, + }) + return result.data.data + }, + }) +} diff --git a/apps/dashboard/src/features/billing-operations/queries/reconciliation-queries.ts b/apps/dashboard/src/features/billing-operations/queries/reconciliation-queries.ts new file mode 100644 index 00000000..550294e2 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/queries/reconciliation-queries.ts @@ -0,0 +1,87 @@ +import { queryOptions } from "@tanstack/react-query" + +import { listReconciliationRuns, type ReconciliationRun } from "@/generated/api" +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" + +export const reconciliationKeys = { + detail: (projectId: string, environmentId: string, runId: string) => + ["billing-reconciliation", projectId, environmentId, "detail", runId] as const, + list: (projectId: string, environmentId: string, cursor: string) => + ["billing-reconciliation", projectId, environmentId, "list", cursor] as const, + scope: (projectId: string, environmentId: string) => + ["billing-reconciliation", projectId, environmentId] as const, +} + +export const RECONCILIATION_PAGE_SIZE = 25 + +const TERMINAL_STATUSES: readonly ReconciliationRun["status"][] = ["completed", "failed", "partial"] + +export function reconciliationRunIsTerminal(run: Pick | undefined) { + return run ? TERMINAL_STATUSES.includes(run.status) : false +} + +export function reconciliationRunsQueryOptions( + projectId: string, + environmentId: string, + cursor = "", +) { + return queryOptions({ + queryKey: reconciliationKeys.list(projectId, environmentId, cursor), + queryFn: async ({ signal }) => { + const result = await listReconciliationRuns({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: RECONCILIATION_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, + signal, + throwOnError: true, + }) + return { + items: result.data.data?.items ?? [], + nextCursor: result.data.data?.nextCursor, + } + }, + // Bounded polling while a run is in flight, and none once every run has + // reached a terminal state. + refetchInterval: (query) => + (query.state.data?.items ?? []).some((run) => !reconciliationRunIsTerminal(run)) + ? 5000 + : false, + }) +} + +/** + * The contract exposes no `GET .../reconciliation-runs/{runId}`, so a run is + * resolved by walking the bounded run list. A run older than the walk renders + * an explicit "no longer in the recent history" state. + */ +const RUN_LOOKUP_MAX_PAGES = 5 +const RUN_LOOKUP_PAGE_SIZE = 100 + +export function reconciliationRunQueryOptions( + projectId: string, + environmentId: string, + runId: string, +) { + return queryOptions({ + queryKey: reconciliationKeys.detail(projectId, environmentId, runId), + queryFn: async ({ signal }): Promise => { + let cursor: string | undefined + for (let page = 0; page < RUN_LOOKUP_MAX_PAGES; page += 1) { + const result = await listReconciliationRuns({ + client: generatedDashboardClient, + path: { environmentId, projectId }, + query: { limit: RUN_LOOKUP_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, + signal, + throwOnError: true, + }) + const match = result.data.data?.items?.find((item) => item.id === runId) + if (match) return match + cursor = result.data.data?.nextCursor + if (!cursor) break + } + return null + }, + refetchInterval: (query) => + query.state.data && !reconciliationRunIsTerminal(query.state.data) ? 5000 : false, + }) +} diff --git a/apps/dashboard/src/features/billing-operations/types/quarantine-recovery.test.ts b/apps/dashboard/src/features/billing-operations/types/quarantine-recovery.test.ts new file mode 100644 index 00000000..ef4435ca --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/types/quarantine-recovery.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest" + +import { + QUARANTINE_RECOVERY_ACTION_KINDS, + quarantineRecoveryActions, +} from "@/features/billing-operations/types/quarantine-recovery" +import type { QuarantineRecord } from "@/generated/api" + +/** + * Risk: a future edit adds a "mark as valid", "force resolve", or "accept + * anyway" path, which would record a validated Transaction Fact from an + * operator's judgement rather than a store round-trip. That is the single + * data-integrity rule Phase 9A is built around, and this module is the only + * place the offered action set is derived. + */ + +const REASON_CODES = [ + "application_mismatch", + "credential_revoked", + "credential_unavailable", + "cross_environment_mismatch", + "environment_mismatch", + "input_content_conflict", + "malformed_reference", + "missing_validation_credential", + "product_ambiguous", + "product_unknown", + "provider_permanently_failed", + "replay_conflict", + "signature_invalid", + "store_environment_mismatch", + "unsupported_product_type", + "unsupported_transaction_type", + "validation_exhausted", +] as const satisfies readonly NonNullable[] + +const STATUSES = [ + "open", + "retrying", + "closed_after_success", + "closed_superseded", +] as const satisfies readonly NonNullable[] + +/** Wording that would signal an operator declaring an input authentic. */ +const ASSERTION_LANGUAGE = /mark|accept|force|ignore|override|approve|declare|trust/i + +const every = REASON_CODES.flatMap((reasonCode) => + STATUSES.map((status) => ({ reasonCode, status })), +) + +describe("quarantine recovery actions", () => { + it("offers no action that asserts an input is valid", () => { + for (const record of every) { + for (const action of quarantineRecoveryActions(record)) { + expect(QUARANTINE_RECOVERY_ACTION_KINDS).toContain(action.kind) + expect(action.kind).not.toMatch(ASSERTION_LANGUAGE) + expect(action.label).not.toMatch(ASSERTION_LANGUAGE) + } + } + }) + + it("routes every mutating action through one of the two audited operations", () => { + for (const record of every) { + for (const action of quarantineRecoveryActions(record)) { + if (action.operation === undefined) continue + expect(["closeQuarantineRecordSuperseded", "retryQuarantinedInput"]).toContain( + action.operation, + ) + } + } + }) + + it("makes asking the store again the only path that can produce a fact", () => { + const storeActions = every + .flatMap((record) => quarantineRecoveryActions(record)) + .filter((action) => action.consultsStore) + + expect(storeActions.length).toBeGreaterThan(0) + for (const action of storeActions) { + expect(action.kind).toBe("retry_provider_validation") + expect(action.operation).toBe("retryQuarantinedInput") + } + }) + + it("offers nothing on a closed record, so a closure cannot be reversed by assertion", () => { + for (const reasonCode of REASON_CODES) { + expect(quarantineRecoveryActions({ reasonCode, status: "closed_after_success" })).toEqual([]) + expect(quarantineRecoveryActions({ reasonCode, status: "closed_superseded" })).toEqual([]) + } + }) + + it("still offers a store round-trip for the reasons an operator can actually repair", () => { + const kinds = quarantineRecoveryActions({ + reasonCode: "product_unknown", + status: "open", + }).map((action) => action.kind) + + expect(kinds).toContain("repair_product_mapping") + expect(kinds).toContain("retry_provider_validation") + }) +}) diff --git a/apps/dashboard/src/features/billing-operations/types/quarantine-recovery.ts b/apps/dashboard/src/features/billing-operations/types/quarantine-recovery.ts new file mode 100644 index 00000000..57e98f17 --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/types/quarantine-recovery.ts @@ -0,0 +1,155 @@ +import type { QuarantineRecord } from "@/generated/api" + +/** + * Quarantine recovery. + * + * A quarantined input is evidence that something the store confirmed could not + * safely proceed. The one way out that can end in a Transaction Fact is asking + * the store again. Nothing an operator can click may assert that an input is + * valid. + * + * That prohibition lives here, in one pure module, for two reasons: the rule is + * a domain rule rather than a rendering detail, and a single derivation is the + * only place a future edit could reintroduce a force-accept path. Note what is + * structurally absent: + * + * - No action kind names an operator assertion of validity. + * - `QuarantineRecoveryOperation` is a closed union of the two audited REST + * operations that exist. There is no endpoint that marks an input valid, so + * no action can reference one. + * - `closeSuperseded` carries `assertsAuthenticity: false`; it closes + * bookkeeping and produces no fact. + */ + +export const QUARANTINE_RECOVERY_ACTION_KINDS = [ + "repair_product_mapping", + "retry_provider_validation", + "close_superseded", + "replace_store_credential", + "review_application_scope", + "review_environment_scope", +] as const + +export type QuarantineRecoveryActionKind = (typeof QUARANTINE_RECOVERY_ACTION_KINDS)[number] + +/** The complete set of audited REST operations quarantine recovery may call. */ +export type QuarantineRecoveryOperation = + "closeQuarantineRecordSuperseded" | "retryQuarantinedInput" + +export interface QuarantineRecoveryAction { + /** + * True only when the store is consulted again. Mosaic never records a + * Transaction Fact from an operator's assertion, so this is the only route to + * one. + */ + consultsStore: boolean + description: string + kind: QuarantineRecoveryActionKind + label: string + /** Absent for navigational guidance that changes nothing. */ + operation?: QuarantineRecoveryOperation +} + +const REPAIR_MAPPING: QuarantineRecoveryAction = { + consultsStore: false, + description: + "Open the Mosaic Product this store Product should map to. Replacing a mapping keeps the previous one in history, so past resolutions stay reproducible.", + kind: "repair_product_mapping", + label: "Repair Product mapping", +} + +const RETRY_VALIDATION: QuarantineRecoveryAction = { + consultsStore: true, + description: + "Re-queue this input for validation. Mosaic asks the store again and appends a new Validation Attempt; the record closes only if that attempt succeeds, and the attempt is recorded as the justification.", + kind: "retry_provider_validation", + label: "Re-run validation", + operation: "retryQuarantinedInput", +} + +const CLOSE_SUPERSEDED: QuarantineRecoveryAction = { + consultsStore: false, + description: + "Close this record because a later record replaced it. This asserts nothing about the original input's authenticity and produces no Transaction Fact.", + kind: "close_superseded", + label: "Close as superseded", + operation: "closeQuarantineRecordSuperseded", +} + +const REPLACE_CREDENTIAL: QuarantineRecoveryAction = { + consultsStore: false, + description: + "This input cannot be validated until a working Store Server Credential exists for its Store Environment. Rotate or add one, then re-run validation.", + kind: "replace_store_credential", + label: "Review Store Server Credentials", +} + +const REVIEW_APPLICATION_SCOPE: QuarantineRecoveryAction = { + consultsStore: false, + description: + "The verified bundle or package identifier is outside this credential's Application scope. Correct the scope on the credential rather than attributing the input by hand.", + kind: "review_application_scope", + label: "Review Application scope", +} + +const REVIEW_ENVIRONMENT_SCOPE: QuarantineRecoveryAction = { + consultsStore: false, + description: + "Mosaic Environment and Store Environment must agree with the credential this input arrived on. Sandbox and production are always separate connections.", + kind: "review_environment_scope", + label: "Review Environment alignment", +} + +type ReasonCode = NonNullable + +const ACTIONS_BY_REASON: Record = { + application_mismatch: [REVIEW_APPLICATION_SCOPE, RETRY_VALIDATION], + credential_revoked: [REPLACE_CREDENTIAL], + credential_unavailable: [REPLACE_CREDENTIAL, RETRY_VALIDATION], + cross_environment_mismatch: [REVIEW_ENVIRONMENT_SCOPE, REPAIR_MAPPING], + environment_mismatch: [REVIEW_ENVIRONMENT_SCOPE], + // A key collision with different content is a security-severity event. The + // original record is never overwritten, and nothing here can declare the + // newcomer legitimate. + input_content_conflict: [CLOSE_SUPERSEDED], + malformed_reference: [CLOSE_SUPERSEDED], + missing_validation_credential: [REPLACE_CREDENTIAL, RETRY_VALIDATION], + product_ambiguous: [REPAIR_MAPPING, RETRY_VALIDATION], + product_unknown: [REPAIR_MAPPING, RETRY_VALIDATION], + provider_permanently_failed: [CLOSE_SUPERSEDED], + replay_conflict: [REPAIR_MAPPING, CLOSE_SUPERSEDED], + signature_invalid: [], + store_environment_mismatch: [REVIEW_ENVIRONMENT_SCOPE, REPLACE_CREDENTIAL], + unsupported_product_type: [CLOSE_SUPERSEDED], + unsupported_transaction_type: [CLOSE_SUPERSEDED], + validation_exhausted: [RETRY_VALIDATION, CLOSE_SUPERSEDED], +} + +/** + * Copy for the reasons that offer no action, so the view renders an explanation + * instead of a disabled control with no stated cause. + */ +const NO_ACTION_EXPLANATIONS: Partial> = { + signature_invalid: + "A payload that fails signature verification is never accepted by retrying. Treat it as a possible forged or misdirected delivery: confirm the endpoint configured at the store, and rotate the intake token if the endpoint may have leaked.", +} + +export function quarantineNoActionExplanation(reasonCode: string | undefined) { + if (reasonCode && reasonCode in NO_ACTION_EXPLANATIONS) { + return NO_ACTION_EXPLANATIONS[reasonCode as ReasonCode] + } + return "This record is closed. Its history is retained as evidence and cannot be edited." +} + +/** + * The recovery actions Mosaic offers for one record. Closed records offer none: + * reopening a closed record by asserting an outcome is exactly the path this + * phase excludes. + */ +export function quarantineRecoveryActions( + record: Pick, +): readonly QuarantineRecoveryAction[] { + if (record.status === "closed_after_success" || record.status === "closed_superseded") return [] + if (!record.reasonCode) return [] + return ACTIONS_BY_REASON[record.reasonCode] ?? [] +} diff --git a/apps/dashboard/src/features/billing-operations/types/reconciliation-range.test.ts b/apps/dashboard/src/features/billing-operations/types/reconciliation-range.test.ts new file mode 100644 index 00000000..1841ac4c --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/types/reconciliation-range.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest" + +import { + MAX_RECONCILIATION_WINDOW_DAYS, + validateReconciliationRange, +} from "@/features/billing-operations/types/reconciliation-range" + +/** + * Risk: an unbounded, inverted, or future reconciliation window is submitted. A + * too-large window floods ingestion and burns store API quota; an inverted or + * future one examines nothing while appearing to run. + */ + +const NOW = new Date("2026-07-28T12:00:00Z") + +function days(count: number) { + return new Date(NOW.getTime() - count * 24 * 60 * 60 * 1000).toISOString() +} + +describe("reconciliation window", () => { + it("accepts a bounded past window", () => { + expect( + validateReconciliationRange({ now: NOW, windowEnd: days(0), windowStart: days(7) }), + ).toEqual([]) + }) + + it("rejects an inverted window", () => { + expect( + validateReconciliationRange({ now: NOW, windowEnd: days(7), windowStart: days(0) }), + ).toContain("start_not_before_end") + }) + + it("rejects a zero-length window", () => { + expect( + validateReconciliationRange({ now: NOW, windowEnd: days(3), windowStart: days(3) }), + ).toContain("start_not_before_end") + }) + + it("rejects a window longer than the store's own history retention", () => { + expect( + validateReconciliationRange({ + now: NOW, + windowEnd: days(0), + windowStart: days(MAX_RECONCILIATION_WINDOW_DAYS + 1), + }), + ).toContain("window_too_long") + + expect( + validateReconciliationRange({ + now: NOW, + windowEnd: days(0), + windowStart: days(MAX_RECONCILIATION_WINDOW_DAYS), + }), + ).toEqual([]) + }) + + it("rejects a window that ends in the future", () => { + expect( + validateReconciliationRange({ now: NOW, windowEnd: days(-2), windowStart: days(3) }), + ).toContain("end_in_future") + }) + + it("rejects missing and unparseable bounds without throwing", () => { + expect(validateReconciliationRange({ now: NOW, windowEnd: days(0), windowStart: "" })).toEqual([ + "start_required", + ]) + expect( + validateReconciliationRange({ now: NOW, windowEnd: "tomorrow", windowStart: "yesterday" }), + ).toEqual(["invalid_timestamp"]) + }) +}) diff --git a/apps/dashboard/src/features/billing-operations/types/reconciliation-range.ts b/apps/dashboard/src/features/billing-operations/types/reconciliation-range.ts new file mode 100644 index 00000000..1828e9ed --- /dev/null +++ b/apps/dashboard/src/features/billing-operations/types/reconciliation-range.ts @@ -0,0 +1,82 @@ +/** + * Reconciliation window rules. + * + * A reconciliation run walks store history. An unbounded, inverted, or future + * window either floods ingestion or silently examines nothing, so the window is + * validated before submission rather than being discovered as a 422. + * + * The 180-day ceiling is the contract's own limit (it matches Apple's + * notification-history retention). The API does not publish a machine-readable + * limits payload, so the value is named here and cited rather than duplicated + * as a bare number at a call site. + */ + +export const MAX_RECONCILIATION_WINDOW_DAYS = 180 + +const DAY_MS = 24 * 60 * 60 * 1000 +/** Tolerates clock skew between the operator's browser and the API. */ +const FUTURE_SKEW_MS = 5 * 60 * 1000 + +export type ReconciliationRangeIssue = + | "end_in_future" + | "end_required" + | "invalid_timestamp" + | "start_not_before_end" + | "start_required" + | "window_too_long" + +const ISSUE_MESSAGES: Record = { + end_in_future: "The window must end now or in the past. Stores have no history to reconcile yet.", + end_required: "Choose when the reconciliation window ends.", + invalid_timestamp: "Enter both bounds as complete dates and times.", + start_not_before_end: "The window must start before it ends.", + start_required: "Choose when the reconciliation window starts.", + window_too_long: `A reconciliation window may not exceed ${MAX_RECONCILIATION_WINDOW_DAYS} days, which is the store's own notification-history retention. Run consecutive windows instead.`, +} + +export function describeReconciliationRangeIssue(issue: ReconciliationRangeIssue) { + return ISSUE_MESSAGES[issue] +} + +export interface ReconciliationRangeInput { + now?: Date + windowEnd: string + windowStart: string +} + +export function validateReconciliationRange( + input: ReconciliationRangeInput, +): readonly ReconciliationRangeIssue[] { + const issues: ReconciliationRangeIssue[] = [] + if (!input.windowStart) issues.push("start_required") + if (!input.windowEnd) issues.push("end_required") + if (issues.length > 0) return issues + + const start = Date.parse(input.windowStart) + const end = Date.parse(input.windowEnd) + if (Number.isNaN(start) || Number.isNaN(end)) return ["invalid_timestamp"] + + if (start >= end) issues.push("start_not_before_end") + if (end - start > MAX_RECONCILIATION_WINDOW_DAYS * DAY_MS) issues.push("window_too_long") + if (end > (input.now?.getTime() ?? Date.now()) + FUTURE_SKEW_MS) issues.push("end_in_future") + + return issues +} + +/** Converts a `datetime-local` control value to the UTC instant the API takes. */ +export function toIsoInstant(localValue: string) { + const parsed = Date.parse(localValue) + return Number.isNaN(parsed) ? "" : new Date(parsed).toISOString() +} + +/** A safe starting window: the last 7 days, well inside the ceiling. */ +export function defaultReconciliationWindow(now = new Date()) { + const end = new Date(now.getTime()) + const start = new Date(now.getTime() - 7 * DAY_MS) + return { windowEnd: toLocalInputValue(end), windowStart: toLocalInputValue(start) } +} + +function toLocalInputValue(date: Date) { + const offset = date.getTimezoneOffset() * 60 * 1000 + return new Date(date.getTime() - offset).toISOString().slice(0, 16) +} 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 11e43618..30ab0bb9 100644 --- a/apps/dashboard/src/features/catalog/components/product-detail-page.tsx +++ b/apps/dashboard/src/features/catalog/components/product-detail-page.tsx @@ -53,6 +53,7 @@ import { projectQueryOptions, } from "@/features/projects/queries/projects-query" import { useOrganizationAccess } from "@/hooks/use-organization-access" +import { describeReturnDestination } from "@/lib/routing/workspace-hrefs" interface ProductDetailPageProps { onReadinessScopeChange: (scope: { applicationId?: string; environmentId?: string }) => void @@ -284,7 +285,7 @@ export function ProductDetailPage({ {returnTo ? ( - Return to Publish review + {describeReturnDestination(returnTo)} ) : null}
diff --git a/apps/dashboard/src/features/catalog/components/products-page.tsx b/apps/dashboard/src/features/catalog/components/products-page.tsx index 19fc6dde..f10a9375 100644 --- a/apps/dashboard/src/features/catalog/components/products-page.tsx +++ b/apps/dashboard/src/features/catalog/components/products-page.tsx @@ -13,12 +13,19 @@ import { productsQueryOptions, type ProductFilters } from "@/features/catalog/qu import { WorkspacePage, WorkflowPanel } from "@/features/organizations/components/workspace-page" import { ScopeMismatchRecovery } from "@/features/organizations/components/scope-mismatch-recovery" import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { describeReturnDestination } from "@/lib/routing/workspace-hrefs" interface ProductsPageProps { filters: ProductFilters onFiltersChange: (filters: ProductFilters) => void organizationId: string projectId: string + /** + * Where a recovery round trip came from. Mosaic Billing sends operators here + * from a quarantine record to map a store Product, and the way back has to + * survive the trip or the repair loop cannot be walked. + */ + returnTo?: string } export function ProductsPage({ @@ -26,6 +33,7 @@ export function ProductsPage({ onFiltersChange, organizationId, projectId, + returnTo, }: ProductsPageProps) { const queryClient = useQueryClient() const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) @@ -88,6 +96,11 @@ export function ProductsPage({ eyebrow="Catalog · Project-wide" title="Products" > + {returnTo ? ( + + {describeReturnDestination(returnTo)} + + ) : null} - View usage + {returnTo ? "Open mappings" : "View usage"} ))} 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 91cba2a8..ed99e927 100644 --- a/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx +++ b/apps/dashboard/src/features/organizations/components/cloud-workspace-shell.tsx @@ -3,9 +3,11 @@ import { ChartLineUpIcon } from "@phosphor-icons/react/dist/ssr/ChartLineUp" import { GearSixIcon } from "@phosphor-icons/react/dist/ssr/GearSix" import { KeyIcon } from "@phosphor-icons/react/dist/ssr/Key" import { PackageIcon } from "@phosphor-icons/react/dist/ssr/Package" +import { ReceiptIcon } from "@phosphor-icons/react/dist/ssr/Receipt" import { StorefrontIcon } from "@phosphor-icons/react/dist/ssr/Storefront" import { SquaresFourIcon } from "@phosphor-icons/react/dist/ssr/SquaresFour" import { UsersThreeIcon } from "@phosphor-icons/react/dist/ssr/UsersThree" +import { useQuery } from "@tanstack/react-query" import { Link, useRouterState } from "@tanstack/react-router" import { NavMain } from "@/components/navigation/nav-main" @@ -19,6 +21,8 @@ import { } from "@/components/ui/sidebar" import { readWorkspaceScope } from "@/features/organizations/types/workspace-navigation" import { UserMenu } from "@/features/auth/components/user-menu" +import { billingSettingsQueryOptions } from "@/features/store-connections/queries/billing-settings-queries" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" import { useOrganizationAccess } from "@/hooks/use-organization-access" import type { NavigationItem } from "@/components/navigation/nav-main" import { OrganizationSwitcher } from "./organization-switcher" @@ -32,6 +36,22 @@ export function CloudWorkspaceShell() { // rights. The API remains the authority; this only prevents dead-end links. const canManage = access.canManage + // Mosaic Billing is per-Project opt-in. While it is off, its Environment + // surfaces have nothing to show, so the group collapses to the one page that + // can turn it on. Hiding the group outright would make billing unreachable. + const environments = useQuery({ + ...environmentsQueryOptions(scope.projectId ?? ""), + enabled: canManage && Boolean(scope.projectId), + }) + const probeEnvironmentId = scope.environmentId ?? environments.data?.items[0]?.id ?? "" + const billingSettings = useQuery({ + ...billingSettingsQueryOptions(scope.projectId ?? "", probeEnvironmentId), + enabled: canManage && Boolean(scope.projectId) && probeEnvironmentId.length > 0, + }) + // Unknown state shows the full group: a nav that hides itself because a probe + // failed is worse than one item too many. + const billingEnabled = billingSettings.data?.billingEnabled !== false + function withManagement(items: NavigationItem[]) { return canManage ? items : [] } @@ -96,7 +116,56 @@ export function CloudWorkspaceShell() { }, ], }, + // Mosaic Billing is per-Project opt-in and owner/admin only. The + // group is hidden from members who could not act on it, so it is + // never a dead end; Environment-scoped items fall back to the + // Project overview exactly as Monetization and Analytics do. + // "Store Server Credentials" is the frozen term, and every + // recovery link in billing uses it, so the nav does too. ...withManagement([ + { + icon: , + title: "Billing", + subItems: [ + ...(billingEnabled + ? [ + { + to: scope.environmentId + ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/transactions` + : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, + title: "Transactions", + icon: <>, + }, + { + to: scope.environmentId + ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/quarantine` + : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, + title: "Quarantine", + icon: <>, + }, + { + to: scope.environmentId + ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/reconciliation` + : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, + title: "Reconciliation", + icon: <>, + }, + { + to: scope.environmentId + ? `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/${scope.environmentId}/health` + : `/organizations/${scope.organizationId}/projects/${scope.projectId}`, + title: "Billing health", + icon: <>, + }, + ] + : []), + { + to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/billing/connections`, + title: billingEnabled ? "Store Server Credentials" : "Set up Mosaic Billing", + icon: <>, + }, + ], + }, { to: `/organizations/${scope.organizationId}/projects/${scope.projectId}/settings/environments`, icon: , diff --git a/apps/dashboard/src/features/store-connections/components/billing-enablement-panel.test.tsx b/apps/dashboard/src/features/store-connections/components/billing-enablement-panel.test.tsx new file mode 100644 index 00000000..ff3fbf5f --- /dev/null +++ b/apps/dashboard/src/features/store-connections/components/billing-enablement-panel.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { BillingEnablementPanel } from "@/features/store-connections/components/billing-enablement-panel" +import { ApiError } from "@/lib/api/errors" + +/** + * Risk: the only control that starts the phase's primary workflow either fails + * silently or fails uninformatively. + * + * Two failure shapes matter. A refusal to disable while credentials are active + * is a documented, recoverable condition with one specific next step (revoke + * first), and rendering it as a bare server message would leave the operator + * with no idea what to do. And an unreadable enablement state must never be + * presented as "off", because that would invite an operator to "turn on" + * billing that is already on, or to conclude their setup failed when it did not. + */ + +function credentialsStillActive() { + return new ApiError("Store credentials are still active.", { + code: "store_credentials_still_active", + correlationId: "req_fixture_1", + retryable: false, + status: 409, + }) +} + +function renderPanel(overrides: Partial[0]> = {}) { + return render( + , + ) +} + +describe("Mosaic Billing enablement control", () => { + it("explains the 409 refusal and names revoking the credentials as the next step", () => { + renderPanel({ error: credentialsStillActive() }) + + expect(screen.getByRole("alert")).toHaveTextContent( + /Revoke the active Store Server Credentials first/, + ) + // The reason matters: disabling alone does not stop Apple posting, and each + // refusal spends one of five non-renewable delivery attempts. + expect(screen.getByRole("alert")).toHaveTextContent(/non-renewable delivery attempts/) + // The raw server message is never surfaced in place of Mosaic-owned copy. + expect(screen.queryByText("Store credentials are still active.")).not.toBeInTheDocument() + }) + + it("falls back to a generic failure for an error it has no specific remedy for", () => { + renderPanel({ error: new Error("boom") }) + + expect(screen.getByRole("alert")).toHaveTextContent("boom") + expect( + screen.queryByText(/Revoke the active Store Server Credentials first/), + ).not.toBeInTheDocument() + }) + + it("never reports an unreadable state as disabled", () => { + renderPanel({ billingEnabled: null }) + + expect(screen.getByText("State unavailable")).toBeVisible() + expect(screen.queryByText("Not enabled")).not.toBeInTheDocument() + // No enable button, because Mosaic does not know what it would be changing. + expect(screen.queryByRole("button", { name: /Turn on Mosaic Billing/ })).not.toBeInTheDocument() + }) + + it("offers turning billing on as the primary step when it is off", () => { + const onChange = vi.fn() + renderPanel({ billingEnabled: false, onChange }) + + screen.getByRole("button", { name: "Turn on Mosaic Billing" }).click() + expect(onChange).toHaveBeenCalledWith(true) + }) + + it("hides the controls from an actor who cannot manage the Organization", () => { + renderPanel({ billingEnabled: false, canManage: false }) + + expect(screen.queryByRole("button", { name: /Turn on Mosaic Billing/ })).not.toBeInTheDocument() + expect(screen.getByRole("link", { name: "Ask an Owner or Admin" })).toBeVisible() + }) +}) diff --git a/apps/dashboard/src/features/store-connections/components/billing-enablement-panel.tsx b/apps/dashboard/src/features/store-connections/components/billing-enablement-panel.tsx new file mode 100644 index 00000000..95d19874 --- /dev/null +++ b/apps/dashboard/src/features/store-connections/components/billing-enablement-panel.tsx @@ -0,0 +1,174 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" +import { BILLING_OPTIONAL_NOTE } from "@/features/billing-ledger/types/billing-vocabulary" +import { WorkflowPanel } from "@/features/organizations/components/workspace-page" +import { ApiError } from "@/lib/api/errors" + +interface BillingEnablementPanelProps { + /** `null` when Mosaic could not read the state, which is not the same as off. */ + billingEnabled: boolean | null + canManage: boolean + activeCredentialCount: number + error: unknown + isPending: boolean + isSaving: boolean + membersHref: string + onChange: (billingEnabled: boolean) => void +} + +/** + * The Mosaic Billing switch, and the first thing on the setup page. + * + * Enabling is step two of two documented steps (the deployment sets + * `MOSAIC_BILLING_ENABLED`; the Project turns itself on here). Until it is on, + * intake, the RTDN pull consumer, and the validation, reconciliation, and + * replay workers all skip the Project, so a fully configured credential still + * produces nothing — which is exactly the dead end this panel closes. + */ +export function BillingEnablementPanel({ + activeCredentialCount, + billingEnabled, + canManage, + error, + isPending, + isSaving, + membersHref, + onChange, +}: BillingEnablementPanelProps) { + const [confirmDisable, setConfirmDisable] = useState(false) + const credentialsStillActive = + error instanceof ApiError && error.code === "store_credentials_still_active" + + return ( + +
+ {isPending ? ( + + ) : billingEnabled === null ? ( + + ) : billingEnabled ? ( + + ) : ( + + )} + + {canManage && billingEnabled === false ? ( + + ) : null} + + {canManage && billingEnabled === true ? ( + + ) : null} +
+ + {billingEnabled === null && !isPending ? ( +

+ Mosaic could not read whether billing is on for this Project. This is not the same as it + being off — retry, and check that the deployment sets{" "} + MOSAIC_BILLING_ENABLED. +

+ ) : null} + + {billingEnabled === false ? ( +
+

+ Turning it on is the first setup step. After that, add a Store Server Credential below + and give the store the notification endpoint Mosaic issues. +

+

{BILLING_OPTIONAL_NOTE}

+
+ ) : null} + + {billingEnabled === true ? ( +

+ Intake, the Pub/Sub pull consumer, and the validation, reconciliation, and replay workers + are all active for this Project. Recorded facts never grant, revoke, or represent anyone's + access to your app. +

+ ) : null} + + {!canManage ? ( +

+ Organization owner or admin permission is required to change this.{" "} + + Ask an Owner or Admin + +

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

Turn Mosaic Billing off for this Project?

+

+ Intake stops accepting Store Notifications and observations, and the workers skip this + Project. Everything already recorded stays: the ledger is append-only and turning + billing off does not delete a single fact, attempt, or quarantine record. +

+ {activeCredentialCount > 0 ? ( +

+ {activeCredentialCount} Store Server Credential(s) are still active. Mosaic refuses to + turn billing off while that is true — see below. +

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

+ Revoke the active Store Server Credentials first +

+

+ Turning billing off would not stop the store. Apple keeps posting to an endpoint whose + intake token still resolves, and every refusal spends one of its five non-renewable + delivery attempts — so a transaction can be lost permanently. Revoking the credential is + what actually stops the store, so Mosaic requires it first and the switch then means + exactly what it says. +

+

+ Revoke each active credential in the list below, then turn billing off. +

+
+ ) : error ? ( +

+ {error instanceof Error ? error.message : "Mosaic could not change this setting."} +

+ ) : null} +
+ ) +} diff --git a/apps/dashboard/src/features/store-connections/components/connect-store-credential-sheet.tsx b/apps/dashboard/src/features/store-connections/components/connect-store-credential-sheet.tsx new file mode 100644 index 00000000..1edf8a20 --- /dev/null +++ b/apps/dashboard/src/features/store-connections/components/connect-store-credential-sheet.tsx @@ -0,0 +1,553 @@ +import { useForm, useStore } from "@tanstack/react-form" +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 { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" +import { providerLabel } from "@/features/billing-ledger/types/billing-vocabulary" +import { + buildCreateStoreCredentialRequest, + readGoogleServiceAccount, + storeEnvironmentMatchesMode, + validateAppleIssuerId, + validateAppleKeyId, + validateApplePrivateKey, + validateGoogleServiceAccount, + validateProviderApplicationIdentifier, + type StoreCredentialFormValues, +} from "@/features/store-connections/types/store-credential-input" +import type { Application, CreateStoreServerCredentialRequest, Environment } 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" + +interface ConnectStoreCredentialSheetProps { + applications: readonly Application[] + applicationsHref: string + environments: readonly Environment[] + environmentsHref: string + onCreate: (request: CreateStoreServerCredentialRequest) => Promise +} + +/** + * Write-only Store Server Credential entry. + * + * The secret is typed once, sent once over TLS, and cleared in the submit + * `finally` block whether the attempt succeeded or failed. It is never written + * to the Query cache, a route search parameter, browser storage, or a route + * loader, and no read ever returns it. + * + * Apple and Google share one sheet because the surrounding workflow — name, + * Mosaic Environment, Store Environment, Application scope, one-time secret — + * is identical; only the credential-specific fields differ. Two near-identical + * sheets would be two places for the secret-handling rules to drift. + */ +export function ConnectStoreCredentialSheet({ + applications, + applicationsHref, + environments, + environmentsHref, + onCreate, +}: ConnectStoreCredentialSheetProps) { + const [open, setOpen] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const form = useForm({ + defaultValues: { + applications: [] as StoreCredentialFormValues["applications"], + appleIssuerId: "", + appleKeyId: "", + environmentId: "", + googlePubSubProjectId: "", + googlePubSubSubscriptionId: "", + name: "", + provider: "app_store" as StoreCredentialFormValues["provider"], + secret: "", + storeEnvironment: "sandbox" as StoreCredentialFormValues["storeEnvironment"], + }, + onSubmit: async ({ value }) => { + setSubmitError(null) + try { + await onCreate( + buildCreateStoreCredentialRequest(value, (applicationId) => + applications.find((item) => item.id === applicationId)?.platform === "android" + ? "android" + : "ios", + ), + ) + form.reset() + setOpen(false) + } catch (error) { + setSubmitError( + error instanceof Error + ? error.message + : "Mosaic could not store this Store Server Credential.", + ) + } finally { + // Cleared on every path: a failed attempt must not leave key material + // sitting in a form field behind a sheet the operator walked away from. + form.setFieldValue("secret", "") + } + }, + }) + + const provider = useStore(form.store, (state) => state.values.provider) + const storeEnvironment = useStore(form.store, (state) => state.values.storeEnvironment) + const secretValue = useStore(form.store, (state) => state.values.secret) + const compatibleEnvironments = environments.filter((environment) => + storeEnvironmentMatchesMode(environment.mode, storeEnvironment), + ) + const googleSummary = + provider === "google_play" && secretValue.trim().length > 0 + ? readGoogleServiceAccount(secretValue) + : undefined + + return ( + { + setOpen(nextOpen) + setSubmitError(null) + if (!nextOpen) form.reset() + }} + open={open} + > + }>Add Store Server Credential + + + Add Store Server Credential + + Mosaic uses this key only to ask the store whether a transaction is authentic. It reads; + it never changes anything in your store account. + + +
{ + event.preventDefault() + event.stopPropagation() + void form.handleSubmit() + }} + > +
+ + {(field) => ( + + Store + + + )} + + + + value.trim().length === 0 + ? "Enter a name for this connection." + : value.length > 120 + ? "Use 120 characters or fewer." + : undefined, + }} + > + {(field) => ( + 0}> + Connection name + 0} + id="store-credential-name" + onBlur={field.handleBlur} + onChange={(event) => field.handleChange(event.currentTarget.value)} + placeholder="App Store sandbox" + value={field.state.value} + /> + ({ message }))} /> + + )} + + + + {(field) => ( + + + Store Environment + + + + Sandbox and production are separate connections and never mix. This is the + store’s own classification, not your Mosaic Environment. + + + )} + + + + value.length === 0 ? "Select a Mosaic Environment." : undefined, + }} + > + {(field) => ( + 0}> + Mosaic Environment + {compatibleEnvironments.length === 0 ? ( +
+

+ No Mosaic Environment matches this Store Environment. A production Store + Environment needs a production Mosaic Environment. +

+ + Create an Environment + +
+ ) : ( + + )} + ({ message }))} /> +
+ )} +
+ + { + if (value.length === 0) return "Select at least one Application." + return value.some((item) => + validateProviderApplicationIdentifier(item.providerApplicationIdentifier), + ) + ? "Enter the store identifier for every selected Application." + : undefined + }, + }} + > + {(field) => ( +
+ Application scope +

+ A verified notification is accepted only for an Application listed here. Mosaic + matches the store’s own bundle ID or package name against these values. +

+ {applications.length === 0 ? ( +
+

+ Register an Application before adding a Store Server Credential. +

+ + Register an Application + +
+ ) : ( +
+ {applications.map((application) => { + const selected = field.state.value.find( + (item) => item.applicationId === application.id, + ) + return ( +
+ + {selected ? ( + + ) : null} +
+ ) + })} +
+ )} + {field.state.meta.errors[0] ? ( +

+ {field.state.meta.errors[0]} +

+ ) : null} +
+ )} +
+ + {provider === "app_store" ? ( + <> + validateAppleIssuerId(value) }} + > + {(field) => ( + 0}> + Issuer ID + 0} + id="store-credential-issuer" + onBlur={field.handleBlur} + onChange={(event) => field.handleChange(event.currentTarget.value)} + placeholder="00000000-0000-0000-0000-000000000000" + spellCheck={false} + value={field.state.value} + /> + + App Store Connect · Users and Access · Integrations. Not a secret. + + ({ message }))} + /> + + )} + + + validateAppleKeyId(value) }} + > + {(field) => ( + 0}> + Key ID + 0} + id="store-credential-key-id" + onBlur={field.handleBlur} + onChange={(event) => field.handleChange(event.currentTarget.value)} + placeholder="ABCDE12345" + spellCheck={false} + value={field.state.value} + /> + ({ message }))} + /> + + )} + + + ) : ( + <> + + {(field) => ( + + + Pub/Sub project ID + + field.handleChange(event.currentTarget.value)} + spellCheck={false} + value={field.state.value} + /> + + )} + + + {(field) => ( + + + Pub/Sub subscription ID + + field.handleChange(event.currentTarget.value)} + spellCheck={false} + value={field.state.value} + /> + + Mosaic pulls notifications from this subscription. Google Play needs no + inbound Mosaic address. + + + )} + + + )} + + + form.getFieldValue("provider") === "app_store" + ? validateApplePrivateKey(value) + : validateGoogleServiceAccount(value), + }} + > + {(field) => ( + 0}> + + {provider === "app_store" + ? "In-App Purchase key (.p8)" + : "Service-account JSON key"} + + {/* + A multi-line key cannot use type="password", so the field is + labelled explicitly instead: entered once, never shown again. + */} +