Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
edf439f
docs: phase 9a stage 1 integration plan
Mujhtech Jul 27, 2026
05b4263
feat: billing ingestion contract v1 schemas, fixtures, and validation
Mujhtech Jul 28, 2026
a42924a
feat: shared billing reference vectors in test-fixtures package
Mujhtech Jul 28, 2026
a440f98
feat: phase 9a billing ingestion backend
Mujhtech Jul 28, 2026
748ae12
feat: optional android transaction observation handoff
Mujhtech Jul 28, 2026
bdfaff7
feat: optional ios transaction observation handoff
Mujhtech Jul 28, 2026
9e1a203
feat: optional flutter transaction observation handoff
Mujhtech Jul 28, 2026
021c1ca
fix: flutter observation submits contract record envelope
Mujhtech Jul 28, 2026
092be55
fix: ios observation submits contract record envelope
Mujhtech Jul 28, 2026
7cdf9a6
feat: phase 9a billing dashboard
Mujhtech Jul 28, 2026
e795824
fix: observation endpoints conform to billing ingestion contract enve…
Mujhtech Jul 28, 2026
390b052
chore: regenerate dashboard api client from corrected openapi
Mujhtech Jul 28, 2026
74d1dce
fix: android observation submits contract record envelope
Mujhtech Jul 28, 2026
4bd8a86
feat: phase 9a demonstration driver and evidence
Mujhtech Jul 28, 2026
fb2ca05
fix: replay attempts, google requery, and observation credential reso…
Mujhtech Jul 28, 2026
8032480
chore: dashboard support for missing-credential quarantine reason
Mujhtech Jul 28, 2026
5ec1cd3
test: ios observation fixture test asserts values from fixture file
Mujhtech Jul 28, 2026
28a6d91
test: android vector tests read the shared fixture file robustly
Mujhtech Jul 28, 2026
331ca05
feat: trusted-server purchase token amendment and vocabulary projecti…
Mujhtech Jul 28, 2026
7b46dcc
fix: stage 5 backend findings round one
Mujhtech Jul 28, 2026
4198a93
fix: stage 5 dashboard findings round one
Mujhtech Jul 28, 2026
ab3c1fb
fix: stage 5 backend findings round two
Mujhtech Jul 28, 2026
890fa6b
fix: final round pagination keyset and residual hardening
Mujhtech Jul 28, 2026
218ef86
docs: phase 9a review
Mujhtech Jul 28, 2026
3e0c1e7
chore: update backend and dashboard agent definitions
Mujhtech Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/mosaic-backend.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/mosaic-dashboard.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
46 changes: 46 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
49 changes: 49 additions & 0 deletions apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading