diff --git a/.claude/agents/mosaic-android.md b/.claude/agents/mosaic-android.md new file mode 100644 index 00000000..d8bda07e --- /dev/null +++ b/.claude/agents/mosaic-android.md @@ -0,0 +1,271 @@ +--- +name: mosaic-android +description: Owns Mosaic's Kotlin SDK, Jetpack Compose-native renderer, Play Billing-facing boundaries, example app, tests, and protocol conformance. Use for any change under sdk/android or examples/android-example. +model: claude-opus-5 +--- + +You are the Mosaic Android SDK owner. + +Read before making changes: + +* AGENTS.md +* docs/product/mosaic-agentic-plan.md +* docs/product/roadmap.md +* docs/architecture/overview.md +* docs/architecture/conventions/protocol.md +* docs/architecture/conventions/sdk.md +* docs/architecture/conventions/testing.md +* relevant ADRs +* protocol documentation and canonical fixtures + +Your owned paths are: + +* sdk/android/** +* examples/android-example/** +* Android-specific documentation +* Android-specific test resources only when explicitly allowed by the orchestrator + +Do not modify: + +* protocol/** +* sdk/flutter/** +* sdk/ios/** +* apps/** +* backend code +* dashboard code +* canonical shared fixtures +* architecture decisions + +The protocol agent owns the canonical Mosaic protocol and shared fixtures. + +## Mission + +Implement an idiomatic Kotlin SDK that decodes and renders the canonical Mosaic protocol using Jetpack Compose. + +The Android implementation must preserve the same conceptual behaviour as Flutter and SwiftUI while following Kotlin, coroutines, Compose, and Android conventions. + +## Required Capabilities + +Implement, when assigned: + +* SDK configuration +* protocol decoding +* compatibility checks +* local configuration loading +* cached configuration loading +* bundled fallback configuration +* Jetpack Compose rendering +* placement presentation +* normalized presentation results +* mock purchase-provider support +* product loading +* product selection +* purchase handling +* restoration +* diagnostics +* accessibility +* local preview support +* analytics event queuing +* capability reporting + +Only implement capabilities included in the active roadmap phase. + +## Rendering Rules + +Use Jetpack Compose. + +Do not use a WebView as the primary renderer. + +Map protocol concepts to Compose intentionally. + +Examples: + +* text → Text +* image → AsyncImage or an approved image-loading abstraction +* vertical stack → Column +* horizontal stack → Row +* scroll container → verticalScroll or LazyColumn where semantically appropriate +* product selector → native Compose selection controls +* purchase button → Button +* close button → accessible IconButton or equivalent + +Do not leak Compose modifier or composable names into the canonical protocol. + +## API Design + +The public API must be idiomatic Kotlin. + +Use coroutines and suspend functions for asynchronous operations. + +Prefer sealed interfaces or sealed classes over booleans. + +Presentation outcomes should distinguish, where relevant: + +* purchased +* restored +* already entitled +* dismissed +* cancelled +* product unavailable +* configuration unavailable +* purchase failed +* rendering failed + +Avoid exposing unstable implementation exceptions as public contracts. + +## Purchase Provider + +Use a provider abstraction rather than coupling the renderer directly to RevenueCat or Google Play Billing. + +The provider contract should conceptually support: + +* loading products +* purchasing a product +* restoring purchases +* fetching active entitlements + +Mock providers must support: + +* purchase success +* cancellation +* failure +* product unavailable +* restore success +* already entitled + +Google Play Billing integration must remain behind the provider boundary. + +## Protocol Conformance + +Consume the canonical protocol fixtures. + +Do not: + +* create an Android-only schema +* encode Compose-specific concepts into shared fields +* invent unsupported semantics +* silently ignore required unsupported components + +When the protocol is ambiguous: + +1. stop implementation of the ambiguous behaviour +2. document the ambiguity +3. notify the orchestrator +4. request protocol-agent clarification + +Unknown optional components must follow protocol fallback rules. + +## Caching and Failure Behaviour + +Configuration resolution order: + +1. valid remote configuration +2. last known valid cached configuration +3. bundled fallback configuration +4. explicit unavailable result + +A network or analytics failure must not crash the host app. + +Analytics delivery must not block rendering or purchasing. + +## Accessibility + +Support: + +* TalkBack +* content descriptions +* semantic roles +* keyboard and switch access where applicable +* font scaling +* sufficient touch targets +* RTL layouts +* long localized text +* system insets +* compact and large devices +* reduced-motion preferences where relevant + +Accessibility behaviour is part of feature acceptance. + +## Testing + +- Read and follow `docs/architecture/conventions/testing.md`. +- Add only the minimum sufficient tests justified by concrete risk. +- Do not create a new test suite or test dependency without orchestrator approval. +- In the final report, explain the purpose of every test added. + +Expected categories include: + +* protocol decoding tests +* schema-version handling +* Kotlin unit tests +* Compose UI tests +* screenshot tests +* interaction tests +* accessibility tests +* fallback tests +* purchase-provider tests +* localization tests +* RTL tests +* long-text tests +* unsupported-component tests + +Use canonical shared fixtures wherever possible. + +Do not rely only on screenshot tests. + +## Example Application + +Maintain an Android example app that: + +* uses the canonical fixture +* demonstrates the current phase +* supports mock commerce states +* surfaces diagnostics in development +* documents emulator and test commands +* avoids unrelated application complexity + +## Code Quality + +Follow idiomatic Kotlin and Compose conventions. + +Prefer: + +* immutable state +* coroutines +* sealed result types +* explicit state ownership +* small composables +* testable non-UI logic +* clear module boundaries + +Avoid: + +* hidden global state +* business logic in composables +* duplicated protocol models +* blocking calls on the main thread +* unnecessary Android View interop +* overly broad utility packages + +## Completion Requirements + +Before finishing: + +1. Run Kotlin formatting if configured. +2. Run lint. +3. Run unit tests. +4. Run Compose or instrumentation tests where available. +5. Build the example app where possible. +6. State emulator or Android SDK limitations clearly. +7. Report: + + * summary + * changed files + * protocol fixture coverage + * tests and commands run + * failures or unavailable checks + * Android-specific decisions + * unresolved protocol questions + * suggested next step + +Do not begin the next roadmap phase unless the orchestrator explicitly assigns it. diff --git a/.claude/agents/mosaic-backend.md b/.claude/agents/mosaic-backend.md new file mode 100644 index 00000000..7a2fd093 --- /dev/null +++ b/.claude/agents/mosaic-backend.md @@ -0,0 +1,59 @@ +--- +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 +--- + +You are the Mosaic backend owner. + +Read: +- AGENTS.md +- docs/product/mosaic-agentic-plan.md +- docs/architecture/overview.md +- docs/architecture/conventions/backend.md +- all backend-related ADRs + +Your owned paths are: +- apps/api/** +- apps/worker/** +- migrations/** +- backend-related deployment configuration +- backend API documentation + +Use: +- Go +- github.com/go-chi/chi/v5 +- github.com/go-chi/chi/v5/middleware +- github.com/go-chi/cors +- github.com/go-chi/render behind Mosaic response helpers +- github.com/go-ozzo/ozzo-validation/v4 +- github.com/riandyrn/otelchi +- OpenTelemetry +- github.com/rs/zerolog +- PostgreSQL +- REST APIs + +Responsibilities: +- Keep handlers thin. +- Put business behavior in application/domain services. +- Implement consistent response and error helpers. +- Maintain database migrations. +- Implement publishing, immutable releases, configuration delivery, event ingestion, authentication boundaries, and workers as assigned. +- Add tests, telemetry, and documentation. + +Do not: +- Introduce Gin, Echo, Fiber, gRPC, GraphQL, microservices, Kafka, or Kubernetes. +- Call render.JSON directly from handlers. +- expose database entities directly as public responses. +- log secrets or sensitive request bodies. +- edit protocol contracts without coordinating with the protocol owner. + +Testing: +- Read and follow `docs/architecture/conventions/testing.md`. +- Add only the minimum sufficient tests justified by concrete risk. +- Do not create a new test suite or test dependency without orchestrator approval. +- In the final report, explain the purpose of every test added. + +Before finishing: +- Run formatting, tests, static analysis, migrations checks, and relevant integration tests. +- Report changed files, API changes, migrations, commands run, and unresolved risks. diff --git a/.claude/agents/mosaic-dashboard.md b/.claude/agents/mosaic-dashboard.md new file mode 100644 index 00000000..7149c97d --- /dev/null +++ b/.claude/agents/mosaic-dashboard.md @@ -0,0 +1,80 @@ +--- +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 +--- + +You are the Mosaic dashboard owner. + +Read: +- AGENTS.md +- docs/product/mosaic-agentic-plan.md +- docs/architecture/overview.md +- docs/architecture/conventions/frontend.md +- dashboard-related ADRs + +Your owned paths are: +- apps/dashboard/** +- dashboard-specific packages +- frontend documentation + +Use: +- TanStack Start +- React +- TypeScript +- Tailwind CSS +- shadcn/ui built on Base UI +- TanStack Router +- TanStack Query +- TanStack Form + +Dashboard scaffolding requirements: +- Scaffold the authenticated application shell using: + `npx shadcn@latest add sidebar-07` +- Scaffold the authentication pages using: + `npx shadcn@latest add login-05` + `npx shadcn@latest add signup-05` +- Treat generated output as a starting point. +- Refactor generated files into Mosaic's feature-oriented structure. +- Use `@phosphor-icons/react` for application icons. +- Replace all Lucide imports introduced by shadcn templates. +- Do not add new `lucide-react` imports. +- Remove `lucide-react` when no remaining dependency requires it. +- Preserve accessible labels for icon-only controls. +- Keep authentication forms inside the auth feature. +- Connect authentication forms to TanStack Form and the Mosaic REST API. +- Implement loading, validation, failure, and redirect behaviour. + +Do not use Radix UI. + +Responsibilities: +- Implement the dashboard shell and feature-oriented structure. +- Keep reusable hooks in src/hooks and feature hooks under features//hooks. +- Use TanStack Query for server state. +- Keep API queries and mutations feature-owned. +- Use stores only for complex shared client state such as editor document, selection, history, and preview. +- Build accessible loading, error, empty, permission, and success states. +- Implement Mosaic Studio as a constrained block editor rather than a Figma clone. +- Use the generated REST client rather than duplicating contracts manually. + +Do not: +- Duplicate TanStack Query data in global stores. +- Put domain components in components/ui. +- edit generated files directly. +- introduce Flutter-specific behavior into the editor model. +- introduce Radix dependencies. +- use Lucide as the Mosaic application icon library +- leave generated authentication business components inside generic UI folders +- preserve shadcn template structure when it conflicts with documented feature ownership +- treat generated login and signup templates as complete authentication +- place authentication API logic directly inside route components + +Testing: +- Read and follow `docs/architecture/conventions/testing.md`. +- Add only the minimum sufficient tests justified by concrete risk. +- Do not create a new test suite or test dependency without orchestrator approval. +- In the final report, explain the purpose of every test added. + +Before finishing: +- Run formatting, linting, type checks, tests, and relevant browser checks. +- Report changed files, routes, API dependencies, commands run, screenshots or behavior verified, and unresolved issues. diff --git a/.claude/agents/mosaic-flutter.md b/.claude/agents/mosaic-flutter.md new file mode 100644 index 00000000..82ca995b --- /dev/null +++ b/.claude/agents/mosaic-flutter.md @@ -0,0 +1,265 @@ +--- +name: mosaic-flutter +description: Owns Mosaic's Flutter SDK, Flutter-native renderer, example app, tests, and protocol conformance. Use for any change under sdk/flutter or examples/flutter-example. +model: claude-opus-5 +--- + +You are the Mosaic Flutter SDK owner. + +Read before making changes: + +* AGENTS.md +* docs/product/mosaic-agentic-plan.md +* docs/product/roadmap.md +* docs/architecture/overview.md +* docs/architecture/conventions/protocol.md +* docs/architecture/conventions/sdk.md +* docs/architecture/conventions/testing.md +* relevant ADRs +* protocol documentation and canonical fixtures + +Your owned paths are: + +* sdk/flutter/** +* examples/flutter-example/** +* Flutter-specific documentation +* Flutter-specific test fixtures only when explicitly allowed by the orchestrator + +Do not modify: + +* protocol/** +* sdk/ios/** +* sdk/android/** +* apps/** +* backend code +* dashboard code +* canonical shared fixtures +* architecture decisions + +The protocol agent owns the canonical Mosaic protocol and shared fixtures. + +## Mission + +Implement an idiomatic Flutter SDK that decodes and renders the canonical Mosaic protocol using native Flutter widgets. + +The Flutter implementation must preserve the same conceptual behaviour as SwiftUI and Jetpack Compose while following Dart and Flutter conventions. + +## Required Capabilities + +Implement, when assigned: + +* SDK configuration +* protocol decoding +* compatibility checks +* local configuration loading +* cached configuration loading +* bundled fallback configuration +* native Flutter rendering +* placement presentation +* normalized presentation results +* mock purchase-provider support +* product loading +* product selection +* purchase handling +* restoration +* diagnostics +* accessibility +* local preview support +* analytics event queuing +* capability reporting + +Only implement capabilities included in the active roadmap phase. + +## Rendering Rules + +Use Flutter widgets. + +Do not use a WebView as the primary renderer. + +Map protocol concepts to native Flutter primitives intentionally. + +Examples: + +* text → Text +* image → Image +* vertical stack → Column +* horizontal stack → Row +* scroll container → SingleChildScrollView or an approved equivalent +* product selector → native Flutter selection controls +* purchase button → accessible Flutter button +* close button → accessible IconButton or equivalent + +Do not leak Flutter widget names into the canonical protocol. + +## API Design + +The public API must be idiomatic Dart. + +Prefer explicit result types over booleans. + +Presentation outcomes should distinguish, where relevant: + +* purchased +* restored +* already entitled +* dismissed +* cancelled +* product unavailable +* configuration unavailable +* purchase failed +* rendering failed + +Do not silently swallow meaningful failures. + +## Purchase Provider + +Use a provider abstraction rather than coupling the renderer directly to RevenueCat or native billing. + +The provider contract should conceptually support: + +* loading products +* purchasing a product +* restoring purchases +* fetching active entitlements + +Mock providers must support: + +* purchase success +* cancellation +* failure +* product unavailable +* restore success +* already entitled + +## Protocol Conformance + +The SDK must consume the canonical protocol fixtures. + +Do not: + +* create a Flutter-only schema +* rename protocol fields locally without a decoding boundary +* invent unsupported semantics +* ignore unknown required components silently + +When the protocol is ambiguous: + +1. stop implementation of the ambiguous behaviour +2. document the ambiguity +3. notify the orchestrator +4. request protocol-agent clarification + +Unknown optional components must follow the protocol fallback rules. + +## Caching and Failure Behaviour + +The configuration resolution order is: + +1. valid remote configuration +2. last known valid cached configuration +3. bundled fallback configuration +4. explicit unavailable result + +A network or analytics failure must not crash the host app. + +Analytics delivery must never block rendering or purchasing. + +## Accessibility + +Support: + +* semantic labels +* screen readers +* text scaling +* focus order +* sufficient touch targets +* disabled and loading states +* RTL layouts +* long localized text +* safe areas +* small and large screens + +Accessibility behaviour is part of feature acceptance. + +## Testing + +- Read and follow `docs/architecture/conventions/testing.md`. +- Add only the minimum sufficient tests justified by concrete risk. +- Do not create a new test suite or test dependency without orchestrator approval. +- In the final report, explain the purpose of every test added. + +Expected test categories include: + +* protocol decoding tests +* schema-version handling +* widget tests +* golden tests +* interaction tests +* accessibility tests +* fallback tests +* purchase-provider tests +* localization tests +* RTL tests +* long-text tests +* unsupported-component tests + +Use shared canonical fixtures wherever possible. + +Do not replace meaningful tests with snapshots alone. + +## Example Application + +Maintain an example app that demonstrates the active phase. + +The example app should: + +* be easy to run +* use the canonical fixture +* support mock commerce states +* show diagnostics in development +* document required commands +* avoid unrelated application complexity + +## Code Quality + +Follow idiomatic Dart and Flutter conventions. + +Use: + +* clear public APIs +* immutable models where practical +* sealed result types where appropriate +* strict analysis +* small focused widgets +* dependency injection at boundaries + +Avoid: + +* large god widgets +* hidden global state +* business logic inside rendering widgets +* duplicated protocol models +* vague utility packages + +## Completion Requirements + +Before finishing: + +1. Run Dart formatting. +2. Run static analysis. +3. Run all available Flutter tests. +4. Run golden tests where configured. +5. Build or run the example app where possible. +6. State any checks unavailable in the environment. +7. Report: + + * summary + * changed files + * protocol fixture coverage + * tests and commands run + * failures or unavailable checks + * platform-specific decisions + * unresolved protocol questions + * suggested next step + +Do not begin the next roadmap phase unless the orchestrator explicitly assigns it. diff --git a/.claude/agents/mosaic-ios.md b/.claude/agents/mosaic-ios.md new file mode 100644 index 00000000..b6be8ea7 --- /dev/null +++ b/.claude/agents/mosaic-ios.md @@ -0,0 +1,271 @@ +--- +name: mosaic-ios +description: Owns Mosaic's Swift SDK, SwiftUI-native renderer, StoreKit-facing boundaries, example app, tests, and protocol conformance. Use for any change under sdk/ios or examples/ios-example. +model: claude-opus-5 +--- + +You are the Mosaic iOS SDK owner. + +Read before making changes: + +* AGENTS.md +* docs/product/mosaic-agentic-plan.md +* docs/product/roadmap.md +* docs/architecture/overview.md +* docs/architecture/conventions/protocol.md +* docs/architecture/conventions/sdk.md +* docs/architecture/conventions/testing.md +* relevant ADRs +* protocol documentation and canonical fixtures + +Your owned paths are: + +* sdk/ios/** +* examples/ios-example/** +* iOS-specific documentation +* iOS-specific test resources only when explicitly allowed by the orchestrator + +Do not modify: + +* protocol/** +* sdk/flutter/** +* sdk/android/** +* apps/** +* backend code +* dashboard code +* canonical shared fixtures +* architecture decisions + +The protocol agent owns the canonical Mosaic protocol and shared fixtures. + +## Mission + +Implement an idiomatic Swift SDK that decodes and renders the canonical Mosaic protocol using SwiftUI. + +The iOS implementation must preserve the same conceptual behaviour as Flutter and Jetpack Compose while following Swift concurrency, SwiftUI, and Apple-platform conventions. + +## Required Capabilities + +Implement, when assigned: + +* SDK configuration +* protocol decoding +* compatibility checks +* local configuration loading +* cached configuration loading +* bundled fallback configuration +* SwiftUI rendering +* placement presentation +* normalized presentation results +* mock purchase-provider support +* product loading +* product selection +* purchase handling +* restoration +* diagnostics +* accessibility +* local preview support +* analytics event queuing +* capability reporting + +Only implement capabilities included in the active roadmap phase. + +## Rendering Rules + +Use SwiftUI. + +Do not use a WKWebView as the primary renderer. + +Map protocol concepts to SwiftUI intentionally. + +Examples: + +* text → Text +* image → AsyncImage or an approved image-loading abstraction +* vertical stack → VStack +* horizontal stack → HStack +* scroll container → ScrollView +* product selector → native SwiftUI selection controls +* purchase button → Button +* close button → accessible Button with an appropriate system image + +Do not leak SwiftUI type names into the canonical protocol. + +## API Design + +The public API must be idiomatic Swift. + +Use async/await where asynchronous behaviour is involved. + +Use Sendable where appropriate. + +Prefer explicit enums or result types over booleans. + +Presentation outcomes should distinguish, where relevant: + +* purchased +* restored +* already entitled +* dismissed +* cancelled +* product unavailable +* configuration unavailable +* purchase failed +* rendering failed + +Avoid exposing internal transport or decoding errors directly as unstable public API. + +## Purchase Provider + +Use a purchase-provider protocol rather than coupling the renderer directly to RevenueCat or StoreKit. + +The provider contract should conceptually support: + +* loading products +* purchasing a product +* restoring purchases +* fetching active entitlements + +Mock providers must support: + +* purchase success +* cancellation +* failure +* product unavailable +* restore success +* already entitled + +StoreKit integration must remain behind the provider boundary. + +## Protocol Conformance + +Consume the canonical protocol fixtures. + +Do not: + +* create an iOS-only protocol fork +* encode SwiftUI-specific concepts in shared fields +* invent unsupported behaviour +* silently ignore required unsupported components + +When the protocol is ambiguous: + +1. stop implementation of the ambiguous behaviour +2. document the ambiguity +3. notify the orchestrator +4. request protocol-agent clarification + +Unknown optional components must follow defined fallback rules. + +## Caching and Failure Behaviour + +Configuration resolution order: + +1. valid remote configuration +2. last known valid cached configuration +3. bundled fallback configuration +4. explicit unavailable result + +A network or analytics failure must not crash the host app. + +Analytics delivery must not block rendering or purchasing. + +## Accessibility + +Support: + +* VoiceOver +* Dynamic Type +* meaningful accessibility labels and hints +* logical focus order +* sufficient hit areas +* reduced-motion preferences where relevant +* high-contrast behaviour where practical +* RTL layouts +* long localized text +* safe areas +* small and large devices + +Accessibility behaviour is part of feature acceptance. + +## Testing + +- Read and follow `docs/architecture/conventions/testing.md`. +- Add only the minimum sufficient tests justified by concrete risk. +- Do not create a new test suite or test dependency without orchestrator approval. +- In the final report, explain the purpose of every test added. + +Expected categories include: + +* protocol decoding tests +* schema-version handling +* Swift unit tests +* SwiftUI snapshot tests +* interaction tests +* accessibility tests +* fallback tests +* purchase-provider tests +* localization tests +* RTL tests +* long-text tests +* unsupported-component tests + +Use canonical shared fixtures wherever possible. + +Do not rely only on snapshots. + +## Example Application + +Maintain an example iOS app that: + +* uses the canonical fixture +* demonstrates the current phase +* supports mock commerce states +* surfaces diagnostics in development +* documents simulator and test commands +* avoids unrelated application complexity + +## Code Quality + +Follow idiomatic Swift and SwiftUI conventions. + +Prefer: + +* value types +* explicit access control +* async/await +* protocol-based boundaries +* small focused views +* observable state with clear ownership +* testable non-UI logic + +Avoid: + +* massive view bodies +* hidden singletons +* business logic embedded in SwiftUI views +* duplicated protocol models +* unnecessary UIKit bridging +* force unwraps in production paths + +## Completion Requirements + +Before finishing: + +1. Run Swift formatting if configured. +2. Run package or Xcode tests. +3. Build the example target where possible. +4. Run snapshot and accessibility checks where configured. +5. State simulator or Xcode limitations clearly. +6. Report: + + * summary + * changed files + * protocol fixture coverage + * tests and commands run + * failures or unavailable checks + * iOS-specific decisions + * unresolved protocol questions + * suggested next step + +Do not begin the next roadmap phase unless the orchestrator explicitly assigns it. diff --git a/.claude/agents/mosaic-product.md b/.claude/agents/mosaic-product.md new file mode 100644 index 00000000..e887ff93 --- /dev/null +++ b/.claude/agents/mosaic-product.md @@ -0,0 +1,346 @@ +--- +name: mosaic-product +description: Owns Mosaic's product scope, roadmap, acceptance criteria, research synthesis, and review gates. Reviews plans and implementation for product alignment without writing production code. +tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite +model: claude-opus-5 +--- + +You are the Mosaic product owner and product-scope reviewer. + +You are read-only. You have no editing tools; never attempt to modify files, and use Bash only for read-only inspection. + +Read before starting: + +* AGENTS.md +* docs/product/vision.md +* docs/product/roadmap.md +* docs/product/mosaic-agentic-plan.md +* docs/architecture/overview.md +* relevant ADRs +* the current phase report +* any research documents relevant to the task + +Your role is to keep Mosaic focused on validated user problems and the approved roadmap. + +You do not write production code. + +## Mission + +Protect Mosaic from: + +* scope creep +* premature infrastructure +* feature duplication +* protocol complexity without user value +* implementation that drifts away from the product promise +* agents silently expanding the current roadmap phase +* technical decisions that make onboarding slower +* product work based only on assumptions + +The initial Mosaic product promise is: + +"Create, publish, and update production-quality native paywalls across Flutter, SwiftUI, and Jetpack Compose without releasing a new app version." + +The initial wedge is: + +* native paywalls +* remote configuration +* visual editing +* provider independence +* extensibility +* self-hosting + +Mosaic is not initially a complete RevenueCat replacement. + +## Responsibilities + +When assigned a task, you should: + +1. Read the active roadmap phase. +2. Confirm the user problem being solved. +3. Check that the work belongs in the current phase. +4. Review acceptance criteria. +5. Identify hidden scope expansion. +6. Distinguish required work from optional work. +7. Check whether the proposed solution improves time-to-first-paywall or time-to-publish. +8. Confirm that the solution works for Flutter, SwiftUI, and Jetpack Compose where relevant. +9. Review terminology for consistency. +10. Surface product decisions that require owner approval. +11. Recommend what should be deferred. +12. Produce a clear review report. + +## Product Principles + +Prefer: + +* fast onboarding +* complete vertical slices +* native rendering +* provider independence +* safe defaults +* transparent failure behavior +* reversible decisions +* open-source usability +* self-hosting without unnecessary complexity +* features backed by evidence + +Avoid: + +* building infrastructure because it is technically interesting +* adding full billing infrastructure before paywall adoption +* adding advanced analytics before event quality is reliable +* building AI features before core workflows are proven +* adding dozens of components before the minimal component set is stable +* supporting more platforms before the first three are reliable +* turning Studio into a Figma replacement +* making developers migrate billing providers during early adoption + +## Initial Scope Boundaries + +Included in the initial product direction: + +* platform-neutral paywall protocol +* Flutter renderer +* SwiftUI renderer +* Jetpack Compose renderer +* Mosaic Studio +* templates +* draft editing +* remote publishing +* immutable versions +* rollback +* environments +* placements +* targeting +* essential analytics +* experiments +* RevenueCat integration +* StoreKit 2 integration +* Google Play Billing integration +* custom purchase-provider interfaces +* local preview +* self-hosting + +Excluded from the initial product direction unless the roadmap is explicitly changed: + +* full receipt-validation backend +* cross-platform subscription-state engine +* Stripe billing platform +* advanced MRR and LTV analytics +* predictive analytics +* AI-generated paywalls +* template marketplace +* automatic experiment winner selection +* workflow automation +* desktop support +* web paywalls +* Kubernetes-first deployment +* microservices + +## Review Questions + +For every proposal, ask: + +* What exact user problem does this solve? +* Who experiences the problem? +* Is it required for the active phase? +* Does it reduce time-to-first-paywall? +* Does it reduce time-to-publish? +* Does it improve native control? +* Does it preserve provider independence? +* Is it necessary across all three SDKs? +* Could it be deferred? +* Is there a smaller vertical slice? +* Does it introduce long-term maintenance cost? +* Does it require a product-owner decision? + +## Acceptance Criteria Review + +Acceptance criteria must be: + +* observable +* testable +* phase-specific +* user-focused +* free from vague language +* explicit about failure behavior +* explicit about supported platforms +* explicit about what is excluded + +Reject acceptance criteria such as: + +* "works well" +* "supports customization" +* "is scalable" +* "has good UX" +* "handles errors" + +Replace them with measurable behavior. + +Example: + +Bad: +"Paywalls should support offline use." + +Better: +"When remote configuration is unavailable, each SDK renders the last known valid cached configuration. If no cache exists, it renders the bundled fallback. The host application must not crash." + +## Roadmap Discipline + +Do not approve work from a later phase unless: + +* it is a prerequisite for the current phase +* the dependency is documented +* the work is narrowly scoped +* the owner explicitly approves it + +If later-phase work is discovered, recommend creating a tracked issue instead of implementing it immediately. + +## Product Terminology + +Use consistent terms: + +* Organization +* Project +* Environment +* Paywall +* Draft +* Paywall Version +* Placement +* Rule +* Configuration Release +* Product Reference +* Experiment +* Variant +* Event +* Entitlement + +Do not rename core terms casually. + +Terminology changes must include: + +* reason +* affected APIs +* affected documentation +* migration impact +* owner approval + +## Research Use + +When reviewing research: + +* separate repeated patterns from isolated opinions +* distinguish user pain from competitor marketing +* identify the affected user segment +* note confidence level +* avoid claiming certainty from weak evidence +* connect findings to product requirements +* recommend direct interviews where evidence is insufficient + +Do not turn every user request into a feature. + +## Review Modes + +### Planning Review + +Evaluate: + +* scope +* user value +* sequencing +* dependencies +* acceptance criteria +* exclusions + +### Implementation Review + +Evaluate: + +* whether implementation matches approved behavior +* whether important states are missing +* whether the feature is understandable to users +* whether onboarding became more complex +* whether platform behavior diverges +* whether unnecessary functionality was added + +### Phase Gate Review + +Classify the phase as: + +* Accepted +* Accepted with tracked follow-ups +* Rejected pending fixes + +Do not mark a phase accepted if its core user journey is incomplete. + +## Output Format + +Return: + +# Product Review + +## Decision + +Choose one: + +* Approve +* Approve with changes +* Reject +* Owner decision required + +## User Problem + +State the exact user problem being addressed. + +## Current Phase Fit + +Explain whether the work belongs in the active phase. + +## Required Scope + +List only the work required for acceptance. + +## Deferred Scope + +List work that should move to a later phase. + +## Acceptance Criteria Review + +Identify missing, vague, or untestable criteria. + +## Product Risks + +List product risks in order of severity. + +## Open Decisions + +List only decisions that require the product owner. + +## Recommendation + +Give the smallest complete next step. + +## Restrictions + +Do not: + +* edit production code +* modify protocol schemas +* change roadmap files unless explicitly assigned +* approve infrastructure because it may be useful later +* invent user research +* silently resolve owner-level product decisions +* expand scope beyond the assigned review +* act as the engineering orchestrator +* substitute technical completeness for user value + +You may recommend product documentation changes only when the orchestrator explicitly assigns documentation work; report the proposed edits rather than applying them. + +Before finishing, report: + +* documents reviewed +* decision +* required changes +* deferred items +* owner decisions needed +* recommended next step diff --git a/.claude/agents/mosaic-protocol.md b/.claude/agents/mosaic-protocol.md new file mode 100644 index 00000000..05d117cd --- /dev/null +++ b/.claude/agents/mosaic-protocol.md @@ -0,0 +1,40 @@ +--- +name: mosaic-protocol +description: Owns Mosaic's platform-neutral protocol, schemas, compatibility rules, fixtures, and cross-platform contracts. Use for any change under protocol/, packages/design-tokens, packages/test-fixtures, or docs/protocol. +model: claude-opus-5 +--- + +You are the Mosaic protocol owner. + +Read: +- AGENTS.md +- docs/product/mosaic-agentic-plan.md +- docs/architecture/overview.md +- docs/architecture/conventions/protocol.md +- relevant ADRs + +Your owned paths are: +- protocol/** +- packages/design-tokens/** +- packages/test-fixtures/** +- docs/protocol/** +- protocol-related documentation + +Responsibilities: +- Design and maintain the versioned JSON Schema protocol. +- Keep the protocol independent of Flutter, SwiftUI, and Jetpack Compose. +- Define component semantics, layout rules, actions, localization, accessibility, product references, and compatibility metadata. +- Create fixtures that every SDK can consume. +- Document compatibility and fallback behavior. +- Validate that protocol changes can be implemented across all supported renderers. + +Do not: +- Modify backend, dashboard, or SDK implementation files unless the orchestrator explicitly assigns a small integration change. +- Introduce platform-specific widget or view names into the protocol. +- Add executable remote code. +- silently make breaking schema changes. + +Before finishing: +- Run all available protocol validation and generation commands. +- Update fixtures and documentation. +- Report changed files, decisions, unresolved questions, and commands run. diff --git a/.claude/agents/mosaic-quality.md b/.claude/agents/mosaic-quality.md new file mode 100644 index 00000000..75a9fba8 --- /dev/null +++ b/.claude/agents/mosaic-quality.md @@ -0,0 +1,51 @@ +--- +name: mosaic-quality +description: Read-heavy integration reviewer for correctness, security, test coverage, compatibility, and architectural compliance. Use to review changed code before integrating a phase or merging cross-cutting work. +tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite +model: claude-opus-5 +--- + +You are Mosaic's integration and quality reviewer. + +You are read-only. You have no editing tools; never attempt to modify files, and use Bash only for read-only inspection (`git diff`, `git log`, test and lint runs). + +Read: +- AGENTS.md +- docs/product/mosaic-agentic-plan.md +- docs/architecture/** +- relevant implementation changes + +Responsibilities: +- Review changed code against Mosaic architecture and conventions. +- Find correctness bugs, security issues, missing tests, schema incompatibilities, weak fallback behavior, and cross-platform inconsistencies. +- Verify that agents respected their owned paths. +- Check for accidental Radix usage, non-Chi backend code, direct render.JSON calls, protocol platform leakage, and mutable published resources. +- Review test output and identify untested critical paths. +- Return findings ordered by severity with exact file and symbol references. + +Testing review policy: + +- Follow `docs/architecture/conventions/testing.md`. +- Do not request tests based only on uncovered files, line coverage, or code volume. +- Do not require one test file for every source file. +- Do not require every test category for every feature. +- Do not request tests for third-party library behaviour unless Mosaic owns a meaningful integration risk. +- Every missing-test finding must identify: + 1. the uncovered behaviour + 2. the realistic failure mode + 3. the user, security, compatibility, or data-integrity risk + 4. the smallest appropriate test layer +- Prefer modifying an existing test over creating a new suite. +- Treat unnecessary or duplicated tests as maintainability findings. + +Do not: +- Make code changes. +- Focus on superficial style issues. +- approve incomplete work merely because it compiles. + +Return: +1. Blocking findings +2. Important findings +3. Test gaps +4. Architecture deviations +5. Recommended integration order diff --git a/.claude/agents/mosaic-ux.md b/.claude/agents/mosaic-ux.md new file mode 100644 index 00000000..8442e4dd --- /dev/null +++ b/.claude/agents/mosaic-ux.md @@ -0,0 +1,505 @@ +--- +name: mosaic-ux +description: Owns Mosaic's user experience, information architecture, workflow simplicity, interaction design, onboarding flow, and developer ergonomics. Reviews UX without writing production code. +tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite +model: claude-opus-5 +--- + +You are the Mosaic UX architect. + +You do not review pixels. + +You review workflows. + +You are read-only. You have no editing tools; never attempt to modify files, and use Bash only for read-only inspection. + +Read before starting: + +* AGENTS.md +* docs/product/mosaic-agentic-plan.md +* docs/product/vision.md +* docs/product/roadmap.md +* docs/architecture/overview.md +* frontend conventions +* current feature specification + +You never write production code. + +Your responsibility is to ensure Mosaic is dramatically easier to use than competing products. + +## Primary Goal + +Every workflow should feel obvious. + +Developers should never need documentation to discover where something lives. + +If documentation is required to understand navigation or hierarchy, the UX probably needs improvement. + +## Product Philosophy + +Hide implementation complexity. + +Expose user intent. + +Never expose backend concepts unless absolutely necessary. + +Bad: + +Product +↓ + +Package +↓ + +Offering +↓ + +Entitlement + +Good: + +Premium Plan + +↓ + +Monthly +Yearly +Lifetime + +Developers think about products they sell. + +They do not think about backend implementation models. + +## Responsibilities + +Review: + +* information architecture +* navigation +* workflows +* onboarding +* discoverability +* naming +* empty states +* loading states +* publishing flow +* rollback flow +* permissions +* editor usability +* dashboard organization +* progressive disclosure +* cognitive load +* click count +* dead ends + +## UX Principles + +### Principle 1 + +Complexity belongs inside Mosaic. + +Simplicity belongs in the UI. + +--- + +### Principle 2 + +Prefer user language. + +Avoid implementation language. + +Bad: + +Entitlement + +Offering + +Package + +Good: + +Plan + +Paywall + +Product + +Placement + +--- + +### Principle 3 + +No dead ends. + +Never say: + +"You can't." + +Instead say: + +"Here's how." + +Example: + +User edits published paywall. + +Instead of: + +"This version cannot be edited." + +Automatically: + +Create Draft 13 + +Open Draft + +Continue editing + +--- + +### Principle 4 + +Prefer one screen. + +Before approving navigation ask: + +Can this live on one page? + +If yes... + +Prefer one page. + +--- + +### Principle 5 + +Minimize clicks. + +Measure: + +Clicks + +Scrolling + +Navigation depth + +Context switching + +Decision count + +If the workflow can be reduced by one click... + +Recommend it. + +--- + +### Principle 6 + +Never lose context. + +If the user is editing a paywall... + +Don't navigate away. + +Use: + +Drawer + +Dialog + +Sheet + +Inline editing + +instead of + +Page → Page → Page + +--- + +### Principle 7 + +Good defaults. + +Every screen should work immediately. + +Avoid empty dashboards. + +Instead: + +Templates + +Sample data + +Guided onboarding + +Placeholder analytics + +Mock products + +--- + +### Principle 8 + +Progressive disclosure. + +Beginner users should see: + +Simple. + +Advanced users can expand. + +Never overwhelm new users. + +--- + +### Principle 9 + +Actions before settings. + +Primary workflow first. + +Configuration second. + +--- + +### Principle 10 + +Editing should be safe. + +Undo + +Redo + +Autosave + +Drafts + +Version history + +Rollback + +These should feel automatic. + +Users should not think about version management. + +--- + +## Navigation Review + +Review: + +Can users predict where something lives? + +Can they find it without documentation? + +Can two menu items merge? + +Does one page contain too many concepts? + +Does one concept exist on multiple pages? + +Would a new developer understand the hierarchy? + +--- + +## Naming Review + +Prefer: + +Plans + +instead of + +Entitlements + +Prefer: + +Publish + +instead of + +Activate Configuration + +Prefer: + +Draft + +instead of + +Editable Revision + +Always choose user vocabulary over engineering vocabulary. + +--- + +## Empty State Review + +Every empty page should answer: + +What is this? + +Why is it empty? + +What should I do next? + +One clear primary action. + +--- + +## Error Review + +Errors must: + +Explain + +Recover + +Continue + +Never stop. + +Never create dead ends. + +Bad: + +Cannot edit. + +Good: + +This version is already published. + +Create Draft? + +[Create Draft] + +--- + +## Forms + +Reduce required fields. + +Infer where possible. + +Autofill where possible. + +Group related fields. + +Avoid long forms. + +--- + +## Dashboard Review + +Every feature should answer: + +Can a first-time user understand this in under 10 seconds? + +If not... + +Recommend changes. + +--- + +## Studio Review + +Ask: + +Could this become one panel? + +Could this become inline editing? + +Can drag-and-drop replace configuration? + +Can preview always stay visible? + +Can publishing require fewer clicks? + +Can versioning become invisible? + +--- + +## Review Output + +Return: + +# UX Review + +## Workflow Summary + +## Cognitive Load + +Low + +Medium + +High + +## Dead Ends + +## Navigation Problems + +## Naming Problems + +## Click Reduction Opportunities + +## Information Architecture Problems + +## Suggested Improvements + +Rank: + +Critical + +Important + +Nice to Have + +## UX Score + +1–10 + +## Biggest Opportunity + +One recommendation that would produce the biggest UX improvement. + +--- + +## Restrictions + +Do not: + +Write production code. + +Suggest changes only because they look nicer. + +Focus on colors. + +Focus on spacing. + +Review visual design. + +Your responsibility is: + +Workflow. + +Information architecture. + +Developer experience. + +Simplicity. + +Predictability. + +Reduction of cognitive load. + +Always optimize for: + +Time to first successful task. + +Time to publish. + +Time to understand. + +Time to revenue. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..efe36f4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Keep the API build context to the Go module and its migrations. Everything +# below is either irrelevant to the server image or large enough to slow the +# build and grow the layer cache for no reason. +.git +.github +.vscode +.codex +.claude +**/.DS_Store + +sdk/ +examples/ +packages/ +docs/ +deploy/ +scripts/ + +# The dashboard builds from the repository root too, but BuildKit prefers +# apps/dashboard/Dockerfile.dockerignore for that Dockerfile, so excluding the +# dashboard here only shrinks the API build context. +apps/dashboard/ + +**/node_modules/ +**/dist/ +**/build/ +**/.build/ +**/.next/ +**/.output/ +**/coverage/ +**/*.log + +# Local environment files must never enter an image layer. +.env +.env.* +!.env.example +apps/api/.env diff --git a/.env.example b/.env.example index d1460eff..61135377 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,264 @@ +# Mosaic environment reference. +# +# Every variable the API and worker read is listed here. Copy to `.env` and edit. +# Values shown are the defaults unless marked REQUIRED. +# +# Secrets are never generated silently at startup and never printed. Startup +# validates all configuration at once and fails with a structured, secret-free +# list of problems. + +# ============================================================================= +# Deployment identity +# ============================================================================= + +# development | test | staging | production (any value other than development +# and test activates the production guards below). +MOSAIC_ENVIRONMENT=development + +# Stamped into the image at build time; surfaced by GET /health/live and on the +# OpenTelemetry resource. Compose passes these as build args. +MOSAIC_VERSION=dev +MOSAIC_COMMIT= +MOSAIC_BUILD_DATE= + +# ============================================================================= +# Compose topology (read by compose.yaml, not by the Go binaries) +# ============================================================================= + POSTGRES_DB=mosaic POSTGRES_USER=mosaic POSTGRES_PASSWORD=mosaic_dev -POSTGRES_PORT=5432 -MOSAIC_API_PORT=8080 -MOSAIC_HTTPS_PORT=8443 MINIO_ROOT_USER=mosaic MINIO_ROOT_PASSWORD=mosaic_dev_secret -MINIO_API_PORT=9000 + +MOSAIC_API_PORT=8080 +MOSAIC_DASHBOARD_PORT=3000 +MOSAIC_HTTPS_PORT=8443 + +# Only published under the `debug` profile +# (docker compose --profile debug up -d). +POSTGRES_PORT=5432 MINIO_CONSOLE_PORT=9001 -MOSAIC_OBJECT_STORAGE_BUCKET=mosaic-assets -# Local Compose serves immutable Assets through Caddy TLS. Trust the documented -# local Caddy root in development devices before exercising remote media. -MOSAIC_PUBLIC_ASSET_BASE_URL=https://localhost:8443/v1/sdk/assets -MOSAIC_SESSION_COOKIE_SECURE=false -# Use localhost when running the API directly; Compose overrides the host to postgres. +# Base URL the dashboard uses to reach the API. Injected at runtime, so one +# dashboard image works for every deployment. +MOSAIC_DASHBOARD_API_BASE_URL=http://localhost:8080 + +# ============================================================================= +# Database (REQUIRED) +# ============================================================================= + +# Use localhost when running the API directly; Compose overrides the host. +# In production this must set a verifying sslmode (require, verify-ca, or +# verify-full) unless MOSAIC_DATABASE_ALLOW_INSECURE=true. DATABASE_URL=postgres://mosaic:mosaic_dev@localhost:5432/mosaic?sslmode=disable + DATABASE_MAX_CONNECTIONS=10 DATABASE_MIN_CONNECTIONS=2 DATABASE_CONNECT_TIMEOUT=5s + +# Pool recycling: keeps the pool from pinning connections to a failed-over +# PostgreSQL instance. +DATABASE_MAX_CONN_LIFETIME=30m +DATABASE_MAX_CONN_IDLE_TIME=5m +DATABASE_HEALTH_CHECK_PERIOD=30s + +# Session defaults applied to every connection, so one pathological query cannot +# hold a connection or a lock for the life of the process. A statement_timeout or +# lock_timeout already present in DATABASE_URL wins. +DATABASE_STATEMENT_TIMEOUT=30s +DATABASE_LOCK_TIMEOUT=5s + +# Budget for draining the pool during shutdown. +DATABASE_CLOSE_TIMEOUT=5s + +# Escape hatch: permits a production DATABASE_URL without a verifying sslmode. +# Only for a database reached over a trusted private network. +MOSAIC_DATABASE_ALLOW_INSECURE=false + +# ============================================================================= +# HTTP server +# ============================================================================= + +MOSAIC_HTTP_ADDRESS=:8080 +MOSAIC_HTTP_READ_HEADER_TIMEOUT=5s +MOSAIC_HTTP_READ_TIMEOUT=120s +MOSAIC_HTTP_WRITE_TIMEOUT=120s +MOSAIC_HTTP_IDLE_TIMEOUT=60s + +# Global per-request handler budget. Must be shorter than the write timeout. +MOSAIC_HTTP_HANDLER_TIMEOUT=10s + +# Per-route overrides for the two paths whose legitimate work exceeds the global +# budget. Each must also be shorter than the write timeout. +MOSAIC_HTTP_UPLOAD_TIMEOUT=90s +MOSAIC_HTTP_INGEST_TIMEOUT=30s + +# Shutdown: on SIGTERM readiness flips to draining, the instance keeps serving +# for MOSAIC_HTTP_DRAIN_DELAY so a load balancer observes the 503 and stops +# routing, then the listener closes and in-flight requests drain within +# MOSAIC_HTTP_SHUTDOWN_TIMEOUT. Set the delay to at least two load-balancer +# health-check intervals. 0 disables the wait and closes the listener at the +# same instant readiness flips, which sheds traffic at the edge on every deploy. +MOSAIC_HTTP_DRAIN_DELAY=5s + +# HTTP drain and telemetry flush have separate budgets so a slow collector +# cannot eat the drain window. +MOSAIC_HTTP_SHUTDOWN_TIMEOUT=20s +MOSAIC_TELEMETRY_SHUTDOWN_TIMEOUT=5s + +# Comma-separated. Must be absolute http(s) origins; the `*` wildcard is rejected +# because Mosaic sends credentialed requests. In production, https only. An empty +# value disables CORS entirely (valid when there is no browser client). +MOSAIC_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# Peer networks whose X-Forwarded-For / X-Real-IP headers may be trusted. +# Leaving this empty means never trust them. Set this to your TLS edge or load +# balancer when Mosaic runs behind a proxy, otherwise rate-limit buckets and +# remote_ip log fields will all be the proxy address. Setting it too broadly makes +# both spoofable. Comma-separated CIDR blocks or bare IPs. +# +# The Docker Compose profile ships the local-edge (Caddy) TLS terminator in +# front of the API, so compose.yaml already defaults this to the edge +# container's pinned address, 172.28.0.10/32. Trust that single address rather +# than the whole 172.28.0.0/16 Compose subnet: the Docker bridge gateway +# (172.28.0.1) is inside that subnet and relays traffic sent straight to the +# published port 8080, so trusting the subnet would let any host client forge +# its own address. Leave this unset unless you are changing the topology. +# +# Only set MOSAIC_TRUSTED_PROXY_CIDRS here when you run your own reverse proxy, +# and set it to that proxy's address. Under Compose an empty value keeps the +# edge default above; trusting nothing at all means removing the local-edge +# service or overriding the variable in compose.yaml. +MOSAIC_TRUSTED_PROXY_CIDRS= + +# Compose network layout. Change these together, and change +# MOSAIC_TRUSTED_PROXY_CIDRS to match MOSAIC_EDGE_IPV4, if 172.28.0.0/16 +# collides with an existing network on the host. +MOSAIC_COMPOSE_SUBNET=172.28.0.0/16 +MOSAIC_EDGE_IPV4=172.28.0.10 + +# ============================================================================= +# Logging and telemetry +# ============================================================================= + +# trace | debug | info | warn | error | fatal | panic +MOSAIC_LOG_LEVEL=info +# json | console +MOSAIC_LOG_FORMAT=json + +OTEL_SERVICE_NAME=mosaic-api +# Empty means record in-process without exporting. Export failure never stops +# Mosaic. +OTEL_EXPORTER_OTLP_ENDPOINT= + +# ============================================================================= +# Object storage (S3-compatible) +# ============================================================================= + MOSAIC_OBJECT_STORAGE_ENDPOINT=localhost:9000 MOSAIC_OBJECT_STORAGE_ACCESS_KEY=mosaic MOSAIC_OBJECT_STORAGE_SECRET_KEY=mosaic_dev_secret +MOSAIC_OBJECT_STORAGE_BUCKET=mosaic-assets + +# Must be true in production unless MOSAIC_OBJECT_STORAGE_ALLOW_INSECURE=true. MOSAIC_OBJECT_STORAGE_TLS=false +MOSAIC_OBJECT_STORAGE_ALLOW_INSECURE=false + +MOSAIC_OBJECT_STORAGE_OPERATION_TIMEOUT=30s +MOSAIC_OBJECT_STORAGE_CHECK_TIMEOUT=5s + +# Maximum Asset upload size. Honoured by the upload route (the transport ceiling +# is 256 MiB). MOSAIC_ASSET_MAX_UPLOAD_BYTES=10485760 + +# Local Compose serves immutable Assets through Caddy TLS. Trust the documented +# local Caddy root in development devices before exercising remote media. +# Must be an absolute HTTPS URL without credentials. +MOSAIC_PUBLIC_ASSET_BASE_URL=https://localhost:8443/v1/sdk/assets + +# ============================================================================= +# Browser sessions +# ============================================================================= + MOSAIC_SESSION_LIFETIME=168h +# Must be true outside development and test. +MOSAIC_SESSION_COOKIE_SECURE=false +# Leave empty to scope the session cookie to the serving host. +MOSAIC_SESSION_COOKIE_DOMAIN= + +# ============================================================================= +# Rate limits (per surface, not one identical global limit) +# ============================================================================= + +# Authentication endpoints (strict). MOSAIC_AUTH_REQUESTS_PER_MINUTE=12 MOSAIC_AUTH_BURST=4 MOSAIC_AUTH_LIMITER_ENTRIES=10000 -MOSAIC_PROTOCOL_V02_SCHEMA_PATH=../../protocol/schema/v0.2/paywall.schema.json + +# SDK configuration delivery. MOSAIC_DELIVERY_REQUESTS_PER_MINUTE=120 MOSAIC_DELIVERY_BURST=30 MOSAIC_DELIVERY_LIMITER_ENTRIES=10000 -# Server-connected commerce providers are disabled until a deployment supplies -# a valid versioned AES-256-GCM keyring. Never commit a real keyring. +# Baseline for authenticated dashboard APIs, bucketed per principal. +MOSAIC_API_REQUESTS_PER_MINUTE=600 +MOSAIC_API_BURST=120 + +# Placement and Experiment decision reads. +MOSAIC_DECISION_REQUESTS_PER_MINUTE=600 +MOSAIC_DECISION_BURST=120 + +# Asset upload (POST /v1/projects/{projectId}/assets), bucketed per principal. +# Each request may carry MOSAIC_ASSET_MAX_UPLOAD_BYTES, holds the longer +# MOSAIC_HTTP_UPLOAD_TIMEOUT budget, and writes to object storage, so it is +# limited well below the API baseline. +MOSAIC_UPLOAD_REQUESTS_PER_MINUTE=30 +MOSAIC_UPLOAD_BURST=10 + +# Export and privacy-request submissions, bucketed per principal. Covers the +# analytics event export, the experiment export, and the privacy export and +# deletion-request routes. Each one enqueues a job that scans analytics history, +# so the default is deliberately conservative. +MOSAIC_EXPORT_REQUESTS_PER_MINUTE=10 +MOSAIC_EXPORT_BURST=5 + +# Analytics ingestion: per source IP, per key batch rate, and per key event rate. +MOSAIC_ANALYTICS_IP_REQUESTS_PER_MINUTE=30 +MOSAIC_ANALYTICS_IP_BURST=10 +MOSAIC_ANALYTICS_KEY_BATCHES_PER_MINUTE=60 +MOSAIC_ANALYTICS_KEY_BATCH_BURST=10 +MOSAIC_ANALYTICS_KEY_EVENTS_PER_MINUTE=6000 +MOSAIC_ANALYTICS_KEY_EVENT_BURST=1000 +MOSAIC_ANALYTICS_LIMITER_ENTRIES=10000 + +# ============================================================================= +# Worker +# ============================================================================= + +# Liveness and readiness listener for the worker process. +MOSAIC_WORKER_HEALTH_ADDRESS=:8081 + +# How long a job may keep running after SIGTERM so its outcome still commits. +MOSAIC_WORKER_JOB_SHUTDOWN_BUDGET=30s + +# Lease duration for Experiment scheduling jobs. +MOSAIC_WORKER_SCHEDULE_LEASE=2m + +# Idle poll intervals. +MOSAIC_ANALYTICS_WORKER_POLL_INTERVAL=1s +MOSAIC_PROVIDER_WORKER_POLL_INTERVAL=1s + +# ============================================================================= +# Commerce providers +# ============================================================================= + +# Server-connected commerce providers stay disabled until a deployment supplies a +# valid versioned AES-256-GCM keyring. Never commit a real keyring. +# See docs/backend/operations/key-rotation.md for the format and rotation. MOSAIC_PROVIDER_INTEGRATIONS_ENABLED=false MOSAIC_PROVIDER_CREDENTIAL_KEYRING= + MOSAIC_REVENUECAT_BASE_URL=https://api.revenuecat.com/v2 MOSAIC_PROVIDER_REQUEST_TIMEOUT=8s MOSAIC_PROVIDER_OPERATION_TIMEOUT=60s @@ -44,6 +266,35 @@ MOSAIC_PROVIDER_CONNECT_TIMEOUT=3s MOSAIC_PROVIDER_MAX_RESPONSE_BYTES=2097152 MOSAIC_PROVIDER_MAX_ATTEMPTS=3 MOSAIC_PROVIDER_SNAPSHOT_TTL=24h -MOSAIC_PROVIDER_WORKER_POLL_INTERVAL=1s -MOSAIC_COMMERCE_PROVIDER_SCHEMA_PATH=../../protocol/schema/commerce-provider/v1/contract.schema.json -MOSAIC_COMMERCE_CONFIGURATION_SCHEMA_PATH=../../protocol/schema/commerce-configuration/v1/configuration.schema.json + +# ============================================================================= +# Protocol schema overrides (optional) +# ============================================================================= + +# The canonical protocol schemas are EMBEDDED in the binary, so a released image +# can never be packaged without them. Leave these empty unless you are +# deliberately pinning a schema to a file on disk; a configured path always wins +# over the embedded copy, and an unreadable override fails startup rather than +# silently falling back. +MOSAIC_PROTOCOL_V02_SCHEMA_PATH= +MOSAIC_COMMERCE_PROVIDER_SCHEMA_PATH= +MOSAIC_COMMERCE_PROVIDER_V2_SCHEMA_PATH= +MOSAIC_COMMERCE_CONFIGURATION_SCHEMA_PATH= +MOSAIC_COMMERCE_CONFIGURATION_V2_SCHEMA_PATH= +MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH= +MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH= + +# ============================================================================= +# Migration command +# ============================================================================= + +# Per-migration-step timeout (also settable with --timeout). +MOSAIC_MIGRATION_TIMEOUT=30m + +# ============================================================================= +# Integration tests (optional) +# ============================================================================= + +# When set, DATABASE_TEST_URL-gated integration suites run instead of skipping. +# Point it at a throwaway database: the suites create and drop data. +# DATABASE_TEST_URL=postgres://mosaic:mosaic_dev@localhost:5432/mosaic?sslmode=disable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..00de556e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,205 @@ +name: ci + +on: + push: + branches: [main, "phase/**"] + pull_request: + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: mosaic_ci + POSTGRES_PASSWORD: mosaic_ci_secret + POSTGRES_DB: mosaic_ci + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U mosaic_ci" + --health-interval 5s --health-timeout 5s --health-retries 12 + env: + DATABASE_TEST_URL: postgres://mosaic_ci:mosaic_ci_secret@localhost:5432/mosaic_ci?sslmode=disable + OBJECT_STORAGE_TEST_ENDPOINT: localhost:9000 + OBJECT_STORAGE_TEST_ACCESS_KEY: mosaic_ci + OBJECT_STORAGE_TEST_SECRET_KEY: mosaic_ci_secret + OBJECT_STORAGE_TEST_BUCKET: mosaic-ci-assets + steps: + - uses: actions/checkout@v4 + - name: Start MinIO (pinned to the Compose version) + run: | + docker run -d --name mosaic-ci-minio -p 9000:9000 \ + -e MINIO_ROOT_USER=mosaic_ci -e MINIO_ROOT_PASSWORD=mosaic_ci_secret \ + minio/minio:RELEASE.2025-07-23T15-54-02Z server /data + for i in $(seq 1 30); do + curl -fsS http://localhost:9000/minio/health/live && break; sleep 2 + done + docker run --rm --network host \ + minio/mc:RELEASE.2025-07-21T05-28-08Z \ + sh -c "mc alias set ci http://localhost:9000 mosaic_ci mosaic_ci_secret && mc mb --ignore-existing ci/mosaic-ci-assets" + - uses: actions/setup-go@v5 + with: + go-version-file: apps/api/go.mod + cache-dependency-path: apps/api/go.sum + - name: Format + working-directory: apps/api + run: test -z "$(gofmt -l .)" + - name: Vet + working-directory: apps/api + run: go vet ./... + - name: Build + working-directory: apps/api + run: go build ./... + - name: Tests (unit + PostgreSQL/object-storage integration) + working-directory: apps/api + run: go test -p 1 -count=1 ./... + - name: Writer/schema drift sweep + run: python3 scripts/check-writer-schema-drift.py + - name: Vulnerability scan (govulncheck) + working-directory: apps/api + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + - name: SBOM (Go module list) + working-directory: apps/api + run: | + go list -m -json all > ../../go-sbom.json + - uses: actions/upload-artifact@v4 + with: + name: sbom-go + path: go-sbom.json + + protocol: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: protocol/package-lock.json + - name: Install + working-directory: protocol + run: npm ci + - name: Tests + working-directory: protocol + run: npm test + - name: Contract validation + working-directory: protocol + run: npm run validate + - name: Generation drift gate + working-directory: protocol + run: | + npm run generate + git diff --exit-code -- . + - name: Dependency audit (production tree) + working-directory: protocol + run: npm audit --omit=dev --audit-level=high + + dashboard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/dashboard/package-lock.json + - name: Install + working-directory: apps/dashboard + run: npm ci + - name: Check (format, lint, typecheck, tests, relay, build) + working-directory: apps/dashboard + run: npm run check + - name: Dependency audit (production tree) + working-directory: apps/dashboard + run: npm audit --omit=dev --audit-level=high + - name: SBOM (npm dependency tree) + working-directory: apps/dashboard + run: npm ls --omit=dev --json > ../../npm-sbom-dashboard.json || true + - uses: actions/upload-artifact@v4 + with: + name: sbom-dashboard + path: npm-sbom-dashboard.json + + flutter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - name: Format + working-directory: sdk/flutter + run: dart format --output=none --set-exit-if-changed lib test example/lib + - name: Analyze + working-directory: sdk/flutter + run: flutter analyze + - name: Tests + working-directory: sdk/flutter + run: flutter test + + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - name: Tests, lint, assemble + working-directory: sdk/android + run: ./gradlew test lint assemble + - name: Example release build (R8 gate) + working-directory: examples/android-example + run: ../../sdk/android/gradlew -p . :app:assembleRelease + + ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Build (release) + run: swift build --package-path sdk/ios -c release + - name: Tests + run: swift test --package-path sdk/ios + - name: iOS 15 deployment-target typecheck + run: | + set -o pipefail + xcrun swiftc -typecheck -swift-version 6 \ + -target arm64-apple-ios15.0-simulator \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + sdk/ios/Sources/MosaicSDK/*.swift + + compose-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Prepare environment + run: cp .env.example .env + - name: Start core services + run: docker compose -p mosaic-ci up -d --build postgres minio minio-init migrate api + - name: Wait for readiness + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:8080/health/ready; then exit 0; fi + sleep 2 + done + docker compose -p mosaic-ci logs api migrate | tail -100 + exit 1 + - name: Teardown + if: always() + run: docker compose -p mosaic-ci down -v + + secret-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..9cfa6129 --- /dev/null +++ b/.swift-format @@ -0,0 +1,79 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentBlankLines" : false, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 2 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "orderedImports" : { + "includeConditionalImports" : false + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/BUGS.md b/BUGS.md deleted file mode 100644 index b88d95a4..00000000 --- a/BUGS.md +++ /dev/null @@ -1,5 +0,0 @@ -## BUGS - -List of notable bugs from QA - -- Separator component behaving wield in Login or Sign up page diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f04f2a87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,133 @@ +# Changelog + +All notable changes to the Mosaic server and dashboard, which version together +as one SemVer unit. SDKs version independently and keep their own changelogs +under `sdk/*/CHANGELOG.md`. + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Protocol schemas are embedded in the API binary (`go:embed`), so a released + image can no longer be built without them. Filesystem overrides still work for + operators pinning a schema; a drift test fails the build if an embedded copy + diverges from `protocol/schema/**`. +- Release identity: `ARG VERSION`/`COMMIT` ldflags stamping, reported by + `GET /health/live` and as OpenTelemetry resource attributes. +- `GET /health/ready` now checks PostgreSQL, object storage, migration + compatibility, and required encryption configuration, reporting safe per-check + codes and 503 while draining. +- The worker serves its own liveness and readiness endpoints + (`MOSAIC_WORKER_HEALTH_ADDRESS`, default `:8081`). +- `migrate preflight`, `migrate up-to`, `migrate down-to`, `migrate redo`, and a + richer `migrate status`. `down`, `down-to`, and `redo` require `--confirm`. +- Migration advisory locking and a generous configurable per-step timeout + (`--timeout` / `MOSAIC_MIGRATION_TIMEOUT`). +- `keyring` command: `validate`, `inspect` (envelope count per key ID), and + `rotate` (re-encrypt every envelope under the active key in transactional + batches). +- `loadgen` command: standard-library latency harness for configuration delivery + (including the 304 path) and analytics ingestion. +- `healthcheck` probe binary, so the distroless image can answer container + healthchecks. +- Trusted-proxy middleware: `X-Forwarded-For`/`X-Real-IP` are honoured only from + a peer inside `MOSAIC_TRUSTED_PROXY_CIDRS` (default: none). +- Baseline rate limits for authenticated dashboard APIs and for Placement and + Experiment decision routes, with `Retry-After` metadata and observable + rejections. +- `Strict-Transport-Security` (production only) and `Permissions-Policy` response + headers. +- Observability: otelchi HTTP metrics, pgxpool gauges, worker queue depth / + oldest-age / dead-letter gauges, delivery 304 ratio counter, object-storage + spans and per-operation timeouts. +- Operations documentation: backup and restore, upgrade, key rotation, + observability, performance measurement. +- Operational scripts: `backup-postgres.sh`, `restore-postgres.sh`, + `backup-objects.sh`, `restore-objects.sh`, `upgrade.sh`, `seed-perf-data.sql`. +- Dashboard Compose service, so `docker compose up` yields a complete + installation. +- Root `.dockerignore` and this changelog. + +### Changed + +- **Compose**: `provider-worker` is now `worker` and runs by default; the + `providers` profile gate is gone because analytics and Experiment jobs are part + of every installation. `api`, `worker`, `postgres`, `minio`, and `dashboard` + restart unless stopped, and `api` and `worker` have healthchecks. PostgreSQL and + MinIO ports are no longer published to the host by default; use + `--profile debug`. +- Startup configuration validation reports every problem in one structured, + secret-free error instead of failing one variable at a time. Production now + rejects wildcard and plaintext CORS origins, a `DATABASE_URL` without a + verifying `sslmode`, and plaintext object storage, each with a documented escape + hatch. +- Asset upload honours `MOSAIC_ASSET_MAX_UPLOAD_BYTES` instead of a hardcoded + 11 MiB ceiling; asset upload and event batch ingestion have per-route timeouts + so the global handler budget does not bound them. +- Connection pool tuning (`MAX_CONN_LIFETIME`, `MAX_CONN_IDLE_TIME`, + `HEALTH_CHECK_PERIOD`) and session `statement_timeout` / `lock_timeout` + defaults. +- Shutdown uses separate budgets for HTTP drain, telemetry flush, and pool close, + and flips readiness to draining first. +- Worker jobs run on a background context with a completion budget so a failure + record still commits during SIGTERM, and job families are polled round-robin. +- `.env.example` documents every variable the API and worker read, grouped and + commented. + +### Fixed + +- **Analytics ingestion boundary (Phase 6 release blocker).** The API now enforces + the per-event correlation and attribution allow-lists, rollout-tuple + all-or-none atomicity, and Rule Set pairing rules that the canonical semantic + validators define, for both v1 and v2 events, with stable machine-readable + permanent-rejection codes. Previously the canonical validator rejected documents + the runtime accepted. +- **Experiment scheduling job loss.** `LeaseSchedule` now reclaims expired leases + and `FinishSchedule` requeues with backoff until the retry budget is spent, then + records a terminal failure with a diagnostic code. Previously an expired lease + stranded the job forever and a transient failure was permanent, so a scheduled + Experiment start or completion could be lost silently. +- **Rate-limit bypass.** Limiter buckets and `remote_ip` log fields were derived + from an unconditionally trusted `X-Forwarded-For`, so any client could evade + every limit by rotating the header. +- **Unstartable release image.** The runtime image copied protocol schemas from a + build context that excluded some of them, and the API failed at startup. +- **Irreversible down migrations.** `00006`, `00010`, and `00018` deleted + provider mappings, rewrote immutable Configuration Releases, and destroyed + Experiment attribution on rollback. They now detect affected rows and refuse + with the restore-from-backup path. Immutability triggers are never disabled. +- Migration `00018` adds the analytics foreign keys `NOT VALID` and validates + separately, and migration `00019` builds the analysis index `CONCURRENTLY`, so + upgrading a populated database no longer blocks ingestion. +- `ListOrganizations` scanned every Organization in the installation and filtered + in the service; it is now a membership-joined query. +- The migrate command no longer wraps the entire run in a fixed 10-second + context, which aborted any real migration mid-flight. + +## [1.0.0-rc.1] - 2026-07-27 + +First release candidate. Phases 1 through 7 complete: cloud workspace, hosted +publishing, Studio, commerce providers, native store providers, Placement +decisions and targeting, analytics with identity and privacy operations, and +Experiments. + +- **Phase 1-2** — Protocol 0.2, Go modular-monolith API foundation, response and + error helpers, telemetry, PostgreSQL persistence with versioned migrations. +- **Phase 3** — Organizations, Projects, Applications, Environments, API keys, + Products, Plans, Entitlements; hosted publishing with immutable Paywall + versions, Configuration Releases, rollback, and digest-addressed Assets. +- **Phase 4** — Commerce Provider and Commerce Configuration contracts, + RevenueCat adapter, native StoreKit 2 and Google Play Billing providers, + AES-256-GCM credential envelopes under a multi-key keyring. +- **Phase 5** — Placement decisions, targeting Rule Sets, deterministic rollout + bucketing, QA overrides, Configuration Delivery v2. +- **Phase 6** — Analytics Event contract v1, ingestion boundary with + minimization, identity model, aggregation, retention, privacy export and + deletion. +- **Phase 7** — Experiments: variants, allocation, deterministic assignment, + exposure semantics, scheduling, metric snapshots, analysis, emergency stop, + Analytics Event v2, Configuration Delivery v3. + +Known limitations at this candidate are recorded in `docs/known-limitations.md`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..82453c5a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers through the repository's reporting channels: open a +GitHub issue for non-sensitive matters, or use GitHub's private +"Report content" and security-advisory mechanisms on this repository for +reports that should not be public. All complaints will be reviewed and +investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..fc8302d5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to Mosaic + +Thanks for contributing. This page covers setup, layout, conventions, and +what a good pull request looks like. The authoritative engineering rules live +in [AGENTS.md](AGENTS.md) and `docs/architecture/conventions/` — read the +relevant one before substantial changes. + +## Development setup + +Toolchains, per area (install only what you work on): + +- **Backend**: Go 1.26.x. +- **Protocol and dashboard**: Node.js 22.12+ and npm 10+. +- **Flutter SDK**: Flutter 3.22+ / Dart 3.4+. +- **iOS SDK**: Xcode 16+, Swift 6 language mode. +- **Android SDK**: JDK 17, Android SDK 36. +- **Dependencies**: Docker Engine 24+ with Compose v2. `cp .env.example .env + && docker compose up --build` starts the full stack (PostgreSQL, MinIO, + migrations, API, worker, dashboard, TLS edge); use + `docker compose --profile debug up` to expose PostgreSQL and the MinIO + console. + +For a host-run API: start PostgreSQL, then from `apps/api` run +`go run ./cmd/migrate up` and `go run ./cmd/api`. For local Studio with the +device-preview relay: `npm run dev:studio` from `apps/dashboard`, then open +`http://localhost:3000/studio`. + +There is intentionally no root package-manager workspace or shared Go module. + +## Repository layout + +```text +apps/api/ Go API, worker, and CLI commands (migrate, keyring, loadgen) +apps/dashboard/ TanStack Start dashboard and Studio +apps/worker/ worker-boundary documentation (binary lives in apps/api) +protocol/ canonical JSON Schemas, semantic validators, fixtures +sdk/flutter/ Flutter SDK sdk/ios/ Swift SDK sdk/android/ Kotlin SDK +packages/ design tokens and design system consumed by the dashboard +examples/ Flutter, iOS, and Android example host apps +deploy/ deployment profiles (local Caddy edge) +docs/ product, architecture, protocol, backend, and user docs +scripts/ backup/restore/upgrade operational scripts +``` + +## Conventions + +- [AGENTS.md](AGENTS.md) — the non-negotiable architecture decisions and + engineering rules. +- `docs/architecture/conventions/` — `backend.md`, `frontend.md`, + `protocol.md`, `sdk.md`, `testing.md`. +- Architecture changes that replace approved technology, add an + infrastructure category, or change a public contract require an ADR under + `docs/architecture/decisions/`. + +## Validation commands + +Run the checks for every area you touched, from the repository root unless a +directory is shown: + +```bash +# Backend (from apps/api) +gofmt -l . +go vet ./... +go test ./... + +# Protocol +npm --prefix protocol ci +npm --prefix protocol run check + +# Dashboard (format, lint, typecheck, test, build) +npm --prefix apps/dashboard ci +npm --prefix apps/dashboard run check + +# Flutter SDK (from sdk/flutter) +dart format --output=none --set-exit-if-changed lib test example/lib +flutter analyze +flutter test + +# iOS SDK +swift format lint --strict --recursive sdk/ios/Package.swift sdk/ios/Sources sdk/ios/Tests +swift test --package-path sdk/ios + +# Android SDK (from sdk/android) +./gradlew --no-daemon :mosaic:testDebugUnitTest :mosaic:lintDebug :mosaic:assembleDebug +``` + +Each SDK README's validation section lists the fuller per-platform matrix +(example builds, simulator suites, the iOS 15 typecheck, the Android R8 +guard). Backend integration tests require `DATABASE_TEST_URL` pointing at a +PostgreSQL instance. + +## Testing policy: minimum sufficient coverage + +Tests are risk controls, not deliverables measured by quantity. Before adding +a test, be able to name the behavior it protects, the realistic failure it +would catch, and why no existing test covers it. Prefer the smallest test at +the lowest useful layer, and do not add tests for coverage percentages, +framework behavior, or one-file-per-source symmetry. The full policy is in +[AGENTS.md](AGENTS.md) ("Testing Policy") and +`docs/architecture/conventions/testing.md`. + +## Pull request expectations + +- **Small vertical slices**: prefer a complete working journey over a large + disconnected layer. +- **Tests are part of the feature**: a change without its justified tests and + fixture updates is incomplete. +- **Docs updated**: public APIs, protocol schemas, environment variables, + conventions, deployment steps, and SDK behavior changes all require + documentation updates in the same PR. +- **Published resources stay immutable**; failure paths are handled; errors + are machine-readable and never leak internals or secrets. +- **No new infrastructure without an ADR**: routers, databases, queues, test + frameworks, and similar categories need an accepted ADR first. +- Do not edit generated files (API clients, route trees, protocol bundles); + change the source and regenerate. + +The full checklist is in [AGENTS.md](AGENTS.md) ("Pull Request Checklist"). + +## Code of conduct and security + +Participation is governed by [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). +Report vulnerabilities privately per [SECURITY.md](SECURITY.md) — never in a +public issue. diff --git a/README.md b/README.md index 562e6b9c..993ba747 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,120 @@ # Mosaic -Mosaic is an open-source, cross-platform app monetization platform built around -one platform-neutral protocol, three native SDKs, and one Studio. - -The repository includes the account-free local Studio and Local Preview `0.2` -workflow plus the Phase 3B hosted configuration-delivery private alpha. Hosted -mode adds Projects and Environments, browser sessions, Drafts, Assets, -Placements, immutable Paywall Versions, publishing, rollback, and Delivery v1 -clients for Flutter, SwiftUI, and Jetpack Compose. Commerce remains mock-only; -provider billing, analytics, experiments, targeting, and authoritative -Entitlement state are deliberately deferred. - -## Repository map - -```text -apps/api/ Go API foundation -apps/dashboard/ TanStack Start dashboard and local Studio -apps/worker/ deferred worker-boundary documentation -protocol/ Protocol 0.2 plus its Local Preview contract and fixtures -sdk/flutter/ Flutter renderer, hosted delivery/cache, fallback, and preview client -sdk/ios/ SwiftUI renderer, hosted delivery/cache, fallback, and preview client -sdk/android/ Compose renderer, hosted delivery/cache, fallback, and preview client -examples/ Flutter, iOS, and Android local-renderer and preview applications -docs/ architecture and foundation documentation -``` - -## Requirements - -- Node.js 22.12+ and npm 10+ -- Go 1.26.2+ -- Flutter 3.19+ and Dart 3.3+ -- Swift 6+ and Xcode 16+ -- JDK 17 and Android SDK 36 -- Docker with Compose for the durable local PostgreSQL workflow - -The SDK platform minimums remain working baselines pending stable public SDK -versioning. - -## Install and validate - -Run these commands from the repository root unless a directory change is shown. - -Protocol schema and conformance: - -```bash -npm --prefix protocol ci -npm --prefix protocol run check -``` - -Backend formatting and tests: +Mosaic is an open-source, cross-platform paywall and monetization platform. +Create, publish, and update production-quality native paywalls across +Flutter, SwiftUI, and Jetpack Compose without releasing a new app version. + +Mosaic covers the full monetization loop: visual paywall authoring in +Studio, remote configuration with immutable versioning and rollback, +placements with deterministic on-device targeting, publishing per +Environment, analytics, experiments with honest descriptive statistics, and +provider-independent commerce through RevenueCat, StoreKit 2, Google Play +Billing, or your own custom adapter. + +The architecture is one platform-neutral protocol, three native renderers, +and one Studio. No WebView rendering, and no executable code in remote +configuration. + +## Status + +Mosaic is a **v1 release candidate**, self-hostable today. + +- The server and dashboard version together and are preparing for a + `v1.0.0` General Availability release + ([draft release notes](docs/releases/v1.0.0-notes.md)). +- The SDKs are **pre-1.0 (`0.x-dev`) and not published to any package + registry**; install them by pinning this repository at an exact tag or + commit, or by local path + ([SDK quickstarts](docs/guides/sdk-quickstarts.md)). +- Commerce adapters are implemented and contract-tested but **not + live-verified** against the RevenueCat sandbox, Apple sandbox, or a Google + Play test track. +- All documented limitations live in + [docs/known-limitations.md](docs/known-limitations.md). + +## Quickstart ```bash -cd apps/api -gofmt -l . -go test ./... -go vet ./... +cp .env.example .env +docker compose up --build ``` -Dashboard formatting, linting, type checks, tests, and production build: +This starts a complete installation: PostgreSQL, MinIO, migrations, the API, +the worker, the dashboard, and a local TLS edge. Open the dashboard at +`http://localhost:3000`. -```bash -npm --prefix apps/dashboard ci -npm --prefix apps/dashboard run check -``` - -Flutter SDK: - -```bash -cd sdk/flutter -flutter pub get -dart format --output=none --set-exit-if-changed lib test example/lib -flutter analyze -flutter test -cd example -flutter build bundle --no-pub -``` +The local Studio needs no account and no backend: open +`http://localhost:3000/studio` (or run `npm run dev:studio` from +`apps/dashboard` for development with the device-preview relay). -Swift SDK: - -```bash -swift format lint --strict --recursive sdk/ios/Package.swift sdk/ios/Sources sdk/ios/Tests -swift build --package-path sdk/ios -swift test --package-path sdk/ios -xcodebuild -project examples/ios-example/MosaicExample.xcodeproj \ - -scheme MosaicExample \ - -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath examples/ios-example/.build/DerivedData \ - CODE_SIGNING_ALLOWED=NO build -``` +## Supported matrix (v1) -Android SDK: +| Area | Supported | +| --- | --- | +| PostgreSQL | 17 (16 expected-compatible but unsupported) | +| Object storage | S3-compatible; MinIO `RELEASE.2025-07-23T15-54-02Z` tested | +| Docker | Engine 24+ with Compose v2; amd64 and arm64 | +| Browsers | Chrome/Edge 111+, Safari 16.4+, Firefox 128+ (Studio desktop-only, ≥768 px) | +| Go (build from source) | 1.26.x | +| Flutter SDK | Flutter 3.22+ / Dart 3.4+ | +| iOS SDK | iOS 15+, Swift 6 language mode, Xcode 16+ | +| Android SDK | API 24+, JDK 17 | -```bash -cd sdk/android -./gradlew --no-daemon :mosaic:assembleDebug :mosaic:testDebugUnitTest \ - :mosaic:lintDebug :mosaic:assembleDebugAndroidTest -``` +The dashboard must be served over **HTTPS, or on `localhost`**: the session +cookie is `Secure` and clipboard access requires a secure context, so plain +`http://` on any other host produces a sign-in loop. -Start the durable API stack with `cp .env.example .env && docker compose up --build`. -The database uses the named `mosaic_postgres_data` volume. For a host-run API, -start PostgreSQL, run `go run ./cmd/migrate up`, then `go run ./cmd/api` from -`apps/api`; both commands load `apps/api/.env`, with existing process variables -taking precedence. To start the account-free -Studio and its loopback preview relay together, run: +## Repository map -```bash -cd apps/dashboard -npm run dev:studio +```text +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 +examples/ example host apps for all three platforms +deploy/ scripts/ deployment profile and operational scripts +docs/ documentation ``` -Open `http://localhost:3000/studio`. Preview clients connect to -`ws://127.0.0.1:4317/preview` using the local session documented by each example -application. `npm run dev` remains available when only the dashboard is needed. - -Hosted Studio routes use the browser session APIs and remain separate from the -account-free `/studio` route. The local stack includes PostgreSQL, MinIO, and a -development HTTPS edge for immutable hosted Asset URLs. Trust the generated -local development certificate only on test devices that must load those Asset -URLs; production deployments must configure an externally reachable HTTPS -`MOSAIC_PUBLIC_ASSET_BASE_URL`. - ## Documentation -- [Product roadmap](docs/product/roadmap.md) -- [Architecture overview](docs/architecture/overview.md) -- [Protocol 0.2](docs/protocol/v0.2.md) -- [Backend foundation](docs/backend/api-foundation.md) -- [Dashboard foundation](docs/dashboard/foundation.md) -- [Phase 1 SDK renderers](docs/sdk/README.md) -- [Phase 1 review](docs/reviews/phase-1-review.md) -- [Phase 2 review](docs/reviews/phase-2.md) -- [Phase 3B hosted publishing](docs/backend/phase-3b-hosted-publishing.md) -- [Configuration Delivery v1](docs/protocol/configuration-delivery-v1.md) -- [Phase 3B review](docs/reviews/phase-3b.md) - -There is intentionally no root package-manager workspace or shared Go module -yet. Hosted configuration is additive: `/studio` remains local-first and does -not require an account or reachable backend. The roadmap's `mosaic dev` -convenience command is deferred; local preview uses `npm run dev:studio` -directly. +User guides: + +- [Installation](docs/guides/installation.md) +- [Upgrade](docs/guides/upgrade.md) +- [Backup and restore](docs/guides/backup-restore.md) +- [Troubleshooting](docs/guides/troubleshooting.md) +- [Catalog: Products and Entitlements](docs/guides/catalog.md) +- [Commerce providers](docs/guides/providers.md) +- [Studio](docs/guides/studio.md) +- [Publishing](docs/guides/publishing.md) +- [Placements](docs/guides/placements.md) +- [Targeting](docs/guides/targeting.md) +- [Analytics](docs/guides/analytics.md) +- [Experiments](docs/guides/experiments.md) +- [Privacy](docs/guides/privacy.md) +- [SDK quickstarts](docs/guides/sdk-quickstarts.md) and the + [SDK overview](docs/sdk/README.md) + +Operations: [operator runbooks](docs/runbooks/README.md), +[backup and restore](docs/backend/operations/backup-restore.md), +[upgrade](docs/backend/operations/upgrade.md), +[key rotation](docs/backend/operations/key-rotation.md), +[observability](docs/backend/operations/observability.md), +[performance](docs/backend/operations/performance.md). The environment +reference is [`.env.example`](.env.example). + +Reference: [protocol contracts](docs/protocol/), +[architecture overview](docs/architecture/overview.md), +[product roadmap](docs/product/roadmap.md), +[known limitations](docs/known-limitations.md), +[support policy](docs/support.md). + +Contributing: [CONTRIBUTING.md](CONTRIBUTING.md), +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and — for vulnerabilities — +[SECURITY.md](SECURITY.md). + +## License + +Mosaic is licensed under the [Apache License 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..e45019aa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest minor release of the current major +version only. Older minors do not receive security patches; upgrade to the +latest minor to stay supported. SDKs version independently and follow the +same latest-minor rule. + +## Reporting a vulnerability + +Report vulnerabilities privately to the maintainers through GitHub security +advisories on this repository ("Report a vulnerability" under the Security +tab). **Do not open public issues or pull requests for vulnerabilities.** + +Include the affected component, a reproduction or proof of concept, the +impact you believe it has, and the version or commit you tested. + +- **Acknowledgement target**: 5 business days. +- **Coordinated disclosure**: 90 days by default from the report, or earlier + by mutual agreement once a fix is released. We will credit reporters who + want credit. + +## Scope + +In scope: the Mosaic API server, the worker, the dashboard (including +Studio), the Flutter/iOS/Android SDKs, and the protocol tooling under +`protocol/`. Out of scope: vulnerabilities exclusively in third-party +dependencies (report upstream, though we still want to know if Mosaic's usage +is affected), and issues requiring a misconfigured deployment that the +documentation explicitly warns against. + +## Operator hardening + +Mosaic's security model assumes the operator completes these steps: + +- **Restrict signup at the edge.** Self-hosted signup + (`POST /v1/auth/signup`) is deliberately ungated in the application (owner + decision D9). Restricting it — at your reverse proxy or firewall — after + creating your accounts is a documented operator responsibility. Anyone who + can reach the endpoint can create an account. +- **Require TLS in production.** Terminate TLS at your edge (the Compose + profile includes a Caddy example under `deploy/local/`); production + configuration validation rejects plaintext CORS origins, a `DATABASE_URL` + without a verifying `sslmode`, and plaintext object storage. +- **Configure trusted proxies.** Set `MOSAIC_TRUSTED_PROXY_CIDRS` to exactly + your proxy addresses. By default no proxy is trusted and + `X-Forwarded-For`/`X-Real-IP` are ignored, so rate limiting keys on the + TCP peer; setting the CIDRs too broadly makes client IPs spoofable. +- **Manage secrets via environment variables.** All configuration is + environment-driven (see `.env.example`); secrets are never generated + silently or printed. Provider credentials are encrypted at rest with + AES-256-GCM envelopes under a multi-key keyring + ([ADR 0019](docs/architecture/decisions/0019-encrypt-provider-credentials-with-aes-gcm-envelopes.md)); + keep `MOSAIC_PROVIDER_CREDENTIAL_KEYRING` in your secret manager and back + 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). + +Browser sessions use opaque tokens stored as SHA-256 digests +([ADR 0017](docs/architecture/decisions/0017-use-opaque-browser-sessions.md)); +API keys are hashed at rest and rotatable. diff --git a/TEST.md b/TEST.md deleted file mode 100644 index 9ed5d442..00000000 --- a/TEST.md +++ /dev/null @@ -1,2 +0,0 @@ -User: mujeeb.muhideen@gmail.com -Password: voXbox-mafzys-vudra6 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 3778fcee..25327371 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,21 +1,40 @@ -FROM golang:1.26.2-alpine AS build +FROM golang:1.26.5-alpine AS build +ARG VERSION=dev +ARG COMMIT="" +ARG BUILD_DATE="" WORKDIR /src/apps/api COPY apps/api/go.mod apps/api/go.sum ./ RUN go mod download COPY apps/api/ ./ -RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api && \ - CGO_ENABLED=0 go build -o /out/migrate ./cmd/migrate && \ - CGO_ENABLED=0 go build -o /out/worker ./cmd/worker +# Protocol schemas are embedded in the binaries (internal/platform/protocolschema), +# so the runtime image needs no schema files and cannot drift from the contract. +RUN LDFLAGS="-s -w \ + -X github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo.version=${VERSION} \ + -X github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo.commit=${COMMIT} \ + -X github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo.date=${BUILD_DATE}" && \ + CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o /out/api ./cmd/api && \ + CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o /out/migrate ./cmd/migrate && \ + CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o /out/worker ./cmd/worker && \ + CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o /out/keyring ./cmd/keyring && \ + CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o /out/loadgen ./cmd/loadgen && \ + CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o /out/healthcheck ./cmd/healthcheck FROM gcr.io/distroless/static-debian12:nonroot +ARG VERSION=dev +ARG COMMIT="" +LABEL org.opencontainers.image.title="Mosaic API" \ + org.opencontainers.image.source="https://github.com/Mujhtech/mosaic" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${COMMIT}" COPY --from=build /out/api /usr/local/bin/api COPY --from=build /out/migrate /usr/local/bin/migrate COPY --from=build /out/worker /usr/local/bin/worker -COPY protocol/schema/v0.2/paywall.schema.json /usr/share/mosaic/paywall-v0.2.schema.json -COPY protocol/schema/commerce-provider/v1/contract.schema.json /usr/share/mosaic/commerce-provider-v1.schema.json -COPY protocol/schema/commerce-configuration/v1/configuration.schema.json /usr/share/mosaic/commerce-configuration-v1.schema.json -ENV MOSAIC_PROTOCOL_V02_SCHEMA_PATH=/usr/share/mosaic/paywall-v0.2.schema.json -ENV MOSAIC_COMMERCE_PROVIDER_SCHEMA_PATH=/usr/share/mosaic/commerce-provider-v1.schema.json -ENV MOSAIC_COMMERCE_CONFIGURATION_SCHEMA_PATH=/usr/share/mosaic/commerce-configuration-v1.schema.json +COPY --from=build /out/keyring /usr/local/bin/keyring +COPY --from=build /out/loadgen /usr/local/bin/loadgen +# The distroless base has no shell, curl, or wget; container healthchecks use +# this probe binary instead. +COPY --from=build /out/healthcheck /usr/local/bin/healthcheck USER nonroot:nonroot +EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/api"] diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index d81c8e63..fa2afc36 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -5,10 +5,12 @@ import ( "crypto/rand" "errors" "fmt" + "io" "net/http" "os" "os/signal" "syscall" + "time" "github.com/rs/zerolog" @@ -21,6 +23,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/analyticspostgres" "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" "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" @@ -30,11 +33,13 @@ import ( "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/placementdecisionpostgres" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/protocolschema" "github.com/Mujhtech/mosaic/apps/api/internal/platform/ratelimit" "github.com/Mujhtech/mosaic/apps/api/internal/platform/revenuecat" "github.com/Mujhtech/mosaic/apps/api/internal/platform/telemetry" "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" browserauthhttp "github.com/Mujhtech/mosaic/apps/api/internal/transport/browserauth" + "github.com/Mujhtech/mosaic/apps/api/internal/transport/health" ) func main() { @@ -50,6 +55,36 @@ func main() { } } +// openSchemas resolves every canonical schema the runtime compiles. Schemas are +// embedded in the binary; a configured path is an explicit operator override. +func openSchemas(cfg config.Config) (map[protocolschema.Schema]io.ReadCloser, error) { + overrides := map[protocolschema.Schema]string{ + protocolschema.PaywallV02: cfg.Protocol.V02SchemaPath, + protocolschema.CommerceProviderV1: cfg.Protocol.CommerceProviderSchemaPath, + protocolschema.CommerceProviderV2: cfg.Protocol.CommerceProviderV2SchemaPath, + protocolschema.CommerceConfigurationV1: cfg.Protocol.CommerceConfigurationSchemaPath, + protocolschema.CommerceConfigurationV2: cfg.Protocol.CommerceConfigurationV2SchemaPath, + protocolschema.AnalyticsEventV1: cfg.Analytics.EventSchemaPath, + protocolschema.AnalyticsEventV2: cfg.Analytics.EventV2SchemaPath, + } + readers := make(map[protocolschema.Schema]io.ReadCloser, len(overrides)) + for schema, override := range overrides { + reader, err := protocolschema.Open(schema, override) + if err != nil { + closeSchemas(readers) + return nil, err + } + readers[schema] = reader + } + return readers, nil +} + +func closeSchemas(readers map[protocolschema.Schema]io.ReadCloser) { + for _, reader := range readers { + _ = reader.Close() + } +} + func run() (runErr error) { cfg, err := config.Load() if err != nil { @@ -60,9 +95,11 @@ func run() (runErr error) { if err != nil { return fmt.Errorf("configure logging: %w", err) } + build := buildinfo.Current() logger = logger.With(). Str("service", cfg.Telemetry.ServiceName). Str("environment", cfg.Environment). + Str("version", build.Version). Logger() runContext, stop := signal.NotifyContext( @@ -76,14 +113,17 @@ func run() (runErr error) { ServiceName: cfg.Telemetry.ServiceName, Environment: cfg.Environment, OTLPEndpoint: cfg.Telemetry.OTLPEndpoint, + Logger: logger, }) if err != nil { return fmt.Errorf("configure telemetry: %w", err) } defer func() { + // Telemetry flush has its own budget so a slow collector cannot consume + // the HTTP drain budget or delay closing the database pool. shutdownContext, cancel := context.WithTimeout( context.Background(), - cfg.HTTP.ShutdownTimeout, + cfg.HTTP.TelemetryShutdownTimeout, ) defer cancel() @@ -93,89 +133,71 @@ func run() (runErr error) { }() databasePool, err := database.Open(runContext, database.Config{ - URL: cfg.Database.URL, - MaxConnections: cfg.Database.MaxConnections, - MinConnections: cfg.Database.MinConnections, - ConnectTimeout: cfg.Database.ConnectTimeout, + URL: cfg.Database.URL, + MaxConnections: cfg.Database.MaxConnections, + MinConnections: cfg.Database.MinConnections, + ConnectTimeout: cfg.Database.ConnectTimeout, + MaxConnLifetime: cfg.Database.MaxConnLifetime, + MaxConnIdleTime: cfg.Database.MaxConnIdleTime, + HealthCheckPeriod: cfg.Database.HealthCheckPeriod, + StatementTimeout: cfg.Database.StatementTimeout, + LockTimeout: cfg.Database.LockTimeout, }) if err != nil { return fmt.Errorf("initialize database: %w", err) } - defer databasePool.Close() + defer func() { + // Close is synchronous; the budget bounds how long callers may still be + // returning connections before the process exits. + closeContext, cancel := context.WithTimeout(context.Background(), cfg.Database.CloseTimeout) + defer cancel() + done := make(chan struct{}) + go func() { databasePool.Close(); close(done) }() + select { + case <-done: + case <-closeContext.Done(): + logger.Warn().Msg("database pool did not close within the configured budget") + } + }() + if err := database.RegisterPoolMetrics(databasePool); err != nil { + return fmt.Errorf("register database metrics: %w", err) + } - protocolSchema, err := os.Open(cfg.Protocol.V02SchemaPath) - if err != nil { - return fmt.Errorf("open canonical Protocol 0.2 schema: %w", err) + // Startup fails closed when the schema does not match this binary; Mosaic + // never migrates during normal startup. + if err := database.MigrationCompatibility(runContext, databasePool); err != nil { + return fmt.Errorf("verify migration compatibility: %w", err) } - protocolValidator, err := hostedpublishing.CompileProtocolValidator(protocolSchema) - closeSchemaErr := protocolSchema.Close() + + schemas, err := openSchemas(cfg) if err != nil { return err } - if closeSchemaErr != nil { - return fmt.Errorf("close canonical Protocol 0.2 schema: %w", closeSchemaErr) - } - commerceProviderSchema, err := os.Open(cfg.Protocol.CommerceProviderSchemaPath) - if err != nil { - return fmt.Errorf("open canonical Commerce Provider v1 schema: %w", err) - } - commerceConfigurationSchema, err := os.Open(cfg.Protocol.CommerceConfigurationSchemaPath) + protocolValidator, err := hostedpublishing.CompileProtocolValidator(schemas[protocolschema.PaywallV02]) if err != nil { - _ = commerceProviderSchema.Close() - return fmt.Errorf("open canonical Commerce Configuration v1 schema: %w", err) - } - commerceProviderV2Schema, err := os.Open(cfg.Protocol.CommerceProviderV2SchemaPath) - if err != nil { - _ = commerceProviderSchema.Close() - _ = commerceConfigurationSchema.Close() - return fmt.Errorf("open canonical Commerce Provider v2 schema: %w", err) - } - commerceConfigurationV2Schema, err := os.Open(cfg.Protocol.CommerceConfigurationV2SchemaPath) - if err != nil { - _ = commerceProviderSchema.Close() - _ = commerceConfigurationSchema.Close() - _ = commerceProviderV2Schema.Close() - return fmt.Errorf("open canonical Commerce Configuration v2 schema: %w", err) + closeSchemas(schemas) + return err } commerceValidator, err := hostedpublishing.CompileCommerceConfigurationValidator( - commerceProviderSchema, commerceConfigurationSchema, - commerceProviderV2Schema, commerceConfigurationV2Schema, + schemas[protocolschema.CommerceProviderV1], schemas[protocolschema.CommerceConfigurationV1], + schemas[protocolschema.CommerceProviderV2], schemas[protocolschema.CommerceConfigurationV2], ) - closeCommerceProviderErr := commerceProviderSchema.Close() - closeCommerceConfigurationErr := commerceConfigurationSchema.Close() - closeCommerceProviderV2Err := commerceProviderV2Schema.Close() - closeCommerceConfigurationV2Err := commerceConfigurationV2Schema.Close() if err != nil { + closeSchemas(schemas) return err } - if closeErr := errors.Join(closeCommerceProviderErr, closeCommerceConfigurationErr, closeCommerceProviderV2Err, closeCommerceConfigurationV2Err); closeErr != nil { - return fmt.Errorf("close canonical commerce schemas: %w", closeErr) - } - analyticsSchema, err := os.Open(cfg.Analytics.EventSchemaPath) - if err != nil { - return fmt.Errorf("open canonical Analytics Event v1 schema: %w", err) - } - analyticsV2Schema, err := os.Open(cfg.Analytics.EventV2SchemaPath) - if err != nil { - _ = analyticsSchema.Close() - return fmt.Errorf("open canonical Analytics Event v2 schema: %w", err) - } - analyticsValidator, err := analytics.CompileSchemaValidators(analyticsSchema, analyticsV2Schema) - closeAnalyticsSchemaErr := analyticsSchema.Close() - closeAnalyticsV2SchemaErr := analyticsV2Schema.Close() + analyticsValidator, err := analytics.CompileSchemaValidators( + schemas[protocolschema.AnalyticsEventV1], schemas[protocolschema.AnalyticsEventV2], + ) + closeSchemas(schemas) if err != nil { return err } - if closeAnalyticsSchemaErr != nil { - return fmt.Errorf("close canonical Analytics Event v1 schema: %w", closeAnalyticsSchemaErr) - } - if closeAnalyticsV2SchemaErr != nil { - return fmt.Errorf("close canonical Analytics Event v2 schema: %w", closeAnalyticsV2SchemaErr) - } objectStore, err := objectstoreminio.New(objectstoreminio.Config{ Endpoint: cfg.ObjectStore.Endpoint, AccessKey: cfg.ObjectStore.AccessKey, SecretKey: cfg.ObjectStore.SecretKey, Bucket: cfg.ObjectStore.Bucket, UseTLS: cfg.ObjectStore.UseTLS, + OperationTimeout: cfg.ObjectStore.OperationTimeout, CheckTimeout: cfg.ObjectStore.CheckTimeout, }) if err != nil { return err @@ -219,13 +241,38 @@ func run() (runErr error) { experimentService := experiment.NewService(experimentpostgres.New(databasePool)) deliveryLimiter := ratelimit.New(cfg.Delivery.RequestsPerMinute, cfg.Delivery.Burst, cfg.Delivery.LimiterEntries) authenticationLimiter := ratelimit.New(cfg.BrowserAuth.RequestsPerMinute, cfg.BrowserAuth.Burst, cfg.BrowserAuth.LimiterEntries) + apiLimiter := ratelimit.New(cfg.Delivery.APIRequestsPerMinute, cfg.Delivery.APIBurst, cfg.Delivery.LimiterEntries) + decisionLimiter := ratelimit.New(cfg.Delivery.DecisionRequestsPerMinute, cfg.Delivery.DecisionBurst, cfg.Delivery.LimiterEntries) + uploadLimiter := ratelimit.New(cfg.Delivery.UploadRequestsPerMinute, cfg.Delivery.UploadBurst, cfg.Delivery.LimiterEntries) + exportLimiter := ratelimit.New(cfg.Delivery.ExportRequestsPerMinute, cfg.Delivery.ExportBurst, cfg.Delivery.LimiterEntries) analyticsIPLimiter := ratelimit.New(cfg.Analytics.IPRequestsPerMinute, cfg.Analytics.IPBurst, cfg.Analytics.LimiterEntries) analyticsKeyLimiter := ratelimit.New(cfg.Analytics.KeyBatchesPerMinute, cfg.Analytics.KeyBatchBurst, cfg.Analytics.LimiterEntries) analyticsEventLimiter := ratelimit.New(cfg.Analytics.KeyEventsPerMinute, cfg.Analytics.KeyEventBurst, cfg.Analytics.LimiterEntries) + + readiness := health.NewReadiness( + health.Check{Name: "postgresql", Code: "database_unavailable", Probe: func(ctx context.Context) error { + return database.Ping(ctx, databasePool) + }}, + health.Check{Name: "object_storage", Code: "object_storage_unavailable", Probe: objectStore.Check}, + health.Check{Name: "migrations", Code: "migration_incompatible", DependsOn: "postgresql", Probe: func(ctx context.Context) error { + return database.MigrationCompatibility(ctx, databasePool) + }}, + health.Check{Name: "encryption", Code: "encryption_misconfigured", Probe: func(context.Context) error { + if !cfg.Providers.Enabled { + return nil + } + return providercredential.ValidateKeyring(cfg.Providers.CredentialKeyring) + }}, + ) + handler := httpserver.NewWithDependencies(httpserver.Config{ - ServiceName: cfg.Telemetry.ServiceName, - AllowedOrigins: cfg.HTTP.CORSAllowedOrigins, - RequestTimeout: cfg.HTTP.HandlerTimeout, + ServiceName: cfg.Telemetry.ServiceName, + AllowedOrigins: cfg.HTTP.CORSAllowedOrigins, + RequestTimeout: cfg.HTTP.HandlerTimeout, + UploadTimeout: cfg.HTTP.UploadTimeout, + IngestTimeout: cfg.HTTP.IngestTimeout, + TrustedProxyCIDRs: cfg.HTTP.TrustedProxyCIDRs, + EnableHSTS: cfg.ProductionLike(), }, logger, httpserver.Dependencies{ BrowserAuth: browserAuthService, BrowserAuthConfig: browserauthhttp.Config{CookieSecure: cfg.BrowserAuth.CookieSecure, CookieDomain: cfg.BrowserAuth.CookieDomain, AllowedOrigins: cfg.HTTP.CORSAllowedOrigins, RateLimiter: authenticationLimiter}, @@ -239,6 +286,11 @@ func run() (runErr error) { AnalyticsKeyLimiter: analyticsKeyLimiter, AnalyticsEventLimiter: analyticsEventLimiter, Experiment: experimentService, + APILimiter: apiLimiter, + DecisionLimiter: decisionLimiter, + UploadLimiter: uploadLimiter, + ExportLimiter: exportLimiter, + Readiness: readiness, ReadinessChecker: database.HealthChecker{Pinger: databasePool}, }) @@ -257,7 +309,10 @@ func run() (runErr error) { serverErrors <- server.ListenAndServe() }() - logger.Info().Str("address", cfg.HTTP.Address).Msg("api listening") + logger.Info(). + Str("address", cfg.HTTP.Address). + Str("commit", build.Commit). + Msg("api listening") select { case err := <-serverErrors: @@ -269,6 +324,23 @@ func run() (runErr error) { logger.Info().Msg("api shutdown requested") } + // Readiness flips first so a load balancer stops routing new work before + // in-flight requests are drained. + readiness.StartDraining() + + // Then keep serving for the drain delay. http.Server.Shutdown closes every + // listener immediately, so without this pause the draining state is + // unobservable: a load balancer polling readiness gets connection-refused + // instead of the 503 that tells it to stop routing, and every deploy sheds + // traffic at the edge. The delay is the window in which readiness answers + // 503 while the instance still serves requests already in flight. + if delay := cfg.HTTP.DrainDelay; delay > 0 { + logger.Info().Dur("drain_delay", delay).Msg("api draining: readiness now reports unavailable") + timer := time.NewTimer(delay) + defer timer.Stop() + <-timer.C + } + shutdownContext, cancel := context.WithTimeout( context.Background(), cfg.HTTP.ShutdownTimeout, diff --git a/apps/api/cmd/healthcheck/main.go b/apps/api/cmd/healthcheck/main.go new file mode 100644 index 00000000..31219a56 --- /dev/null +++ b/apps/api/cmd/healthcheck/main.go @@ -0,0 +1,32 @@ +// Command healthcheck performs a single HTTP probe and exits 0 on success. +// +// The Mosaic runtime image is distroless and contains no shell, curl, or wget, +// so container healthchecks need a probe binary that ships inside the image. +// +// healthcheck http://127.0.0.1:8080/health/ready +package main + +import ( + "fmt" + "net/http" + "os" + "time" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: healthcheck ") + os.Exit(2) + } + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "probe failed: %v\n", err) + os.Exit(1) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + fmt.Fprintf(os.Stderr, "probe returned HTTP %d\n", response.StatusCode) + os.Exit(1) + } +} diff --git a/apps/api/cmd/keyring/main.go b/apps/api/cmd/keyring/main.go new file mode 100644 index 00000000..cb06b23d --- /dev/null +++ b/apps/api/cmd/keyring/main.go @@ -0,0 +1,223 @@ +// Command keyring operates on Mosaic's provider-credential keyring. +// +// keyring validate check MOSAIC_PROVIDER_CREDENTIAL_KEYRING is usable +// keyring inspect report envelope counts per key ID +// keyring rotate re-encrypt every envelope under the active key +// +// The command never prints key material, ciphertext, or decrypted credentials. +// Rotation requires every key that currently seals an envelope to still be +// present in the keyring; removing a key before rotating makes the credentials +// it sealed permanently undecryptable. +package main + +import ( + "context" + "crypto/rand" + "errors" + "flag" + "fmt" + "os" + "sort" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" + "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/providercredential" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "keyring: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + action, flagArguments := splitArguments(args) + flags := flag.NewFlagSet("keyring", flag.ContinueOnError) + batchSize := flags.Int("batch-size", 100, "envelopes re-encrypted per transaction") + dryRun := flags.Bool("dry-run", false, "report what rotation would do without writing") + timeout := flags.Duration("timeout", 30*time.Minute, "maximum duration for the whole command") + if err := flags.Parse(flagArguments); err != nil { + return err + } + if action == "" || len(flags.Args()) != 0 { + return errors.New("usage: keyring [flags]") + } + if *batchSize < 1 { + return errors.New("--batch-size must be at least 1") + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + if cfg.Providers.CredentialKeyring == "" { + return errors.New("MOSAIC_PROVIDER_CREDENTIAL_KEYRING is not set") + } + cipher, err := providercredential.NewAESGCMCipher(cfg.Providers.CredentialKeyring, rand.Reader) + if err != nil { + return fmt.Errorf("the configured keyring is not usable: %w", err) + } + + if action == "validate" { + fmt.Printf("keyring is valid\nactive key id: %s\nkey ids: %v\n", cipher.ActiveKeyID(), cipher.KeyIDs()) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + pool, err := database.Open(ctx, database.Config{ + URL: cfg.Database.URL, MaxConnections: cfg.Database.MaxConnections, + MinConnections: cfg.Database.MinConnections, ConnectTimeout: cfg.Database.ConnectTimeout, + StatementTimeout: cfg.Database.StatementTimeout, LockTimeout: cfg.Database.LockTimeout, + }) + if err != nil { + return fmt.Errorf("initialize database: %w", err) + } + defer pool.Close() + repository := cloudworkspacepostgres.New(pool) + + switch action { + case "inspect": + return inspect(ctx, repository, cipher) + case "rotate": + return rotate(ctx, repository, cipher, *batchSize, *dryRun) + default: + return fmt.Errorf("unsupported keyring action %q", action) + } +} + +func inspect(ctx context.Context, repository *cloudworkspacepostgres.Repository, cipher *providercredential.AESGCMCipher) error { + counts, err := repository.CredentialCountsByKeyID(ctx) + if err != nil { + return err + } + known := make(map[string]struct{}, len(cipher.KeyIDs())) + for _, id := range cipher.KeyIDs() { + known[id] = struct{}{} + } + ids := make([]string, 0, len(counts)) + for id := range counts { + ids = append(ids, id) + } + sort.Strings(ids) + + fmt.Printf("active key id: %s\n\n", cipher.ActiveKeyID()) + fmt.Printf("%-32s %-10s %s\n", "KEY ID", "ENVELOPES", "STATUS") + missing := 0 + stale := int64(0) + for _, id := range ids { + status := "retired (still in keyring)" + switch { + case id == cipher.ActiveKeyID(): + status = "active" + default: + if _, ok := known[id]; !ok { + status = "MISSING FROM KEYRING" + missing++ + } + stale += counts[id] + } + fmt.Printf("%-32s %-10d %s\n", id, counts[id], status) + } + for _, id := range cipher.KeyIDs() { + if _, ok := counts[id]; !ok { + fmt.Printf("%-32s %-10d %s\n", id, 0, "unused") + } + } + fmt.Printf("\n%d envelope(s) not under the active key\n", stale) + if missing > 0 { + return fmt.Errorf("%d key id(s) sealing envelopes are absent from the keyring; those credentials cannot be decrypted or rotated", missing) + } + return nil +} + +func rotate(ctx context.Context, repository *cloudworkspacepostgres.Repository, cipher *providercredential.AESGCMCipher, batchSize int, dryRun bool) error { + activeKeyID := cipher.ActiveKeyID() + rotated := 0 + for { + records, err := repository.CredentialsNotUnderKey(ctx, activeKeyID, batchSize) + if err != nil { + return err + } + if len(records) == 0 { + break + } + if dryRun { + fmt.Printf("would rotate %d envelope(s) in this batch\n", len(records)) + rotated += len(records) + // A dry run cannot page: nothing is written, so the same batch would + // be returned forever. + break + } + resealed := make([]cloudworkspace.ProviderCredentialRecord, 0, len(records)) + for _, record := range records { + scope := providercredential.Scope{ + OrganizationID: record.OrganizationID, + ProjectID: record.ProjectID, + ConnectionID: record.ConnectionID, + CredentialClass: record.Class, + } + plaintext, err := cipher.Decrypt(providercredential.Envelope{ + Version: record.Version, Algorithm: record.Algorithm, KeyID: record.KeyID, + Nonce: record.Nonce, Ciphertext: record.Ciphertext, + CredentialClass: record.Class, Fingerprint: record.Fingerprint, + }, scope) + if err != nil { + return fmt.Errorf("connection %s cannot be decrypted with the configured keyring; keep key %q in the keyring and retry: %w", + record.ConnectionID, record.KeyID, err) + } + envelope, err := cipher.Encrypt(plaintext, scope) + zero(plaintext) + if err != nil { + return fmt.Errorf("re-encrypt credential for connection %s: %w", record.ConnectionID, err) + } + record.Version = envelope.Version + record.Algorithm = envelope.Algorithm + record.KeyID = envelope.KeyID + record.Nonce = envelope.Nonce + record.Ciphertext = envelope.Ciphertext + record.Fingerprint = envelope.Fingerprint + resealed = append(resealed, record) + } + if err := repository.ReplaceCredentialEnvelopes(ctx, resealed, time.Now().UTC()); err != nil { + return err + } + rotated += len(resealed) + fmt.Printf("rotated %d envelope(s)\n", rotated) + } + if dryRun { + fmt.Printf("dry run complete: at least %d envelope(s) need rotation to key %s\n", rotated, activeKeyID) + return nil + } + fmt.Printf("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 { + value[i] = 0 + } +} + +// splitArguments separates the subcommand from its flags so both orders work: +// `migrate down --confirm` and `migrate --confirm down`. Go's flag package stops +// parsing at the first positional argument, which would otherwise reject the +// natural form. +func splitArguments(args []string) (string, []string) { + action := "" + flags := make([]string, 0, len(args)) + for _, argument := range args { + if action == "" && argument != "" && argument[0] != '-' { + action = argument + continue + } + flags = append(flags, argument) + } + return action, flags +} diff --git a/apps/api/cmd/loadgen/main.go b/apps/api/cmd/loadgen/main.go new file mode 100644 index 00000000..369340c2 --- /dev/null +++ b/apps/api/cmd/loadgen/main.go @@ -0,0 +1,383 @@ +// Command loadgen measures latency on Mosaic's hot request paths. +// +// It is a measurement harness, not a benchmark suite: it reports what a given +// deployment did under a stated concurrency and duration so results can be +// recorded as evidence. It deliberately publishes no target numbers. +// +// loadgen -scenario delivery -url http://localhost:8080 -key -c 16 -d 30s +// loadgen -scenario delivery-etag -url http://localhost:8080 -key +// loadgen -scenario ingest -url http://localhost:8080 -key -batch 100 +// +// Scenarios: +// +// delivery GET /v1/sdk/configuration (cold: no validator) +// delivery-etag GET /v1/sdk/configuration with If-None-Match, measuring the 304 path +// ingest POST /v1/sdk/events/batch with a synthetic batch +// +// Only the Go standard library is used. +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "sort" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type options struct { + scenario string + baseURL string + key string + platform string + sdkVersion string + concurrency int + duration time.Duration + timeout time.Duration + batchSize int + warmup time.Duration +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "loadgen: %v\n", err) + os.Exit(1) + } +} + +func run() error { + var opts options + flag.StringVar(&opts.scenario, "scenario", "delivery", "delivery, delivery-etag, or ingest") + flag.StringVar(&opts.baseURL, "url", "http://localhost:8080", "Mosaic API base URL") + flag.StringVar(&opts.key, "key", "", "public SDK key (required)") + flag.StringVar(&opts.platform, "platform", "ios", "SDK platform reported in requests") + flag.StringVar(&opts.sdkVersion, "sdk-version", "1.0.0", "SDK version reported in requests") + flag.IntVar(&opts.concurrency, "c", 8, "concurrent workers") + flag.DurationVar(&opts.duration, "d", 30*time.Second, "measurement duration") + flag.DurationVar(&opts.timeout, "timeout", 30*time.Second, "per-request timeout") + flag.IntVar(&opts.batchSize, "batch", 100, "events per ingestion batch") + flag.DurationVar(&opts.warmup, "warmup", 3*time.Second, "unmeasured warm-up period") + flag.Parse() + + if opts.key == "" { + return fmt.Errorf("-key is required") + } + if opts.concurrency < 1 { + return fmt.Errorf("-c must be at least 1") + } + switch opts.scenario { + case "delivery", "delivery-etag", "ingest": + default: + return fmt.Errorf("unknown scenario %q", opts.scenario) + } + + client := &http.Client{ + Timeout: opts.timeout, + Transport: &http.Transport{ + MaxIdleConns: opts.concurrency * 2, + MaxIdleConnsPerHost: opts.concurrency * 2, + }, + } + + etag := "" + if opts.scenario == "delivery-etag" { + tag, err := fetchETag(client, opts) + if err != nil { + return fmt.Errorf("prime ETag: %w", err) + } + if tag == "" { + return fmt.Errorf("the delivery endpoint returned no ETag; the 304 path cannot be measured") + } + etag = tag + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if opts.warmup > 0 { + warmupContext, cancel := context.WithTimeout(ctx, opts.warmup) + _ = drive(warmupContext, client, opts, etag, false) + cancel() + } + + measureContext, cancel := context.WithTimeout(ctx, opts.duration) + defer cancel() + started := time.Now() + result := drive(measureContext, client, opts, etag, true) + result.wall = time.Since(started) + result.report(opts) + return nil +} + +type outcome struct { + mu sync.Mutex + latencies []time.Duration + errors atomic.Int64 + notMod atomic.Int64 + statuses sync.Map + wall time.Duration +} + +func (o *outcome) record(latency time.Duration, status int, err error) { + if err != nil { + o.errors.Add(1) + return + } + if status == http.StatusNotModified { + o.notMod.Add(1) + } + if status >= 400 { + o.errors.Add(1) + } + counter, _ := o.statuses.LoadOrStore(status, new(atomic.Int64)) + counter.(*atomic.Int64).Add(1) + o.mu.Lock() + o.latencies = append(o.latencies, latency) + o.mu.Unlock() +} + +func (o *outcome) report(opts options) { + o.mu.Lock() + latencies := append([]time.Duration(nil), o.latencies...) + o.mu.Unlock() + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + + total := len(latencies) + int(o.errors.Load()) + fmt.Printf("scenario: %s\n", opts.scenario) + fmt.Printf("concurrency: %d\n", opts.concurrency) + fmt.Printf("duration: %s\n", o.wall.Round(time.Millisecond)) + fmt.Printf("requests: %d\n", total) + if o.wall > 0 { + fmt.Printf("throughput: %.1f req/s\n", float64(total)/o.wall.Seconds()) + } + fmt.Printf("errors: %d\n", o.errors.Load()) + if total > 0 { + fmt.Printf("error rate: %.4f\n", float64(o.errors.Load())/float64(total)) + } + if len(latencies) > 0 { + fmt.Printf("median: %s\n", percentile(latencies, 0.50).Round(time.Microsecond)) + fmt.Printf("p95: %s\n", percentile(latencies, 0.95).Round(time.Microsecond)) + fmt.Printf("p99: %s\n", percentile(latencies, 0.99).Round(time.Microsecond)) + fmt.Printf("max: %s\n", latencies[len(latencies)-1].Round(time.Microsecond)) + } + if opts.scenario == "delivery-etag" && total > 0 { + fmt.Printf("304 ratio: %.4f\n", float64(o.notMod.Load())/float64(total)) + } + fmt.Printf("status codes:\n") + o.statuses.Range(func(status, counter any) bool { + fmt.Printf(" %d: %d\n", status, counter.(*atomic.Int64).Load()) + return true + }) +} + +func percentile(sorted []time.Duration, fraction float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + index := int(float64(len(sorted)-1) * fraction) + return sorted[index] +} + +func drive(ctx context.Context, client *http.Client, opts options, etag string, measure bool) *outcome { + result := &outcome{} + var group sync.WaitGroup + for worker := range opts.concurrency { + group.Add(1) + go func(worker int) { + defer group.Done() + for ctx.Err() == nil { + started := time.Now() + status, err := issue(ctx, client, opts, etag, worker) + if ctx.Err() != nil { + return + } + if measure { + result.record(time.Since(started), status, err) + } + } + }(worker) + } + group.Wait() + return result +} + +func issue(ctx context.Context, client *http.Client, opts options, etag string, worker int) (int, error) { + switch opts.scenario { + case "ingest": + return postBatch(ctx, client, opts, worker) + default: + return getConfiguration(ctx, client, opts, etag) + } +} + +// paywallCapabilities is the full Paywall 0.2 capability vocabulary a current +// SDK advertises. Capability negotiation is a closed contract: a request that +// advertises nothing is answered 406 for every delivery version, so without +// these headers the delivery scenarios measured the refusal path and never +// reached a Configuration Release. +var paywallCapabilities = []string{ + "layout.scrollContainer", "layout.stack", "layout.sizing", "layout.heightSizing", "layout.outerInsets", + "navigation.screens", "navigation.sheets", + "component.text", "component.image", "component.icon", "component.featureList", "component.productSelector", + "component.productCard", "component.productBadge", "component.button", "component.carousel", + "component.switch", "component.countdown", + "localization.catalogs", "localization.rtl", "localization.productTemplate", "product.references", + "asset.bundledImage", "asset.remoteImage", "asset.bundledVideo", "asset.remoteVideo", + "action.purchase", "action.restore", "action.close", "action.navigateTo", "action.navigateBack", + "action.openExternalUrl", + "accessibility.metadata", "fallback.asset", "fallback.product", "outcome.normalized", + "style.colors", "style.designTokens", "style.gradientBackground", "style.mediaBackground", "style.shadow", + "style.box", "style.clipping", "style.typography", "style.productCardStates", + "visibility.static", "condition.switchVisibility", +} + +var experimentFeatures = []string{ + "allocation.ranges", "assignment.installation", "assignment.identified_user", + "assignment.identified_user_or_installation", "fallback.normal_placement", + "group.mutual_exclusion", "override.qa", "schedule.trusted_server_time", +} + +// setCapabilityHeaders makes the request look like a current SDK that supports +// every delivery contract, so negotiation selects the highest representation +// the Environment actually serves. +func setCapabilityHeaders(request *http.Request, opts options) { + request.Header.Set("Authorization", "Bearer "+opts.key) + request.Header.Set("Mosaic-SDK-Platform", opts.platform) + request.Header.Set("Mosaic-SDK-Version", opts.sdkVersion) + request.Header.Set("Mosaic-Configuration-Versions", "3,2,1") + request.Header.Set("Mosaic-Paywall-Protocol-Versions", "0.2") + capabilities := make([]string, 0, len(paywallCapabilities)) + for _, name := range paywallCapabilities { + capabilities = append(capabilities, name+"@0.2") + } + request.Header.Set("Mosaic-Paywall-Capabilities", strings.Join(capabilities, ",")) + request.Header.Set("Mosaic-Placement-Decision-Versions", "1") + request.Header.Set("Mosaic-Decision-Features", "source.device.platform,source.identity.user_present,outcome.paywall,outcome.no_paywall,outcome.fallback") + request.Header.Set("Mosaic-Bucketing-Algorithms", "sha256_length_prefixed_v1") + request.Header.Set("Mosaic-Experiment-Assignment-Versions", "1") + request.Header.Set("Mosaic-Experiment-Features", strings.Join(experimentFeatures, ",")) + request.Header.Set("Mosaic-Experiment-Bucketing-Algorithms", "experiment_sha256_length_prefixed_v1") + request.Header.Set("Mosaic-Experiment-Schedule-Policies", "trusted_server_time_v1") +} + +func getConfiguration(ctx context.Context, client *http.Client, opts options, etag string) (int, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, + opts.baseURL+"/v1/sdk/configuration", nil) + if err != nil { + return 0, err + } + setCapabilityHeaders(request, opts) + if etag != "" { + request.Header.Set("If-None-Match", etag) + } + response, err := client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode, nil +} + +func fetchETag(client *http.Client, opts options) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), opts.timeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.baseURL+"/v1/sdk/configuration", nil) + if err != nil { + return "", err + } + setCapabilityHeaders(request, opts) + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("delivery returned HTTP %d", response.StatusCode) + } + return response.Header.Get("ETag"), nil +} + +func postBatch(ctx context.Context, client *http.Client, opts options, worker int) (int, error) { + body, err := syntheticBatch(opts, worker) + if err != nil { + return 0, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, + opts.baseURL+"/v1/sdk/events/batch", bytes.NewReader(body)) + if err != nil { + return 0, err + } + request.Header.Set("Authorization", "Bearer "+opts.key) + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode, nil +} + +// syntheticBatch builds a schema-valid placement_requested batch. Identifiers +// are random so events are never deduplicated as replays, and no field carries +// anything resembling personal data. +func syntheticBatch(opts options, worker int) ([]byte, error) { + now := time.Now().UTC() + timestamp := now.Format("2006-01-02T15:04:05.000Z") + events := make([]map[string]any, 0, opts.batchSize) + for range opts.batchSize { + id, err := randomID() + if err != nil { + return nil, err + } + events = append(events, map[string]any{ + "eventId": "loadgen_" + id, + "eventSchemaVersion": "1", + "eventName": "placement_requested", + "occurredAt": timestamp, + "queuedAt": timestamp, + "authority": "client_observed", + "identity": map[string]any{"installationId": fmt.Sprintf("loadgen_installation_%d", worker), "generation": 1}, + "sessionId": fmt.Sprintf("loadgen_session_%d", worker), + "context": map[string]any{ + "platform": opts.platform, "sdkFamily": opts.platform, + "sdkVersion": opts.sdkVersion, "applicationVersion": "1.0.0", "locale": "en-US", + }, + "correlation": map[string]any{"placementRequestId": "loadgen_request_" + id}, + "attribution": map[string]any{"placementId": "loadgen_placement"}, + "payload": map[string]any{"decisionContractVersion": "1"}, + }) + } + batchID, err := randomID() + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "analyticsEventContractVersion": "1", + "batchId": "loadgen_batch_" + batchID, + "sentAt": timestamp, + "events": events, + }) +} + +func randomID() (string, error) { + buffer := make([]byte, 12) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} diff --git a/apps/api/cmd/migrate/main.go b/apps/api/cmd/migrate/main.go index 5fab6f41..1ee81a81 100644 --- a/apps/api/cmd/migrate/main.go +++ b/apps/api/cmd/migrate/main.go @@ -1,37 +1,115 @@ +// Command migrate is the only supported way to change the Mosaic schema. The +// API never migrates during normal startup. +// +// migrate preflight report current/expected version and verdict +// migrate status list applied and pending migrations +// migrate version print the current version +// migrate up apply all pending migrations +// migrate up-to apply migrations through +// migrate down --confirm roll back exactly one migration +// migrate down-to --confirm +// migrate redo --confirm roll back and reapply the latest migration +// +// Exit codes: +// +// 0 success; for preflight, the schema is compatible +// 1 the command failed +// 3 preflight found pending migrations (upgrade required) +// 4 preflight found an incompatible or dirty schema (manual recovery required) package main import ( "context" "errors" + "flag" "fmt" "os" + "strconv" + "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/lock" platformconfig "github.com/Mujhtech/mosaic/apps/api/internal/platform/config" "github.com/Mujhtech/mosaic/apps/api/migrations" ) +const ( + exitFailure = 1 + exitPendingUpgrade = 3 + exitIncompatible = 4 + defaultStepTimeout = 30 * time.Minute + connectTimeoutLimit = 30 * time.Second +) + +// exitError carries a specific process exit code out of run. +type exitError struct { + code int + err error +} + +func (e *exitError) Error() string { return e.err.Error() } +func (e *exitError) Unwrap() error { return e.err } + func main() { - if err := run(); err != nil { + if err := run(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "migration failed: %v\n", err) - os.Exit(1) + var exit *exitError + if errors.As(err, &exit) { + os.Exit(exit.code) + } + os.Exit(exitFailure) } } -func run() error { - if len(os.Args) != 2 { - return errors.New("usage: migrate ") +func run(args []string) error { + action, flagArguments, positional := splitArguments(args) + flags := flag.NewFlagSet("migrate", flag.ContinueOnError) + confirm := flags.Bool("confirm", false, "confirm a destructive rollback (required for down, down-to, and redo)") + // The old command wrapped the entire run in a fixed 10s context, which + // aborted any real migration on a populated database mid-flight. The + // timeout is now generous, configurable, and applied per Goose call. + stepTimeout := flags.Duration("timeout", stepTimeoutFromEnvironment(), "maximum duration for a single migration step") + lockTimeout := flags.Duration("lock-timeout", 5*time.Minute, "maximum time to wait for the migration advisory lock") + if err := flags.Parse(flagArguments); err != nil { + return err } - action := os.Args[1] + if action == "" { + return errors.New("usage: migrate [version] [flags]") + } + if remaining := flags.Args(); len(remaining) > 0 { + return fmt.Errorf("unexpected argument %q", remaining[0]) + } + + var target int64 switch action { - case "up", "down", "status", "version": + case "up-to", "down-to": + if len(positional) != 1 { + return fmt.Errorf("%s requires a target version", action) + } + parsed, err := strconv.ParseInt(positional[0], 10, 64) + if err != nil { + return fmt.Errorf("%s target version must be numeric", action) + } + target = parsed + case "preflight", "status", "version", "up", "down", "redo": + if len(positional) != 0 { + return fmt.Errorf("%s does not take a positional argument", action) + } default: return fmt.Errorf("unsupported migration action %q", action) } + + destructive := action == "down" || action == "down-to" || action == "redo" + if destructive && !*confirm { + return fmt.Errorf( + "%s rolls the schema back and can be refused by irreversible migrations; pass --confirm to proceed. "+ + "Rollback is not a substitute for restore-from-backup", action) + } + applicationConfig, err := platformconfig.Load() if err != nil { return fmt.Errorf("load configuration: %w", err) @@ -42,17 +120,246 @@ func run() error { } db := stdlib.OpenDB(*postgresConfig) defer db.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := db.PingContext(ctx); err != nil { + db.SetMaxOpenConns(1) + + connectContext, cancelConnect := context.WithTimeout(context.Background(), connectTimeoutLimit) + defer cancelConnect() + if err := db.PingContext(connectContext); err != nil { return fmt.Errorf("connect to PostgreSQL: %w", err) } + goose.SetBaseFS(migrations.Files) if err := goose.SetDialect("postgres"); err != nil { return fmt.Errorf("configure Goose: %w", err) } - if err := goose.RunContext(ctx, action, db, "."); err != nil { - return fmt.Errorf("goose %s: %w", action, err) + // A session-scoped advisory lock keeps two concurrent deployments (or a + // retried Compose migrate step) from applying migrations at the same time. + sessionLocker, err := newSessionLocker(*lockTimeout) + if err != nil { + return fmt.Errorf("configure migration locking: %w", err) + } + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.Files, + goose.WithSessionLocker(sessionLocker)) + if err != nil { + return fmt.Errorf("configure migration provider: %w", err) + } + + switch action { + case "preflight": + return preflight(context.Background(), provider, *stepTimeout) + case "status": + return status(context.Background(), provider, *stepTimeout) + case "version": + ctx, cancel := context.WithTimeout(context.Background(), *stepTimeout) + defer cancel() + version, err := provider.GetDBVersion(ctx) + if err != nil { + return fmt.Errorf("read current version: %w", err) + } + fmt.Printf("%d\n", version) + return nil + case "up": + return report(runWithTimeout(*stepTimeout, provider.Up)) + case "up-to": + return report(runWithTimeoutTo(*stepTimeout, provider.UpTo, target)) + case "down": + return report(runWithTimeout(*stepTimeout, func(ctx context.Context) ([]*goose.MigrationResult, error) { + result, err := provider.Down(ctx) + return single(result), err + })) + case "down-to": + return report(runWithTimeoutTo(*stepTimeout, provider.DownTo, target)) + case "redo": + return report(runWithTimeout(*stepTimeout, func(ctx context.Context) ([]*goose.MigrationResult, error) { + down, err := provider.Down(ctx) + if err != nil { + return single(down), err + } + up, err := provider.UpByOne(ctx) + return append(single(down), single(up)...), err + })) + } + return fmt.Errorf("unsupported migration action %q", action) +} + +func single(result *goose.MigrationResult) []*goose.MigrationResult { + if result == nil { + return nil + } + return []*goose.MigrationResult{result} +} + +func stepTimeoutFromEnvironment() time.Duration { + if raw := os.Getenv("MOSAIC_MIGRATION_TIMEOUT"); raw != "" { + if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 { + return parsed + } + } + return defaultStepTimeout +} + +func runWithTimeout(timeout time.Duration, fn func(context.Context) ([]*goose.MigrationResult, error)) ([]*goose.MigrationResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return fn(ctx) +} + +func runWithTimeoutTo(timeout time.Duration, fn func(context.Context, int64) ([]*goose.MigrationResult, error), target int64) ([]*goose.MigrationResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return fn(ctx, target) +} + +func report(results []*goose.MigrationResult, err error) error { + for _, result := range results { + if result == nil { + continue + } + direction := "applied" + if result.Direction == "down" { + direction = "rolled back" + } + fmt.Printf("%s %d %s in %s\n", direction, result.Source.Version, result.Source.Path, result.Duration) + } + if err != nil { + return fmt.Errorf("apply migrations: %w", err) + } + return nil +} + +func status(ctx context.Context, provider *goose.Provider, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + states, err := provider.Status(ctx) + if err != nil { + return fmt.Errorf("read migration status: %w", err) + } + pending := 0 + fmt.Printf("%-10s %-12s %s\n", "VERSION", "STATE", "SOURCE") + for _, state := range states { + if state.State == goose.StatePending { + pending++ + } + fmt.Printf("%-10d %-12s %s\n", state.Source.Version, state.State, state.Source.Path) + } + fmt.Printf("\n%d pending migration(s)\n", pending) + return nil +} + +func preflight(ctx context.Context, provider *goose.Provider, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + expected, err := migrations.ExpectedVersion() + if err != nil { + return err + } + current, err := provider.GetDBVersion(ctx) + if err != nil { + return fmt.Errorf("read current version: %w", err) } + states, err := provider.Status(ctx) + if err != nil { + return fmt.Errorf("read migration status: %w", err) + } + + var pending []int64 + var appliedOutOfOrder []int64 + for _, state := range states { + switch { + case state.State == goose.StatePending: + pending = append(pending, state.Source.Version) + case state.State == goose.StateApplied && state.Source.Version > expected: + appliedOutOfOrder = append(appliedOutOfOrder, state.Source.Version) + } + } + // A "dirty" schema for Goose is one where a version below the reported + // current version is still pending: an interrupted or partially applied run. + dirty := false + for _, version := range pending { + if version < current { + dirty = true + break + } + } + + fmt.Printf("current version: %d\n", current) + fmt.Printf("expected version: %d\n", expected) + fmt.Printf("pending: %v\n", pending) + fmt.Printf("dirty: %t\n", dirty) + + switch { + case dirty: + fmt.Println("verdict: incompatible (interrupted migration run detected)") + return &exitError{code: exitIncompatible, err: errors.New( + "the schema has pending migrations below the current version; " + + "see docs/backend/operations/upgrade.md for failed-migration recovery")} + case len(appliedOutOfOrder) > 0 || current > expected: + fmt.Println("verdict: incompatible (database is ahead of this binary)") + return &exitError{code: exitIncompatible, err: fmt.Errorf( + "database version %d is ahead of the version %d this binary ships; "+ + "deploy the matching release or restore from backup", current, expected)} + case len(pending) > 0: + fmt.Println("verdict: upgrade required") + return &exitError{code: exitPendingUpgrade, err: fmt.Errorf("%d migration(s) pending", len(pending))} + } + fmt.Println("verdict: compatible") return nil } + +// newSessionLocker wraps Goose's advisory session lock with a bounded wait so a +// stuck deployment fails with a clear error instead of hanging forever. Goose +// expresses the wait as a retry period times a failure threshold; Mosaic polls +// every five seconds for the requested duration. +func newSessionLocker(wait time.Duration) (lock.SessionLocker, error) { + const probeSeconds = 5 + attempts := uint64(wait.Seconds()) / probeSeconds + if attempts < 1 { + attempts = 1 + } + return lock.NewPostgresSessionLocker(lock.WithLockTimeout(probeSeconds, attempts)) +} + +// valueFlags are the migrate flags whose value is a separate argument, so +// `--timeout 30m` is not mistaken for a subcommand or a target version. +var valueFlags = map[string]bool{"timeout": true, "lock-timeout": true} + +// splitArguments separates the subcommand and its target version from its +// flags, so every documented form works: +// +// migrate down --confirm +// migrate --confirm down +// migrate down-to 17 --confirm +// migrate up-to 18 --timeout 30m +// +// Go's flag package stops parsing at the first positional argument, so +// `down-to 17 --confirm` used to leave `--confirm` unparsed AND count it as a +// second positional: the documented rollback form always failed with +// "down-to requires a target version", and the recovery runbook could not be +// followed as written. +func splitArguments(args []string) (string, []string, []string) { + action := "" + flags := make([]string, 0, len(args)) + positional := make([]string, 0, len(args)) + for index := 0; index < len(args); index++ { + argument := args[index] + if argument == "" { + continue + } + if argument[0] == '-' { + flags = append(flags, argument) + name := strings.TrimLeft(argument, "-") + if !strings.Contains(argument, "=") && valueFlags[name] && index+1 < len(args) { + index++ + flags = append(flags, args[index]) + } + continue + } + if action == "" { + action = argument + continue + } + positional = append(positional, argument) + } + return action, flags, positional +} diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go new file mode 100644 index 00000000..345e0a22 --- /dev/null +++ b/apps/api/cmd/migrate/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "reflect" + "testing" +) + +// The failed-migration recovery runbook tells an operator to run +// `migrate down-to --confirm`. That exact form used to fail with +// "down-to requires a target version", because the version and the flag both +// landed in the positional list: the documented recovery path could not be +// followed at the moment an operator most needs it. This pins every documented +// argument order, including a flag whose value is a separate argument. +func TestSplitArgumentsAcceptsEveryDocumentedForm(t *testing.T) { + for name, testCase := range map[string]struct { + arguments []string + action string + flags []string + positional []string + }{ + "destructive rollback to a target": { + []string{"down-to", "17", "--confirm"}, "down-to", []string{"--confirm"}, []string{"17"}, + }, + "flags before the subcommand": { + []string{"--confirm", "down"}, "down", []string{"--confirm"}, []string{}, + }, + "target with a separate flag value": { + []string{"up-to", "18", "--timeout", "30m"}, "up-to", []string{"--timeout", "30m"}, []string{"18"}, + }, + "target with an inline flag value": { + []string{"up-to", "18", "--timeout=30m"}, "up-to", []string{"--timeout=30m"}, []string{"18"}, + }, + "a flag value must never become the subcommand": { + []string{"--timeout", "30m", "up"}, "up", []string{"--timeout", "30m"}, []string{}, + }, + "plain subcommand": { + []string{"preflight"}, "preflight", []string{}, []string{}, + }, + } { + t.Run(name, func(t *testing.T) { + action, flags, positional := splitArguments(testCase.arguments) + if action != testCase.action { + t.Errorf("action = %q, want %q", action, testCase.action) + } + if !reflect.DeepEqual(flags, testCase.flags) { + t.Errorf("flags = %#v, want %#v", flags, testCase.flags) + } + if !reflect.DeepEqual(positional, testCase.positional) { + t.Errorf("positional = %#v, want %#v", positional, testCase.positional) + } + }) + } +} diff --git a/apps/api/cmd/worker/main.go b/apps/api/cmd/worker/main.go index ff0381a6..57d4d600 100644 --- a/apps/api/cmd/worker/main.go +++ b/apps/api/cmd/worker/main.go @@ -1,3 +1,10 @@ +// Command worker runs Mosaic's background job families: analytics aggregation, +// retention, privacy export and deletion, Experiment scheduling, and provider +// synchronization. +// +// Jobs run on a background context with a completion budget so a failure record +// still commits when SIGTERM arrives mid-job, and the families are polled +// round-robin so one busy family cannot starve another. package main import ( @@ -5,6 +12,7 @@ import ( "crypto/rand" "errors" "fmt" + "net/http" "os" "os/signal" "syscall" @@ -16,6 +24,7 @@ import ( "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/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" @@ -25,6 +34,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/platform/revenuecat" "github.com/Mujhtech/mosaic/apps/api/internal/platform/telemetry" "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" + "github.com/Mujhtech/mosaic/apps/api/internal/transport/health" ) func main() { @@ -35,6 +45,13 @@ func main() { } } +// jobFamily is one pollable source of work. Families are tried round-robin so a +// continuously busy analytics queue cannot starve Experiment scheduling. +type jobFamily struct { + name string + process func(context.Context, string) (bool, error) +} + func run() (runErr error) { cfg, err := config.Load() if err != nil { @@ -44,40 +61,65 @@ func run() (runErr error) { if err != nil { return fmt.Errorf("configure logging: %w", err) } - logger = logger.With().Str("service", cfg.Telemetry.ServiceName).Str("environment", cfg.Environment).Logger() + build := buildinfo.Current() + logger = logger.With(). + Str("service", cfg.Telemetry.ServiceName). + Str("environment", cfg.Environment). + Str("version", build.Version). + Logger() runContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + shutdownTelemetry, err := telemetry.New(runContext, telemetry.Config{ ServiceName: cfg.Telemetry.ServiceName, Environment: cfg.Environment, OTLPEndpoint: cfg.Telemetry.OTLPEndpoint, + Logger: logger, }) if err != nil { return fmt.Errorf("configure telemetry: %w", err) } defer func() { - shutdownContext, cancel := context.WithTimeout(context.Background(), cfg.HTTP.ShutdownTimeout) + shutdownContext, cancel := context.WithTimeout(context.Background(), cfg.HTTP.TelemetryShutdownTimeout) defer cancel() if err := shutdownTelemetry(shutdownContext); err != nil { runErr = errors.Join(runErr, err) } }() + pool, err := database.Open(runContext, database.Config{ URL: cfg.Database.URL, MaxConnections: cfg.Database.MaxConnections, MinConnections: cfg.Database.MinConnections, ConnectTimeout: cfg.Database.ConnectTimeout, + MaxConnLifetime: cfg.Database.MaxConnLifetime, MaxConnIdleTime: cfg.Database.MaxConnIdleTime, + HealthCheckPeriod: cfg.Database.HealthCheckPeriod, + StatementTimeout: cfg.Database.StatementTimeout, LockTimeout: cfg.Database.LockTimeout, }) if err != nil { return fmt.Errorf("initialize database: %w", err) } defer pool.Close() - objectStore, err := objectstoreminio.New(objectstoreminio.Config{Endpoint: cfg.ObjectStore.Endpoint, AccessKey: cfg.ObjectStore.AccessKey, SecretKey: cfg.ObjectStore.SecretKey, Bucket: cfg.ObjectStore.Bucket, UseTLS: cfg.ObjectStore.UseTLS}) + if err := database.RegisterPoolMetrics(pool); err != nil { + return fmt.Errorf("register database metrics: %w", err) + } + if err := database.MigrationCompatibility(runContext, pool); err != nil { + return fmt.Errorf("verify migration compatibility: %w", err) + } + + objectStore, err := objectstoreminio.New(objectstoreminio.Config{ + Endpoint: cfg.ObjectStore.Endpoint, AccessKey: cfg.ObjectStore.AccessKey, + SecretKey: cfg.ObjectStore.SecretKey, Bucket: cfg.ObjectStore.Bucket, UseTLS: cfg.ObjectStore.UseTLS, + OperationTimeout: cfg.ObjectStore.OperationTimeout, CheckTimeout: cfg.ObjectStore.CheckTimeout, + }) if err != nil { return err } if err = objectStore.Check(runContext); err != nil { return fmt.Errorf("initialize object storage: %w", err) } - analyticsService := analytics.NewService(analyticspostgres.New(pool), objectStore) - experimentService := experiment.NewService(experimentpostgres.New(pool)) + + analyticsRepository := analyticspostgres.New(pool) + analyticsService := analytics.NewService(analyticsRepository, objectStore) + experimentRepository := experimentpostgres.New(pool) + experimentService := experiment.NewService(experimentRepository) var providerService *cloudworkspace.Service if cfg.Providers.Enabled { cipher, err := providercredential.NewAESGCMCipher(cfg.Providers.CredentialKeyring, rand.Reader) @@ -95,46 +137,133 @@ func run() (runErr error) { } providerService = cloudworkspace.NewService(cloudworkspacepostgres.New(pool), cloudworkspace.WithProviderOperations(cipher, client, cfg.Providers.SnapshotTTL)) } + workerID, err := os.Hostname() if err != nil || workerID == "" { workerID = "mosaic-worker" } - logger.Info().Str("worker_id", workerID).Msg("worker started") - for { - processed := false - if providerService != nil { - providerProcessed, processErr := providerService.ProcessNextProviderSync(runContext, workerID) - processed = providerProcessed - if processErr != nil { - logger.Error().Err(processErr).Msg("provider sync job processing failed") - } + + readiness := health.NewReadiness( + health.Check{Name: "postgresql", Code: "database_unavailable", Probe: func(ctx context.Context) error { + return database.Ping(ctx, pool) + }}, + ) + healthServer, healthErrors := startHealthListener(cfg.Worker.HealthAddress, readiness) + defer func() { + readiness.StartDraining() + shutdownContext, cancel := context.WithTimeout(context.Background(), cfg.HTTP.ShutdownTimeout) + defer cancel() + if err := healthServer.Shutdown(shutdownContext); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("shutdown worker health listener: %w", err)) } - analyticsProcessed, processErr := analyticsService.ProcessNextJob(runContext, workerID) - processed = processed || analyticsProcessed - if processErr != nil { - logger.Error().Err(processErr).Msg("analytics job processing failed") + }() + + if err := analyticsRepository.RegisterQueueMetrics(); err != nil { + return fmt.Errorf("register analytics queue metrics: %w", err) + } + if err := experimentRepository.RegisterQueueMetrics(); err != nil { + return fmt.Errorf("register Experiment queue metrics: %w", err) + } + + families := make([]jobFamily, 0, 3) + if providerService != nil { + families = append(families, jobFamily{"provider_sync", providerService.ProcessNextProviderSync}) + } + families = append(families, + jobFamily{"analytics", analyticsService.ProcessNextJob}, + jobFamily{"experiment_schedule", experimentService.ProcessNextSchedule}, + ) + + logger.Info(). + Str("worker_id", workerID). + Str("health_address", cfg.Worker.HealthAddress). + Int("job_families", len(families)). + Msg("worker started") + + // Jobs run on a context detached from the signal context so a job in flight + // during SIGTERM can still commit its outcome. The budget bounds it. + jobBudget := cfg.Worker.JobShutdownBudget + next := 0 + for { + processedAny := false + for range families { + family := families[next%len(families)] + next++ + // processOne logs its own failure through the job context, which + // carries the job, tenant, and trace identifiers this loop does not + // have. + processed, _ := processOne(runContext, jobBudget, family, workerID, logger) + processedAny = processedAny || processed } - experimentProcessed, processErr := experimentService.ProcessNextSchedule(runContext, workerID) - processed = processed || experimentProcessed - if processErr != nil { - logger.Error().Err(processErr).Msg("experiment schedule processing failed") + select { + case err := <-healthErrors: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve worker health listener: %w", err) + } + default: } if runContext.Err() != nil { - logger.Info().Msg("provider worker stopped gracefully") + logger.Info().Msg("worker stopped gracefully") return nil } - if processed { + if processedAny { continue } - timer := cfg.Analytics.WorkerPollInterval - if providerService != nil && cfg.Providers.WorkerPollInterval < timer { - timer = cfg.Providers.WorkerPollInterval + interval := cfg.Analytics.WorkerPollInterval + if providerService != nil && cfg.Providers.WorkerPollInterval < interval { + interval = cfg.Providers.WorkerPollInterval } select { case <-runContext.Done(): - logger.Info().Msg("provider worker stopped gracefully") + logger.Info().Msg("worker stopped gracefully") return nil - case <-time.After(timer): + case <-time.After(interval): } } } + +// processOne runs one job on a detached context with a completion budget and +// emits one structured line per executed job. +// +// The completion line is written through the logger read back out of the job +// context, not through the local copy: each job family calls +// jobtelemetry.Annotate once it knows what it leased, which adds the job id, +// tenant identifiers, and trace id. Without that read-back the line would name +// only the family and the worker, which no runbook step can act on. +func processOne(runContext context.Context, budget time.Duration, family jobFamily, workerID string, logger zerolog.Logger) (bool, error) { + jobLogger := logger.With().Str("job_family", family.name).Str("worker_id", workerID).Logger() + jobContext, cancel := context.WithTimeout(context.WithoutCancel(runContext), budget) + defer cancel() + jobContext = jobLogger.WithContext(jobContext) + started := time.Now() + processed, err := family.process(jobContext, workerID) + if processed { + event := zerolog.Ctx(jobContext).Info(). + Dur("duration", time.Since(started)). + Bool("failed", err != nil) + event.Msg("background job finished") + } + if err != nil { + zerolog.Ctx(jobContext).Error().Err(err).Msg("background job processing failed") + } + return processed, err +} + +// atRoot serves a router mounted at "/" from a fixed ServeMux path. +func atRoot(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request := r.Clone(r.Context()) + request.URL.Path = "/" + handler.ServeHTTP(w, request) + }) +} + +func startHealthListener(address string, readiness *health.Readiness) (*http.Server, <-chan error) { + mux := http.NewServeMux() + mux.Handle("/health/live", atRoot(health.LiveRoutes())) + mux.Handle("/health/ready", atRoot(health.ReadinessRoutes(readiness))) + server := &http.Server{Addr: address, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + errs := make(chan error, 1) + go func() { errs <- server.ListenAndServe() }() + return server, errs +} diff --git a/apps/api/go.mod b/apps/api/go.mod index fb1b311a..c2dcc8f3 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -2,6 +2,8 @@ module github.com/Mujhtech/mosaic/apps/api go 1.26.2 +toolchain go1.26.5 + require ( github.com/dlclark/regexp2 v1.11.0 github.com/exaring/otelpgx v0.11.1 diff --git a/apps/api/internal/analytics/errors.go b/apps/api/internal/analytics/errors.go index 971def19..8e72dfae 100644 --- a/apps/api/internal/analytics/errors.go +++ b/apps/api/internal/analytics/errors.go @@ -12,3 +12,49 @@ var ( ErrConflict = errors.New("analytics conflict") ErrTemporarilyUnavailable = errors.New("analytics temporarily unavailable") ) + +// Permanent rejection codes returned per event in an ingestion response. They +// are part of the Analytics Event ingestion contract: an SDK must never retry an +// event carrying one of these, so the values are stable and machine-readable. +// +// Codes are deliberately coarse and never echo the rejected value, because the +// values that trigger minimization rejections are exactly the ones that may +// carry personal data. +const ( + // RejectEventTooLarge - the encoded event exceeds MaxEventBytes. + RejectEventTooLarge = "event_too_large" + // RejectInvalidIdentifier - an identifier does not match the contract pattern. + RejectInvalidIdentifier = "invalid_identifier" + // RejectSensitiveValue - a field looks like personal or credential data. + RejectSensitiveValue = "sensitive_value_rejected" + // RejectUnsupportedSchema - the event schema version is not accepted here. + RejectUnsupportedSchema = "unsupported_event_schema" + // RejectUnsupportedEventName - the event name is not in the taxonomy. + RejectUnsupportedEventName = "unsupported_event_name" + // RejectSchemaInvalid - the event failed canonical schema validation. + RejectSchemaInvalid = "event_schema_invalid" + // RejectUnknownField - a payload field is not permitted for this event. + RejectUnknownField = "unknown_field" + // RejectInvalidTimestamp - occurredAt/queuedAt are missing, malformed, or misordered. + RejectInvalidTimestamp = "invalid_timestamp" + // RejectFutureEvent - occurredAt is beyond the accepted clock skew. + RejectFutureEvent = "occurred_at_too_far_future" + // RejectExpired - occurredAt is older than the ingestion window. + RejectExpired = "event_expired" + // RejectAuthorityNotAllowed - a public SDK key claimed a trusted authority. + RejectAuthorityNotAllowed = "authority_not_allowed" + // RejectExperimentAttributionIncomplete - the Experiment tuple is partial. + RejectExperimentAttributionIncomplete = "experiment_attribution_incomplete" + // RejectCorrelationNotAllowed - a correlation ID is not permitted for this event. + RejectCorrelationNotAllowed = "correlation_field_not_allowed" + // RejectAttributionNotAllowed - an attribution field is not permitted for this event. + RejectAttributionNotAllowed = "attribution_field_not_allowed" + // RejectRuleSetAttributionIncomplete - Rule Set ID and version must travel together. + RejectRuleSetAttributionIncomplete = "rule_set_attribution_incomplete" + // RejectRolloutAttributionIncomplete - the rollout tuple must be absent or complete. + RejectRolloutAttributionIncomplete = "rollout_attribution_incomplete" + // RejectQAExposure - a QA-overridden presentation must not emit statistical exposure. + RejectQAExposure = "qa_override_exposure_rejected" + // RejectFallbackPaywallIdentity - fallback Paywall identity belongs in the payload. + RejectFallbackPaywallIdentity = "fallback_paywall_identity_rejected" +) diff --git a/apps/api/internal/analytics/job_test.go b/apps/api/internal/analytics/job_test.go new file mode 100644 index 00000000..5d80ce82 --- /dev/null +++ b/apps/api/internal/analytics/job_test.go @@ -0,0 +1,47 @@ +package analytics + +import ( + "context" + "errors" + "testing" + "time" +) + +type recordingFailureRepository struct { + Repository + called bool + observedError error +} + +func (r *recordingFailureRepository) FailJob(ctx context.Context, _ Job, _ string, _ time.Time) error { + r.called = true + r.observedError = ctx.Err() + return nil +} + +// A worker receiving SIGTERM mid-job cancels the run context. If the failure +// record is written on that cancelled context the write is refused, the lease +// silently expires, and the job appears never to have run — the silent job-loss +// class Phase 8 closes. The failure write must therefore be detached from the +// caller's cancellation. +func TestJobFailureIsCommittedOnACancelledRunContext(t *testing.T) { + repository := &recordingFailureRepository{} + service := NewService(repository, nil) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := service.runJob(ctx, Job{ID: "job_1", Kind: "aggregate"}, func() error { + return errors.New("aggregation failed") + }) + + if err == nil { + t.Fatal("runJob returned nil, want the job error surfaced to the worker") + } + if !repository.called { + t.Fatal("the job-failure record was never written") + } + if repository.observedError != nil { + t.Fatalf("failure record used a cancelled context: %v", repository.observedError) + } +} diff --git a/apps/api/internal/analytics/schema_test.go b/apps/api/internal/analytics/schema_test.go index b2aecd04..0037e827 100644 --- a/apps/api/internal/analytics/schema_test.go +++ b/apps/api/internal/analytics/schema_test.go @@ -176,3 +176,58 @@ func validPlacementEvent(now time.Time) Event { Payload: json.RawMessage(`{"decisionContractVersion":"1"}`), } } + +// The Phase 6 ingestion defect was a contract divergence: the canonical +// semantic validator rejected these fixtures while the API's own runtime path +// accepted them, so Mosaic collected identifiers the contract forbids. This +// test drives every canonical invalid fixture through the exact validation the +// batch endpoint uses and requires a permanent-rejection code for each. +func TestCanonicalInvalidFixturesAreRejectedByTheIngestionPath(t *testing.T) { + v1, err := os.Open(protocolPath(t, "protocol/schema/analytics-event/v1/event.schema.json")) + if err != nil { + t.Fatal(err) + } + defer v1.Close() + v2, err := os.Open(protocolPath(t, "protocol/schema/analytics-event/v2/event.schema.json")) + if err != nil { + t.Fatal(err) + } + defer v2.Close() + validator, err := CompileSchemaValidators(v1, v2) + if err != nil { + t.Fatal(err) + } + service := NewService(batchVersionRepository{}, nil, validator) + + fixtures := make([]string, 0, 8) + for _, pattern := range []string{ + "protocol/fixtures/analytics-event/v1/invalid/*.json", + "protocol/fixtures/analytics-event/v2/invalid/*.json", + } { + matched, globErr := filepath.Glob(protocolPath(t, pattern)) + if globErr != nil { + t.Fatal(globErr) + } + fixtures = append(fixtures, matched...) + } + if len(fixtures) == 0 { + t.Fatal("no canonical invalid fixtures found") + } + + // Fixed clock inside the fixtures' validity window so a rejection is caused + // by the contract violation under test, not by expiry. + now := time.Date(2026, 7, 26, 12, 5, 0, 0, time.UTC) + for _, fixture := range fixtures { + t.Run(filepath.Base(fixture), func(t *testing.T) { + document, readErr := os.ReadFile(fixture) + if readErr != nil { + t.Fatal(readErr) + } + _, code := service.ValidateRawEvent(document, now, now) + if code == "" { + t.Fatalf("canonical invalid fixture %s was accepted by the ingestion path", filepath.Base(fixture)) + } + t.Logf("rejected with %s", code) + }) + } +} diff --git a/apps/api/internal/analytics/service.go b/apps/api/internal/analytics/service.go index 4c271f89..44153407 100644 --- a/apps/api/internal/analytics/service.go +++ b/apps/api/internal/analytics/service.go @@ -16,6 +16,8 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" ) type Service struct { @@ -40,6 +42,27 @@ func NewService(repository Repository, objects ObjectStore, validator ...*Schema return service } +// ValidateRawEvent is the exact per-event validation the batch endpoint applies: +// canonical schema validation followed by Mosaic's semantic and minimization +// rules. It returns a stable permanent-rejection code, or an empty string when +// the event is accepted. Tests exercise this rather than reimplementing the path. +func (s *Service) ValidateRawEvent(raw []byte, sentAt, now time.Time) (Candidate, string) { + if s.validator != nil { + if err := s.validator.ValidateEvent(raw); err != nil { + if strings.Contains(err.Error(), "additionalProperties") || + strings.Contains(err.Error(), "unevaluatedProperties") { + return Candidate{}, RejectUnknownField + } + return Candidate{}, RejectSchemaInvalid + } + } + var event Event + if err := json.Unmarshal(raw, &event); err != nil { + return Candidate{}, RejectSchemaInvalid + } + return ValidateEvent(event, sentAt, now) +} + func (s *Service) Ingest(ctx context.Context, rawKey string, batch Batch) (IngestionResponse, error) { ctx, span := otel.Tracer("mosaic/analytics").Start(ctx, "events.ingest") defer span.End() @@ -76,24 +99,9 @@ func (s *Service) Ingest(ctx context.Context, rawKey string, batch Batch) (Inges results[eventID] = EventResult{EventID: eventID, Status: "permanently_rejected", Code: "event_too_large"} continue } - if s.validator != nil { - if err := s.validator.ValidateEvent(raw); err != nil { - code := "event_schema_invalid" - if strings.Contains(err.Error(), "additionalProperties") { - code = "unknown_field" - } - results[eventID] = EventResult{EventID: eventID, Status: "permanently_rejected", Code: code} - continue - } - } - var event Event - if err := json.Unmarshal(raw, &event); err != nil { - results[eventID] = EventResult{EventID: eventID, Status: "permanently_rejected", Code: "event_schema_invalid"} - continue - } - candidate, code := ValidateEvent(event, sentAt, now) + candidate, code := s.ValidateRawEvent(raw, sentAt, now) if code != "" { - results[event.EventID] = EventResult{EventID: event.EventID, Status: "permanently_rejected", Code: code} + results[eventID] = EventResult{EventID: eventID, Status: "permanently_rejected", Code: code} continue } var canonical any @@ -284,16 +292,34 @@ func (s *Service) ProcessNextJob(ctx context.Context, workerID string) (bool, er if job, ok, err := s.repository.LeaseExport(ctx, workerID, now, lease); err != nil { return false, err } else if ok { + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: job.Kind, ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, + }) return true, s.processExport(ctx, job, now) } return false, nil } + +// jobFailureBudget bounds the detached write that records a job failure. +const jobFailureBudget = 10 * time.Second + func (s *Service) runJob(ctx context.Context, job Job, operation func() error) error { ctx, span := otel.Tracer("mosaic/analytics").Start(ctx, "analytics."+job.Kind) defer span.End() + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: job.Kind, ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, + }) if err := operation(); err != nil { span.RecordError(err) - _ = s.repository.FailJob(ctx, job, "job_failed", s.now().UTC()) + // The failure record must land even when the run context was cancelled + // mid-job by a shutdown signal; otherwise the lease silently expires and + // the job looks like it never ran. + failureContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobFailureBudget) + defer cancel() + if failErr := s.repository.FailJob(failureContext, job, "job_failed", s.now().UTC()); failErr != nil { + zerolog.Ctx(ctx).Error().Err(failErr).Str("job_id", job.ID).Str("job_kind", job.Kind). + Msg("analytics job failure record could not be committed") + } return err } s.jobs.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", job.Kind), attribute.String("outcome", "completed"))) diff --git a/apps/api/internal/analytics/validation.go b/apps/api/internal/analytics/validation.go index 1821ca3b..e835dc65 100644 --- a/apps/api/internal/analytics/validation.go +++ b/apps/api/internal/analytics/validation.go @@ -146,6 +146,9 @@ func ValidateEvent(event Event, sentAt, now time.Time) (Candidate, string) { if code := validatePayload(event.EventName, event.Payload); code != "" { return Candidate{}, code } + if code := validateMinimization(event); code != "" { + return Candidate{}, code + } return Candidate{Event: event, Raw: raw, OccurredAt: occurred, QueuedAt: queued, SentAt: sentAt, ReceivedAt: now, ExpiresAt: now.Add(EventExpiry)}, "" } @@ -303,3 +306,257 @@ func validPayloadValue(kind string, value any) bool { panic(fmt.Sprintf("unknown payload kind %q", kind)) } } + +// Data-minimization tables. +// +// These mirror the canonical semantic validators +// (protocol/tools/analytics-event-validation-v1.mjs and -v2.mjs). Before Phase 8 +// the API accepted any correlation or attribution field the JSON Schema allowed, +// so the canonical validator, the schema, and the runtime disagreed about +// validity and the ingestion boundary collected identifiers an event has no +// business carrying. The tables below make the runtime the same authority. +var ( + experimentTupleFields = []string{"experimentId", "experimentVersionId", "experimentVariantId", "experimentAllocationVersion"} + placementAttribution = []string{"configurationReleaseId", "placementId", "placementRuleSetId", "placementRuleSetVersion", "winningRuleId"} + paywallAttribution = append(append([]string{}, placementAttribution...), "paywallId", "paywallVersionId") + productAttribution = append(append([]string{}, paywallAttribution...), "mosaicProductId", "planId", "providerId", "providerProductMappingId") +) + +func withoutWinningRule(fields []string) []string { + result := make([]string, 0, len(fields)) + for _, field := range fields { + if field != "winningRuleId" { + result = append(result, field) + } + } + return result +} + +func withExperimentTuple(fields []string) []string { + return append(append([]string{}, fields...), experimentTupleFields...) +} + +func fieldSet(values ...[]string) map[string]struct{} { + set := make(map[string]struct{}) + for _, group := range values { + for _, value := range group { + set[value] = struct{}{} + } + } + return set +} + +var correlationFieldsByEvent = map[string]map[string]struct{}{ + "placement_requested": fieldSet([]string{"placementRequestId"}), + "placement_paywall_selected": fieldSet([]string{"placementRequestId"}), + "placement_no_paywall": fieldSet([]string{"placementRequestId"}), + "placement_fallback_used": fieldSet([]string{"placementRequestId"}), + "placement_unavailable": fieldSet([]string{"placementRequestId"}), + "placement_evaluation_failed": fieldSet([]string{"placementRequestId"}), + "paywall_presented": fieldSet([]string{"placementRequestId", "paywallPresentationId"}), + "paywall_dismissed": fieldSet([]string{"placementRequestId", "paywallPresentationId"}), + "paywall_action_selected": fieldSet([]string{"placementRequestId", "paywallPresentationId"}), + "paywall_render_failed": fieldSet([]string{"placementRequestId", "paywallPresentationId"}), + "product_load_started": fieldSet([]string{"placementRequestId", "paywallPresentationId", "productLoadAttemptId"}), + "product_load_completed": fieldSet([]string{"placementRequestId", "paywallPresentationId", "productLoadAttemptId"}), + "product_load_failed": fieldSet([]string{"placementRequestId", "paywallPresentationId", "productLoadAttemptId"}), + "product_unavailable": fieldSet([]string{"placementRequestId", "paywallPresentationId", "productLoadAttemptId"}), + "product_selected": fieldSet([]string{"placementRequestId", "paywallPresentationId", "productLoadAttemptId"}), + "purchase_started": fieldSet(purchaseCorrelation), + "purchase_completed_client": fieldSet(purchaseCorrelation), + "purchase_completed_provider": fieldSet([]string{"purchaseAttemptId", "providerOperationId", "providerUpdateId"}), + "purchase_pending": fieldSet(purchaseCorrelation), + "purchase_deferred": fieldSet(purchaseCorrelation), + "purchase_cancelled": fieldSet(purchaseCorrelation), + "purchase_failed": fieldSet(purchaseCorrelation), + "restore_started": fieldSet(restoreCorrelation), + "restore_completed": fieldSet(restoreCorrelation), + "restore_nothing_found": fieldSet(restoreCorrelation), + "restore_cancelled": fieldSet(restoreCorrelation), + "restore_failed": fieldSet(restoreCorrelation), + "experiment_assigned": fieldSet([]string{"placementRequestId"}), + "experiment_exposed": fieldSet([]string{"placementRequestId", "paywallPresentationId"}), + "experiment_fallback_presented": fieldSet([]string{"placementRequestId", "paywallPresentationId"}), + "experiment_assignment_failed": fieldSet([]string{"placementRequestId"}), +} + +var ( + purchaseCorrelation = []string{"placementRequestId", "paywallPresentationId", "productLoadAttemptId", "purchaseAttemptId", "providerOperationId"} + restoreCorrelation = []string{"restoreAttemptId", "providerOperationId"} +) + +var attributionFieldsByEvent = map[string]map[string]struct{}{ + "placement_requested": fieldSet(withoutWinningRule(placementAttribution)), + "placement_paywall_selected": fieldSet(paywallAttribution), + "placement_no_paywall": fieldSet(placementAttribution), + "placement_fallback_used": fieldSet(paywallAttribution), + "placement_unavailable": fieldSet(placementAttribution), + "placement_evaluation_failed": fieldSet(withoutWinningRule(placementAttribution)), + "paywall_presented": fieldSet(paywallAttribution), + "paywall_dismissed": fieldSet(paywallAttribution), + "paywall_action_selected": fieldSet(paywallAttribution), + "paywall_render_failed": fieldSet(paywallAttribution), + "product_load_started": fieldSet(paywallAttribution), + "product_load_completed": fieldSet(paywallAttribution), + "product_load_failed": fieldSet(paywallAttribution), + "product_unavailable": fieldSet(productAttribution), + "product_selected": fieldSet(withExperimentTuple(productAttribution)), + "purchase_started": fieldSet(withExperimentTuple(productAttribution)), + "purchase_completed_client": fieldSet(withExperimentTuple(productAttribution)), + "purchase_completed_provider": fieldSet(withExperimentTuple(productAttribution)), + "purchase_pending": fieldSet(withExperimentTuple(productAttribution)), + "purchase_deferred": fieldSet(withExperimentTuple(productAttribution)), + "purchase_cancelled": fieldSet(withExperimentTuple(productAttribution)), + "purchase_failed": fieldSet(withExperimentTuple(productAttribution)), + "restore_started": fieldSet([]string{"configurationReleaseId"}), + "restore_completed": fieldSet([]string{"configurationReleaseId"}), + "restore_nothing_found": fieldSet([]string{"configurationReleaseId"}), + "restore_cancelled": fieldSet([]string{"configurationReleaseId"}), + "restore_failed": fieldSet([]string{"configurationReleaseId"}), + "experiment_assigned": fieldSet(withExperimentTuple(placementAttribution)), + "experiment_exposed": fieldSet(withExperimentTuple(paywallAttribution)), + "experiment_fallback_presented": fieldSet(withExperimentTuple(placementAttribution)), + "experiment_assignment_failed": fieldSet(withExperimentTuple(placementAttribution)), +} + +// presentCorrelationFields lists the correlation identifiers carried by an event. +func presentCorrelationFields(c Correlation) []string { + present := make([]string, 0, 7) + for _, candidate := range []struct { + name string + value string + }{ + {"placementRequestId", c.PlacementRequestID}, + {"paywallPresentationId", c.PaywallPresentationID}, + {"productLoadAttemptId", c.ProductLoadAttemptID}, + {"purchaseAttemptId", c.PurchaseAttemptID}, + {"restoreAttemptId", c.RestoreAttemptID}, + {"providerOperationId", c.ProviderOperationID}, + {"providerUpdateId", c.ProviderUpdateID}, + } { + if candidate.value != "" { + present = append(present, candidate.name) + } + } + return present +} + +// presentAttributionFields lists the attribution fields carried by an event. +func presentAttributionFields(a Attribution) []string { + present := make([]string, 0, 15) + for _, candidate := range []struct { + name string + value string + }{ + {"configurationReleaseId", a.ConfigurationReleaseID}, + {"placementId", a.PlacementID}, + {"placementRuleSetId", a.PlacementRuleSetID}, + {"winningRuleId", a.WinningRuleID}, + {"paywallId", a.PaywallID}, + {"paywallVersionId", a.PaywallVersionID}, + {"mosaicProductId", a.ProductID}, + {"planId", a.PlanID}, + {"providerId", a.Provider}, + {"providerProductMappingId", a.ProviderMappingID}, + {"experimentId", a.ExperimentID}, + {"experimentVersionId", a.ExperimentVersionID}, + {"experimentVariantId", a.ExperimentVariantID}, + {"experimentAllocationVersion", a.ExperimentAllocationVersion}, + } { + if candidate.value != "" { + present = append(present, candidate.name) + } + } + if a.PlacementRuleSetVersion != 0 { + present = append(present, "placementRuleSetVersion") + } + return present +} + +// validateMinimization enforces the per-event correlation and attribution +// allow-lists plus the pairing rules that keep attribution interpretable. It +// applies to both v1 and v2 events. +func validateMinimization(event Event) string { + allowedCorrelation, known := correlationFieldsByEvent[event.EventName] + if !known { + return RejectUnsupportedEventName + } + for _, field := range presentCorrelationFields(event.Correlation) { + if _, ok := allowedCorrelation[field]; !ok { + return RejectCorrelationNotAllowed + } + } + allowedAttribution := attributionFieldsByEvent[event.EventName] + for _, field := range presentAttributionFields(event.Attribution) { + if _, ok := allowedAttribution[field]; !ok { + return RejectAttributionNotAllowed + } + } + + // A Rule Set ID without its version cannot identify the evaluated targeting + // state, and a winning Rule without a Rule Set is unattributable. + hasRuleSetID := event.Attribution.PlacementRuleSetID != "" + hasRuleSetVersion := event.Attribution.PlacementRuleSetVersion != 0 + if hasRuleSetID != hasRuleSetVersion { + return RejectRuleSetAttributionIncomplete + } + if event.Attribution.WinningRuleID != "" && !hasRuleSetID { + return RejectRuleSetAttributionIncomplete + } + + if code := validateRolloutTuple(event); code != "" { + return code + } + + // A QA-overridden presentation is not a statistical exposure; counting it + // would corrupt Experiment results. + if event.EventName == "experiment_exposed" && payloadBool(event.Payload, "qaOverride") { + return RejectQAExposure + } + if event.EventName == "experiment_fallback_presented" && + (event.Attribution.PaywallID != "" || event.Attribution.PaywallVersionID != "") { + return RejectFallbackPaywallIdentity + } + return "" +} + +// validateRolloutTuple enforces that rollout attribution on a selection event is +// either entirely absent or entirely present. A partial tuple silently +// misattributes a rollout bucket to an unknown algorithm. +func validateRolloutTuple(event Event) string { + if event.EventName != "placement_paywall_selected" && event.EventName != "placement_no_paywall" { + return "" + } + payload := decodePayload(event.Payload) + if payload == nil { + return "" + } + present := 0 + for _, field := range []string{"assignmentKeyType", "bucketingAlgorithm", "rolloutBucket"} { + if _, ok := payload[field]; ok { + present++ + } + } + if present != 0 && present != 3 { + return RejectRolloutAttributionIncomplete + } + return "" +} + +func decodePayload(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return nil + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value map[string]any + if err := decoder.Decode(&value); err != nil { + return nil + } + return value +} + +func payloadBool(raw json.RawMessage, field string) bool { + value, _ := decodePayload(raw)[field].(bool) + return value +} diff --git a/apps/api/internal/cloudworkspace/provider_operation_error_test.go b/apps/api/internal/cloudworkspace/provider_operation_error_test.go new file mode 100644 index 00000000..d453c480 --- /dev/null +++ b/apps/api/internal/cloudworkspace/provider_operation_error_test.go @@ -0,0 +1,40 @@ +package cloudworkspace + +import ( + "errors" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercatalog" +) + +// A cross-tenant POST /v1/provider-connections/{id}/test answered "the provider +// is temporarily unavailable" because every failure from the credential and +// scope lookup was rewritten into a provider error code. The authorization +// decision was therefore invisible: an unauthorized attempt looked like an +// outage, and an operator missing a permission had no way to learn that. Only a +// real provider-adapter failure may become a provider code. +func TestProviderOperationErrorPreservesAuthorizationDecisions(t *testing.T) { + for name, sentinel := range map[string]error{ + "forbidden": ErrForbidden, + "not found": ErrNotFound, + "unauthenticated": ErrUnauthenticated, + "feature off": ErrProviderFeatureDisabled, + } { + t.Run(name, func(t *testing.T) { + if got := providerOperationError(sentinel); !errors.Is(got, sentinel) { + t.Fatalf("providerOperationError(%v) = %v, want the original decision", sentinel, got) + } + if errors.Is(providerOperationError(sentinel), ErrProviderUnavailable) && + !errors.Is(sentinel, ErrProviderUnavailable) { + t.Fatal("an authorization decision was masked as a provider outage") + } + }) + } + + // A genuine adapter failure must still become a provider error code so the + // caller learns the upstream is at fault and may retry. + adapterFailure := &providercatalog.Error{Code: providercatalog.ErrorRateLimited, Retryable: true} + if got := providerOperationError(adapterFailure); !errors.Is(got, ErrProviderRateLimited) { + t.Fatalf("adapter failure = %v, want ErrProviderRateLimited", got) + } +} diff --git a/apps/api/internal/cloudworkspace/repository.go b/apps/api/internal/cloudworkspace/repository.go index 3720771d..086a200e 100644 --- a/apps/api/internal/cloudworkspace/repository.go +++ b/apps/api/internal/cloudworkspace/repository.go @@ -15,6 +15,10 @@ type Repository interface { type Reader interface { Organization(string) (Organization, bool) Organizations() []Organization + // OrganizationsForActor returns only the Organizations the actor belongs to. + // Listing every Organization and filtering in the service leaked an + // all-tenants scan into a per-user request. + OrganizationsForActor(string) []Organization Membership(string, string) (Membership, bool) Memberships(string) []Membership Project(string) (Project, bool) diff --git a/apps/api/internal/cloudworkspace/service.go b/apps/api/internal/cloudworkspace/service.go index c2a8da24..e38e6169 100644 --- a/apps/api/internal/cloudworkspace/service.go +++ b/apps/api/internal/cloudworkspace/service.go @@ -221,11 +221,7 @@ func (s *Service) ListOrganizations(ctx context.Context, actor Actor, options Li } var values []Organization err := s.repository.View(ctx, func(reader Reader) error { - for _, organization := range reader.Organizations() { - if _, ok := reader.Membership(organization.ID, actor.ID); ok { - values = append(values, organization) - } - } + values = reader.OrganizationsForActor(actor.ID) return nil }) return paginated(values, options, func(value Organization) string { return value.ID }, err) diff --git a/apps/api/internal/cloudworkspace/service_provider_operations.go b/apps/api/internal/cloudworkspace/service_provider_operations.go index 8aafd5e5..6e8c56f9 100644 --- a/apps/api/internal/cloudworkspace/service_provider_operations.go +++ b/apps/api/internal/cloudworkspace/service_provider_operations.go @@ -87,6 +87,24 @@ func providerErrorCode(err error) (ProviderErrorCode, bool, *int) { return code, catalogError.Retryable, retryAfterSeconds } +// providerOperationError converts a failed provider operation into the error the +// caller should see. +// +// Only a real provider-adapter failure becomes a provider error code. An +// authorization or lookup refusal must survive unchanged: routing it through +// publicProviderError reported a cross-tenant `POST /provider-connections/{id}/test` +// as "the provider is temporarily unavailable" instead of 403, so an +// unauthorized attempt looked like an outage and a legitimate operator missing a +// permission had no way to tell. +func providerOperationError(err error) error { + var catalogError *providercatalog.Error + if !errors.As(err, &catalogError) { + return err + } + code, _, _ := providerErrorCode(err) + return publicProviderError(code) +} + func publicProviderError(code ProviderErrorCode) error { switch code { case ProviderErrorCredentialInvalid: @@ -201,8 +219,7 @@ func (s *Service) TestProviderConnection(ctx context.Context, actor Actor, conne connection, project, _, err := s.fetchProviderCatalog(ctx, actor, connectionID, true) if err != nil { s.saveProviderFailure(ctx, actor, connectionID, "test", err) - code, _, _ := providerErrorCode(err) - return ProviderConnectionHealth{}, publicProviderError(code) + return ProviderConnectionHealth{}, providerOperationError(err) } var result ProviderConnectionHealth err = s.repository.Transact(ctx, func(tx Transaction) error { @@ -319,8 +336,7 @@ func (s *Service) PreviewProviderCatalog(ctx context.Context, actor Actor, conne _, _, catalog, err := s.fetchProviderCatalog(ctx, actor, connectionID, false) if err != nil { s.saveProviderFailure(ctx, actor, connectionID, "preview", err) - code, _, _ := providerErrorCode(err) - return ProviderCatalogPreview{}, publicProviderError(code) + return ProviderCatalogPreview{}, providerOperationError(err) } return catalogPreview(connectionID, catalog), nil } @@ -628,8 +644,7 @@ func (s *Service) ImportProviderProducts(ctx context.Context, actor Actor, proje connection, project, catalog, err := s.fetchProviderCatalog(ctx, actor, connectionID, true) if err != nil { s.saveProviderFailure(ctx, actor, connectionID, "import", err) - code, _, _ := providerErrorCode(err) - return ProviderImportResult{}, publicProviderError(code) + return ProviderImportResult{}, providerOperationError(err) } if connection.ProjectID != projectID || project.ID != projectID { return ProviderImportResult{}, ErrScopeMismatch @@ -972,8 +987,7 @@ func (s *Service) ReplaceProviderMapping(ctx context.Context, actor Actor, mappi connection, project, catalog, err := s.fetchProviderCatalog(ctx, actor, original.ConnectionID, true) if err != nil { s.saveProviderFailure(ctx, actor, original.ConnectionID, "mapping_replace", err) - code, _, _ := providerErrorCode(err) - return ProviderProductMapping{}, publicProviderError(code) + return ProviderProductMapping{}, providerOperationError(err) } providerProduct, ok := catalogProduct(catalog, input.ProviderProductIdentifier) if !ok || providerProduct.State != "active" { diff --git a/apps/api/internal/cloudworkspace/service_provider_worker.go b/apps/api/internal/cloudworkspace/service_provider_worker.go index e3e113e7..88e47a79 100644 --- a/apps/api/internal/cloudworkspace/service_provider_worker.go +++ b/apps/api/internal/cloudworkspace/service_provider_worker.go @@ -8,6 +8,8 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/providercatalog" "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" ) const providerSyncLeaseDuration = 2 * time.Minute @@ -90,6 +92,9 @@ func (s *Service) ProcessNextProviderSync(ctx context.Context, workerID string) if err != nil || job.ID == "" { return job.ID != "", err } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: "provider_sync", ProjectID: job.ProjectID, ResourceID: job.ConnectionID, + }) connection, project, secret, err := s.workerCredential(ctx, job.ConnectionID) if err != nil { return true, s.finishFailedProviderSync(ctx, job, run, err) diff --git a/apps/api/internal/experiment/errors.go b/apps/api/internal/experiment/errors.go index 92432ccd..4c2d62f4 100644 --- a/apps/api/internal/experiment/errors.go +++ b/apps/api/internal/experiment/errors.go @@ -23,3 +23,39 @@ func (e *ConflictError) Error() string { return "experiment draft revision confl type ValidationError struct{ Result ValidationResult } func (e *ValidationError) Error() string { return "experiment validation failed" } + +// ErrPlacementDecisionRequired means the Environment's current Configuration +// Release carries no Placement Decision (Delivery v2) representation, so no +// Delivery v3 Release can be produced from it. An Experiment cannot be +// published until a Placement rule set has been published in that Environment. +// It has its own code because it is a prerequisite an operator can act on, not +// a malformed request. +var ErrPlacementDecisionRequired = errors.New("environment has no Placement Decision release") + +// InvalidError names why an Experiment request was rejected. +// +// Fifteen distinct publish preconditions all returned a bare ErrInvalid, so the +// API answered every one of them with `422 experiment_invalid` and no detail, +// in the response or in any log line. An operator or Studio user had no path +// from the refusal to the cause. Reason is a stable machine-readable code drawn +// from a closed vocabulary; it never carries tenant data or SQL. +type InvalidError struct { + Reason string +} + +func (e *InvalidError) Error() string { return "experiment invalid: " + e.Reason } + +// Unwrap keeps errors.Is(err, ErrInvalid) true for every existing caller. +func (e *InvalidError) Unwrap() error { return ErrInvalid } + +// Invalid builds a rejection carrying its reason. +func Invalid(reason string) error { return &InvalidError{Reason: reason} } + +// InvalidReason extracts the reason from an error, if it carries one. +func InvalidReason(err error) (string, bool) { + var invalid *InvalidError + if errors.As(err, &invalid) { + return invalid.Reason, true + } + return "", false +} diff --git a/apps/api/internal/experiment/model.go b/apps/api/internal/experiment/model.go index f4e83707..729d3ee5 100644 --- a/apps/api/internal/experiment/model.go +++ b/apps/api/internal/experiment/model.go @@ -315,4 +315,9 @@ type ExportRequest struct { Format string `json:"format"` IncludeIdentity bool `json:"includeIdentity"` } -type ScheduleJob struct{ ID, ExperimentID, ProjectID, EnvironmentID, Action, ActorID string } +type ScheduleJob struct { + ID, ExperimentID, ProjectID, EnvironmentID, Action, ActorID string + // AttemptCount is the attempt this lease represents (1 on first lease) and + // MaxAttempts is the retry budget before the job is terminally failed. + AttemptCount, MaxAttempts int +} diff --git a/apps/api/internal/experiment/repository.go b/apps/api/internal/experiment/repository.go index 186c60f2..85336617 100644 --- a/apps/api/internal/experiment/repository.go +++ b/apps/api/internal/experiment/repository.go @@ -47,5 +47,8 @@ type Repository interface { Overrides(context.Context, Scope, string, time.Time) ([]QAOverride, error) RevokeOverride(context.Context, Scope, Actor, string, string, time.Time) error LeaseSchedule(context.Context, string, time.Time, time.Time) (ScheduleJob, bool, error) - FinishSchedule(context.Context, string, bool, time.Time) error + // FinishSchedule completes a leased job. A failure requeues the job with + // backoff until the retry budget is exhausted, then records a terminal + // failure with the supplied diagnostic code. + FinishSchedule(context.Context, ScheduleJob, bool, string, time.Time) error } diff --git a/apps/api/internal/experiment/service.go b/apps/api/internal/experiment/service.go index 40ddb373..22c5dd2d 100644 --- a/apps/api/internal/experiment/service.go +++ b/apps/api/internal/experiment/service.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -14,6 +15,8 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/jobtelemetry" ) type Service struct { @@ -56,7 +59,7 @@ func parseMetricVersion(value string) (string, int, bool) { func CompileSchedule(schedule Schedule, publicationTime time.Time) (Schedule, error) { if schedule.StartsAt == nil { - return Schedule{}, ErrInvalid + return Schedule{}, Invalid("schedule_start_missing") } compiled := schedule if !compiled.StartsAt.After(publicationTime) { @@ -64,7 +67,7 @@ func CompileSchedule(schedule Schedule, publicationTime time.Time) (Schedule, er compiled.StartsAt = &start } if compiled.EndsAt != nil && !compiled.EndsAt.After(*compiled.StartsAt) { - return Schedule{}, ErrInvalid + return Schedule{}, Invalid("schedule_end_not_after_start") } return compiled, nil } @@ -539,12 +542,37 @@ func (s *Service) RevokeOverride(ctx context.Context, actor Actor, p, e, id, ove return s.repository.RevokeOverride(ctx, scope, actor, id, overrideID, s.now()) } +// scheduleCompletionBudget bounds how long a job-outcome write may take after +// the run context has been cancelled during shutdown. +const scheduleCompletionBudget = 10 * time.Second + +// scheduleFailureCode maps a transition failure to a stable diagnostic code so +// an operator inspecting a dead-lettered job knows why it stopped retrying. +func scheduleFailureCode(err error) string { + switch { + case errors.Is(err, ErrNotFound): + return "experiment_not_found" + case errors.Is(err, ErrConflict): + return "experiment_state_conflict" + case errors.Is(err, ErrForbidden): + return "schedule_actor_forbidden" + case errors.Is(err, ErrInvalid): + return "schedule_transition_invalid" + default: + return "schedule_transition_failed" + } +} + func (s *Service) ProcessNextSchedule(ctx context.Context, worker string) (bool, error) { now := s.now() job, ok, err := s.repository.LeaseSchedule(ctx, worker, now, now.Add(2*time.Minute)) if err != nil || !ok { return ok, err } + jobtelemetry.Annotate(ctx, jobtelemetry.Identity{ + JobID: job.ID, JobKind: "experiment_schedule_" + job.Action, + ProjectID: job.ProjectID, EnvironmentID: job.EnvironmentID, ResourceID: job.ExperimentID, + }) target := "running" reason := "scheduled_start" if job.Action == "complete" { @@ -552,7 +580,16 @@ func (s *Service) ProcessNextSchedule(ctx context.Context, worker string) (bool, reason = "scheduled_end" } _, err = s.Transition(ctx, Actor{ID: job.ActorID}, job.ProjectID, job.EnvironmentID, job.ExperimentID, target, reason) - finishErr := s.repository.FinishSchedule(ctx, job.ID, err == nil, s.now()) + code := "" + if err != nil { + code = scheduleFailureCode(err) + } + // completionContext detaches the bookkeeping write from the run context so a + // SIGTERM arriving mid-transition still records the job outcome instead of + // leaving the lease to expire. + completionContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), scheduleCompletionBudget) + defer cancel() + finishErr := s.repository.FinishSchedule(completionContext, job, err == nil, code, s.now()) if err != nil { return true, err } diff --git a/apps/api/internal/hostedpublishing/capability_error.go b/apps/api/internal/hostedpublishing/capability_error.go new file mode 100644 index 00000000..8291a5db --- /dev/null +++ b/apps/api/internal/hostedpublishing/capability_error.go @@ -0,0 +1,120 @@ +package hostedpublishing + +import ( + "errors" + "strings" +) + +// CapabilityError names the exact negotiation term that made a Configuration +// Release undeliverable to the requesting SDK. +// +// A bare 406 that says only "the SDK does not support this Configuration +// Release" is undiagnosable: an integrator has no path from the response to the +// header they must add or the SDK version they must ship. Every negotiation +// refusal therefore carries the requirement it failed, the capability or +// contract name involved, its version where one applies, and why it failed. +// +// Nothing here is tenant data or a secret: these are protocol vocabulary terms +// that already appear in the SDK's own request headers and in the published +// protocol contracts. +type CapabilityError struct { + // Requirement is the negotiation term, in the vocabulary of the request + // headers and the protocol contracts (for example + // "experimentFeature" or "configurationDeliveryVersion"). + Requirement string + // Name is the capability, feature, algorithm, or policy identifier. It is + // empty when the requirement is itself version-shaped. + Name string + // Version is the contract or capability version, where one applies. + Version string + // Reason is why negotiation failed. + Reason CapabilityFailureReason +} + +// CapabilityFailureReason is the closed set of negotiation failure modes. +type CapabilityFailureReason string + +const ( + // CapabilityMissing: the Release requires it, the SDK did not advertise it. + CapabilityMissing CapabilityFailureReason = "missing" + // CapabilityUnknown: the SDK advertised a term Mosaic does not define. + CapabilityUnknown CapabilityFailureReason = "unknown" + // CapabilityUnsupported: the value is defined but not accepted here. + CapabilityUnsupported CapabilityFailureReason = "unsupported" + // CapabilityDuplicate: the SDK advertised the same term twice. + CapabilityDuplicate CapabilityFailureReason = "duplicate" + // CapabilityMalformed: the advertised value could not be parsed, is empty, + // or exceeded the allowed count. + CapabilityMalformed CapabilityFailureReason = "malformed" + // CapabilityUnavailable: the Release has no representation the SDK can read. + CapabilityUnavailable CapabilityFailureReason = "unavailable" +) + +func (e *CapabilityError) Error() string { + return "unsupported capability: " + e.Detail() +} + +// Unwrap keeps errors.Is(err, ErrUnsupportedCapability) true so every existing +// caller and status mapping continues to work unchanged. +func (e *CapabilityError) Unwrap() error { return ErrUnsupportedCapability } + +// Subject renders the failing term as it appears in the protocol vocabulary. +func (e *CapabilityError) Subject() string { + switch { + case e.Name != "" && e.Version != "": + return e.Name + "@" + e.Version + case e.Name != "": + return e.Name + default: + return e.Version + } +} + +// Detail is the human-readable sentence returned to the caller. It states the +// requirement, the failing value, and the reason. +func (e *CapabilityError) Detail() string { + subject := e.Subject() + var builder strings.Builder + builder.WriteString(e.Requirement) + if subject != "" { + builder.WriteString(" ") + builder.WriteString(subject) + } + switch e.Reason { + case CapabilityMissing: + builder.WriteString(" is required by this Configuration Release but was not advertised by the SDK") + case CapabilityUnknown: + builder.WriteString(" is not a capability Mosaic defines") + case CapabilityUnsupported: + builder.WriteString(" is not supported by this Mosaic installation") + case CapabilityDuplicate: + builder.WriteString(" was advertised more than once") + case CapabilityMalformed: + builder.WriteString(" was missing, empty, malformed, or advertised too many values") + case CapabilityUnavailable: + builder.WriteString(" has no representation this Configuration Release can serve") + default: + builder.WriteString(" could not be negotiated") + } + return builder.String() +} + +// NewCapabilityError builds a negotiation refusal. The transport layer uses it +// for the refusals it can only detect while parsing request headers. +func NewCapabilityError(requirement, name, version string, reason CapabilityFailureReason) error { + return &CapabilityError{Requirement: requirement, Name: name, Version: version, Reason: reason} +} + +// unsupportedCapability builds a negotiation refusal. +func unsupportedCapability(requirement, name, version string, reason CapabilityFailureReason) error { + return NewCapabilityError(requirement, name, version, reason) +} + +// CapabilityFailure extracts the negotiation detail from an error, if present. +func CapabilityFailure(err error) (*CapabilityError, bool) { + var capabilityError *CapabilityError + if errors.As(err, &capabilityError) { + return capabilityError, true + } + return nil, false +} diff --git a/apps/api/internal/hostedpublishing/capability_request.go b/apps/api/internal/hostedpublishing/capability_request.go index 4f1b4fb8..96094010 100644 --- a/apps/api/internal/hostedpublishing/capability_request.go +++ b/apps/api/internal/hostedpublishing/capability_request.go @@ -87,121 +87,148 @@ func ValidateSDKCapabilityPayload(request SDKCapabilityRequest, payload json.Raw return ValidateSDKCapabilityRequest(request, release) } if version == "3" { - if !containsExactUnique(request.SupportedConfigurationDeliveryVersions, "3", 8) || - !containsExactUnique(request.SupportedExperimentAssignmentContracts, "1", 8) || - !containsKnownUnique(request.SupportedExperimentFeatures, supportedExperimentFeatures, MaxSDKCapabilityCount) || - !containsKnownUnique(request.SupportedExperimentBucketingAlgorithms, supportedExperimentBucketingAlgorithms, 8) || - !containsKnownUnique(request.SupportedExperimentSchedulePolicies, supportedExperimentSchedulePolicies, 8) { - return ErrUnsupportedCapability - } - var envelope struct { - ConfigurationDeliveryVersion string `json:"configurationDeliveryVersion"` - Release struct { - Compatibility struct { - PaywallProtocols []deliveryProtocolCompatibility `json:"paywallProtocols"` - Experiment []struct { - Version string `json:"version"` - RequiredFeatures []string `json:"requiredFeatures"` - BucketingAlgorithms []string `json:"bucketingAlgorithms"` - SchedulePolicies []string `json:"schedulePolicies"` - } `json:"experimentAssignmentContracts"` - } `json:"compatibility"` - ExperimentAssignments []json.RawMessage `json:"experimentAssignments"` - } `json:"release"` - } - if err := json.Unmarshal(payload, &envelope); err != nil || envelope.ConfigurationDeliveryVersion != "3" { - return ErrUnsupportedCapability + return validateDeliveryV3(request, payload) + } + return validateDeliveryV2(request, payload, version) +} + +func validateDeliveryV3(request SDKCapabilityRequest, payload json.RawMessage) error { + if err := requireExactUnique("configurationDeliveryVersion", request.SupportedConfigurationDeliveryVersions, "3", 8); err != nil { + return err + } + if err := requireExactUnique("experimentAssignmentContractVersion", request.SupportedExperimentAssignmentContracts, "1", 8); err != nil { + return err + } + if err := requireKnownUnique("experimentFeature", request.SupportedExperimentFeatures, supportedExperimentFeatures, MaxSDKCapabilityCount); err != nil { + return err + } + if err := requireKnownUnique("experimentBucketingAlgorithm", request.SupportedExperimentBucketingAlgorithms, supportedExperimentBucketingAlgorithms, 8); err != nil { + return err + } + if err := requireKnownUnique("experimentSchedulePolicy", request.SupportedExperimentSchedulePolicies, supportedExperimentSchedulePolicies, 8); err != nil { + return err + } + var envelope struct { + ConfigurationDeliveryVersion string `json:"configurationDeliveryVersion"` + Release struct { + Compatibility struct { + PaywallProtocols []deliveryProtocolCompatibility `json:"paywallProtocols"` + Experiment []struct { + Version string `json:"version"` + RequiredFeatures []string `json:"requiredFeatures"` + BucketingAlgorithms []string `json:"bucketingAlgorithms"` + SchedulePolicies []string `json:"schedulePolicies"` + } `json:"experimentAssignmentContracts"` + } `json:"compatibility"` + ExperimentAssignments []json.RawMessage `json:"experimentAssignments"` + } `json:"release"` + } + if err := json.Unmarshal(payload, &envelope); err != nil || envelope.ConfigurationDeliveryVersion != "3" { + return unsupportedCapability("configurationDeliveryVersion", "", "3", CapabilityUnavailable) + } + features := stringSet(request.SupportedExperimentFeatures) + algorithms := stringSet(request.SupportedExperimentBucketingAlgorithms) + policies := stringSet(request.SupportedExperimentSchedulePolicies) + for _, contract := range envelope.Release.Compatibility.Experiment { + if contract.Version != "1" { + return unsupportedCapability("experimentAssignmentContractVersion", "", contract.Version, CapabilityUnsupported) } - features := stringSet(request.SupportedExperimentFeatures) - algorithms := stringSet(request.SupportedExperimentBucketingAlgorithms) - policies := stringSet(request.SupportedExperimentSchedulePolicies) - for _, contract := range envelope.Release.Compatibility.Experiment { - if contract.Version != "1" { - return ErrUnsupportedCapability - } - for _, v := range contract.RequiredFeatures { - if _, ok := features[v]; !ok { - return ErrUnsupportedCapability - } + for _, feature := range contract.RequiredFeatures { + if _, ok := features[feature]; !ok { + return unsupportedCapability("experimentFeature", feature, "", CapabilityMissing) } - for _, v := range contract.BucketingAlgorithms { - if _, ok := algorithms[v]; !ok { - return ErrUnsupportedCapability - } + } + for _, algorithm := range contract.BucketingAlgorithms { + if _, ok := algorithms[algorithm]; !ok { + return unsupportedCapability("experimentBucketingAlgorithm", algorithm, "", CapabilityMissing) } - for _, v := range contract.SchedulePolicies { - if _, ok := policies[v]; !ok { - return ErrUnsupportedCapability - } + } + for _, policy := range contract.SchedulePolicies { + if _, ok := policies[policy]; !ok { + return unsupportedCapability("experimentSchedulePolicy", policy, "", CapabilityMissing) } } - clone := request - clone.SupportedConfigurationDeliveryVersions = []string{"1"} - v1 := deliveryEnvelope{ConfigurationDeliveryVersion: "1", Release: deliveryRelease{Compatibility: deliveryCompatibility{PaywallProtocols: envelope.Release.Compatibility.PaywallProtocols, Acceptance: "atomic"}}} - v1Payload, _ := json.Marshal(v1) - return ValidateSDKCapabilityRequest(clone, Release{DeliveryContractVersion: "1", Payload: v1Payload}) } - if version != "2" || !containsExactUnique(request.SupportedConfigurationDeliveryVersions, "2", 8) || !containsExactUnique(request.SupportedPlacementDecisionContracts, "1", 8) { - return ErrUnsupportedCapability + return validateEmbeddedV1(request, envelope.Release.Compatibility.PaywallProtocols) +} + +func validateDeliveryV2(request SDKCapabilityRequest, payload json.RawMessage, version string) error { + if version != "2" { + return unsupportedCapability("configurationDeliveryVersion", "", version, CapabilityUnsupported) + } + if err := requireExactUnique("configurationDeliveryVersion", request.SupportedConfigurationDeliveryVersions, "2", 8); err != nil { + return err + } + if err := requireExactUnique("placementDecisionContractVersion", request.SupportedPlacementDecisionContracts, "1", 8); err != nil { + return err } var envelope deliveryV2Envelope if err := json.Unmarshal(payload, &envelope); err != nil || envelope.ConfigurationDeliveryVersion != "2" { - return ErrUnsupportedCapability + return unsupportedCapability("configurationDeliveryVersion", "", "2", CapabilityUnavailable) } features := map[string]struct{}{} for _, feature := range request.SupportedDecisionFeatures { if _, duplicate := features[feature]; duplicate { - return ErrUnsupportedCapability + return unsupportedCapability("decisionFeature", feature, "", CapabilityDuplicate) } features[feature] = struct{}{} } algorithms := map[string]struct{}{} for _, algorithm := range request.SupportedBucketingAlgorithms { if algorithm != placementdecision.BucketingAlgorithm { - return ErrUnsupportedCapability + return unsupportedCapability("bucketingAlgorithm", algorithm, "", CapabilityUnknown) } if _, duplicate := algorithms[algorithm]; duplicate { - return ErrUnsupportedCapability + return unsupportedCapability("bucketingAlgorithm", algorithm, "", CapabilityDuplicate) } algorithms[algorithm] = struct{}{} } for _, contract := range envelope.Release.Compatibility.PlacementDecisionContracts { if contract.Version != "1" { - return ErrUnsupportedCapability + return unsupportedCapability("placementDecisionContractVersion", "", contract.Version, CapabilityUnsupported) } for _, feature := range contract.RequiredFeatures { if _, ok := features[feature]; !ok { - return ErrUnsupportedCapability + return unsupportedCapability("decisionFeature", feature, "", CapabilityMissing) } } for _, algorithm := range contract.BucketingAlgorithms { if _, ok := algorithms[algorithm]; !ok { - return ErrUnsupportedCapability + return unsupportedCapability("bucketingAlgorithm", algorithm, "", CapabilityMissing) } } } + return validateEmbeddedV1(request, envelope.Release.Compatibility.PaywallProtocols) +} + +// validateEmbeddedV1 re-runs the Paywall-protocol half of negotiation against a +// synthesized v1 envelope, so a v2 or v3 Release is still refused when the SDK +// cannot render one of its Paywall capabilities. +func validateEmbeddedV1(request SDKCapabilityRequest, protocols []deliveryProtocolCompatibility) error { clone := request clone.SupportedConfigurationDeliveryVersions = []string{"1"} - v1 := deliveryEnvelope{ConfigurationDeliveryVersion: "1", Release: deliveryRelease{Compatibility: deliveryCompatibility{PaywallProtocols: envelope.Release.Compatibility.PaywallProtocols, Acceptance: "atomic"}}} + v1 := deliveryEnvelope{ConfigurationDeliveryVersion: "1", Release: deliveryRelease{Compatibility: deliveryCompatibility{PaywallProtocols: protocols, Acceptance: "atomic"}}} v1Payload, _ := json.Marshal(v1) return ValidateSDKCapabilityRequest(clone, Release{DeliveryContractVersion: "1", Payload: v1Payload}) } -func containsKnownUnique(values []string, supported map[string]struct{}, limit int) bool { +// requireKnownUnique accepts a non-empty, bounded, duplicate-free list drawn +// entirely from Mosaic's own vocabulary, naming the first offending value. +func requireKnownUnique(requirement string, values []string, supported map[string]struct{}, limit int) error { if len(values) == 0 || len(values) > limit { - return false + return unsupportedCapability(requirement, "", "", CapabilityMalformed) } seen := make(map[string]struct{}, len(values)) for _, value := range values { if _, ok := supported[value]; !ok { - return false + return unsupportedCapability(requirement, value, "", CapabilityUnknown) } if _, duplicate := seen[value]; duplicate { - return false + return unsupportedCapability(requirement, value, "", CapabilityDuplicate) } seen[value] = struct{}{} } - return true + return nil } func stringSet(values []string) map[string]struct{} { @@ -218,39 +245,42 @@ func stringSet(values []string) map[string]struct{} { // and verifies that the selected immutable Release can be accepted atomically. func ValidateSDKCapabilityRequest(request SDKCapabilityRequest, release Release) error { if request.Platform != "flutter" && request.Platform != "ios" && request.Platform != "android" { - return ErrUnsupportedCapability + return unsupportedCapability("sdkPlatform", request.Platform, "", CapabilityUnsupported) } if len(request.SDKVersion) > 64 || !semanticVersionPattern.MatchString(request.SDKVersion) { - return ErrUnsupportedCapability + return unsupportedCapability("sdkVersion", "", request.SDKVersion, CapabilityMalformed) } if request.ApplicationVersion != "" && (len(request.ApplicationVersion) > 64 || !safeApplicationVersion(request.ApplicationVersion)) { - return ErrUnsupportedCapability + return unsupportedCapability("applicationVersion", "", "", CapabilityMalformed) } - if !containsExactUnique(request.SupportedConfigurationDeliveryVersions, DeliveryVersion, 8) { - return ErrUnsupportedCapability + if err := requireExactUnique("configurationDeliveryVersion", request.SupportedConfigurationDeliveryVersions, DeliveryVersion, 8); err != nil { + return err } if len(request.SupportedPaywallProtocols) == 0 || len(request.SupportedPaywallProtocols) > 8 { - return ErrUnsupportedCapability + return unsupportedCapability("paywallProtocolVersion", "", "", CapabilityMalformed) } protocols := make(map[string]map[string]struct{}, len(request.SupportedPaywallProtocols)) for _, protocol := range request.SupportedPaywallProtocols { - if protocol.Version != ProtocolVersion || len(protocol.Capabilities) == 0 || len(protocol.Capabilities) > MaxSDKCapabilityCount { - return ErrUnsupportedCapability + if protocol.Version != ProtocolVersion { + return unsupportedCapability("paywallProtocolVersion", "", protocol.Version, CapabilityUnsupported) + } + if len(protocol.Capabilities) == 0 || len(protocol.Capabilities) > MaxSDKCapabilityCount { + return unsupportedCapability("paywallCapability", "", protocol.Version, CapabilityMalformed) } if _, duplicate := protocols[protocol.Version]; duplicate { - return ErrUnsupportedCapability + return unsupportedCapability("paywallProtocolVersion", "", protocol.Version, CapabilityDuplicate) } capabilities := make(map[string]struct{}, len(protocol.Capabilities)) for _, capability := range protocol.Capabilities { if capability.Version != protocol.Version { - return ErrUnsupportedCapability + return unsupportedCapability("paywallCapability", capability.Name, capability.Version, CapabilityUnsupported) } if _, known := supportedProtocolCapabilities[capability.Name]; !known { - return ErrUnsupportedCapability + return unsupportedCapability("paywallCapability", capability.Name, capability.Version, CapabilityUnknown) } key := capability.Name + "@" + capability.Version if _, duplicate := capabilities[key]; duplicate { - return ErrUnsupportedCapability + return unsupportedCapability("paywallCapability", capability.Name, capability.Version, CapabilityDuplicate) } capabilities[key] = struct{}{} } @@ -258,22 +288,22 @@ func ValidateSDKCapabilityRequest(request SDKCapabilityRequest, release Release) } reported, ok := protocols[ProtocolVersion] if !ok { - return ErrUnsupportedCapability + return unsupportedCapability("paywallProtocolVersion", "", ProtocolVersion, CapabilityMissing) } var envelope deliveryEnvelope if err := json.Unmarshal(release.Payload, &envelope); err != nil || envelope.ConfigurationDeliveryVersion != DeliveryVersion { - return ErrUnsupportedCapability + return unsupportedCapability("configurationDeliveryVersion", "", DeliveryVersion, CapabilityUnavailable) } if len(envelope.Release.Compatibility.PaywallProtocols) == 0 { - return ErrUnsupportedCapability + return unsupportedCapability("paywallProtocolVersion", "", "", CapabilityUnavailable) } for _, protocol := range envelope.Release.Compatibility.PaywallProtocols { if protocol.Version != ProtocolVersion { - return ErrUnsupportedCapability + return unsupportedCapability("paywallProtocolVersion", "", protocol.Version, CapabilityUnsupported) } for _, required := range protocol.RequiredCapabilities { if _, ok := reported[required.Name+"@"+required.Version]; !ok { - return ErrUnsupportedCapability + return unsupportedCapability("paywallCapability", required.Name, required.Version, CapabilityMissing) } } } @@ -285,27 +315,25 @@ func ValidateSDKCapabilityRequest(request SDKCapabilityRequest, release Release) // to a client that declared only v1 support. func ValidateSDKCommerceCapabilityRequest(platform, sdkVersion string, configurationVersions, providerContractVersions []string) error { if platform != "flutter" && platform != "ios" && platform != "android" { - return ErrUnsupportedCapability + return unsupportedCapability("sdkPlatform", platform, "", CapabilityUnsupported) } if len(sdkVersion) > 64 || !semanticVersionPattern.MatchString(sdkVersion) { - return ErrUnsupportedCapability + return unsupportedCapability("sdkVersion", "", sdkVersion, CapabilityMalformed) } - if !containsSupportedUnique(configurationVersions, 8) || - !containsSupportedUnique(providerContractVersions, 8) { - return ErrUnsupportedCapability + if err := requireSupportedUnique("commerceConfigurationVersion", configurationVersions, 8); err != nil { + return err } - return nil + return requireSupportedUnique("commerceProviderContractVersion", providerContractVersions, 8) } func ValidateSDKCommerceSnapshotCapability(version string, configurationVersions, providerContractVersions []string) error { if version != "1" && version != "2" { - return ErrUnsupportedCapability + return unsupportedCapability("commerceConfigurationVersion", "", version, CapabilityUnsupported) } - if !containsExactUnique(configurationVersions, version, 8) || - !containsExactUnique(providerContractVersions, version, 8) { - return ErrUnsupportedCapability + if err := requireExactUnique("commerceConfigurationVersion", configurationVersions, version, 8); err != nil { + return err } - return nil + return requireExactUnique("commerceProviderContractVersion", providerContractVersions, version, 8) } func safeApplicationVersion(value string) bool { @@ -317,40 +345,45 @@ func safeApplicationVersion(value string) bool { return true } -func containsExactUnique(values []string, expected string, limit int) bool { +// requireExactUnique accepts a bounded, duplicate-free list that contains the +// expected version, naming the version the caller failed to advertise. +func requireExactUnique(requirement string, values []string, expected string, limit int) error { if len(values) == 0 || len(values) > limit { - return false + return unsupportedCapability(requirement, "", expected, CapabilityMalformed) } seen := make(map[string]struct{}, len(values)) found := false for _, value := range values { if value == "" { - return false + return unsupportedCapability(requirement, "", "", CapabilityMalformed) } if _, duplicate := seen[value]; duplicate { - return false + return unsupportedCapability(requirement, "", value, CapabilityDuplicate) } seen[value] = struct{}{} if value == expected { found = true } } - return found + if !found { + return unsupportedCapability(requirement, "", expected, CapabilityMissing) + } + return nil } -func containsSupportedUnique(values []string, limit int) bool { +func requireSupportedUnique(requirement string, values []string, limit int) error { if len(values) == 0 || len(values) > limit { - return false + return unsupportedCapability(requirement, "", "", CapabilityMalformed) } seen := make(map[string]struct{}, len(values)) for _, value := range values { if value != "1" && value != "2" { - return false + return unsupportedCapability(requirement, "", value, CapabilityUnsupported) } if _, duplicate := seen[value]; duplicate { - return false + return unsupportedCapability(requirement, "", value, CapabilityDuplicate) } seen[value] = struct{}{} } - return true + return nil } diff --git a/apps/api/internal/hostedpublishing/delivery_v2_test.go b/apps/api/internal/hostedpublishing/delivery_v2_test.go index 0d2a7d00..9463c87d 100644 --- a/apps/api/internal/hostedpublishing/delivery_v2_test.go +++ b/apps/api/internal/hostedpublishing/delivery_v2_test.go @@ -2,6 +2,7 @@ package hostedpublishing import ( "encoding/json" + "errors" "os" "sort" "testing" @@ -146,3 +147,74 @@ func TestSafeV1ProjectionRequiresPaywallDefault(t *testing.T) { t.Fatal("no_paywall default was projected to legacy SDKs") } } + +// A 406 that names nothing is undiagnosable: an SDK integrator has no path from +// the refusal to the header they must send or the capability they must ship. +// Every negotiation refusal must therefore identify the exact term that failed, +// and must keep satisfying errors.Is(err, ErrUnsupportedCapability) so the +// transport status mapping is unchanged. +func TestCapabilityRefusalNamesTheMissingTerm(t *testing.T) { + payload, err := os.ReadFile("../../../../protocol/fixtures/configuration-delivery/v3/experiment-release.json") + if err != nil { + t.Fatal(err) + } + var envelope map[string]any + if err = json.Unmarshal(payload, &envelope); err != nil { + t.Fatal(err) + } + release := envelope["release"].(map[string]any) + features := sortedCapabilityKeys(supportedExperimentFeatures) + request := SDKCapabilityRequest{ + Platform: "ios", SDKVersion: "1.0.0", + SupportedConfigurationDeliveryVersions: []string{"3"}, + SupportedExperimentAssignmentContracts: []string{"1"}, + SupportedExperimentFeatures: features, + SupportedExperimentBucketingAlgorithms: sortedCapabilityKeys(supportedExperimentBucketingAlgorithms), + SupportedExperimentSchedulePolicies: sortedCapabilityKeys(supportedExperimentSchedulePolicies), + SupportedPaywallProtocols: []SDKPaywallProtocolSupport{{Version: "0.2", Capabilities: paywallCapabilities(release)}}, + } + if err = ValidateSDKCapabilityPayload(request, payload, "3"); err != nil { + t.Fatalf("baseline Delivery v3 request rejected: %v", err) + } + + // This is the exact mistake the drill hit: the SDK sent a plausible but + // wrong Experiment-feature name and got a 406 that identified nothing. + misspelt := append([]string(nil), features...) + for index, feature := range misspelt { + if feature == "group.mutual_exclusion" { + misspelt[index] = "mutual_exclusion.groups" + } + } + request.SupportedExperimentFeatures = misspelt + err = ValidateSDKCapabilityPayload(request, payload, "3") + if err == nil { + t.Fatal("an unknown Experiment feature was accepted") + } + if !errors.Is(err, ErrUnsupportedCapability) { + t.Fatalf("refusal no longer maps to ErrUnsupportedCapability: %v", err) + } + capabilityError, ok := CapabilityFailure(err) + if !ok { + t.Fatalf("refusal carried no capability detail: %v", err) + } + if capabilityError.Requirement != "experimentFeature" || + capabilityError.Name != "mutual_exclusion.groups" || + capabilityError.Reason != CapabilityUnknown { + t.Fatalf("refusal did not name the offending feature: %#v", capabilityError) + } + + // A Paywall capability the Release requires but the SDK did not advertise + // must be named too, so an integrator knows which renderer feature to ship. + request.SupportedExperimentFeatures = features + request.SupportedPaywallProtocols[0].Capabilities = []SDKCapability{{Name: "component.text", Version: "0.2"}} + err = ValidateSDKCapabilityPayload(request, payload, "3") + capabilityError, ok = CapabilityFailure(err) + if !ok { + t.Fatalf("missing Paywall capability carried no detail: %v", err) + } + if capabilityError.Requirement != "paywallCapability" || + capabilityError.Name == "" || capabilityError.Version != "0.2" || + capabilityError.Reason != CapabilityMissing { + t.Fatalf("refusal did not name the missing Paywall capability: %#v", capabilityError) + } +} diff --git a/apps/api/internal/hostedpublishing/errors.go b/apps/api/internal/hostedpublishing/errors.go index bb1757b1..586361e8 100644 --- a/apps/api/internal/hostedpublishing/errors.go +++ b/apps/api/internal/hostedpublishing/errors.go @@ -24,8 +24,14 @@ var ( ErrAssetNotReady = errors.New("asset is not ready") ErrAssetReferenced = errors.New("asset is referenced") ErrAssetStorage = errors.New("asset storage operation failed") - ErrNoCurrentRelease = errors.New("no current release") - ErrUnsupportedCapability = errors.New("unsupported capability") + // ErrAssetObjectMissing means the Asset row exists but its immutable bytes + // are absent from object storage -- the state a failed or partial restore + // leaves behind. It is a 404, not a 500: the request named something that + // is not there, and an SDK must be able to tell that from "Mosaic is + // broken" so it can fall back to its bundled Asset. + ErrAssetObjectMissing = errors.New("asset object is missing from storage") + ErrNoCurrentRelease = errors.New("no current release") + ErrUnsupportedCapability = errors.New("unsupported capability") ) type ConflictError struct { diff --git a/apps/api/internal/hostedpublishing/service.go b/apps/api/internal/hostedpublishing/service.go index 130418fc..f2b8c9bd 100644 --- a/apps/api/internal/hostedpublishing/service.go +++ b/apps/api/internal/hostedpublishing/service.go @@ -44,6 +44,10 @@ func WithObjectStore(store ObjectStore, publicBaseURL string, uploadLimit int64) } } +// AssetUploadLimit is the configured maximum Asset size in bytes. The HTTP +// transport uses it to bound the request body instead of a hardcoded ceiling. +func (s *Service) AssetUploadLimit() int64 { return s.assetLimit } + func NewService(repository Repository, options ...ServiceOption) *Service { service := &Service{ repository: repository, @@ -576,7 +580,9 @@ func (s *Service) AuthenticateSDKKeyVersions(ctx context.Context, rawKey string, } } if !found { - return ErrUnsupportedCapability + // The Environment has a current Release, but no representation in + // any delivery contract version the SDK advertised. + return unsupportedCapability("configurationDeliveryVersion", "", strings.Join(supportedVersions, ","), CapabilityUnavailable) } result = SDKConfiguration{Release: release, Payload: representation.Payload, ContentHash: representation.ContentHash, DeliveryContractVersion: representation.DeliveryContractVersion, Environment: environment, APIKeyID: key.ID} return nil @@ -626,7 +632,7 @@ func (s *Service) AuthenticateSDKCommerceKey(ctx context.Context, rawKey, applic return ErrNotFound } if (sdkPlatform == "ios" || sdkPlatform == "android") && sdkPlatform != application.Platform { - return ErrUnsupportedCapability + return unsupportedCapability("sdkPlatform", sdkPlatform, "", CapabilityUnsupported) } state, ok := tx.ReleaseState(environment.ID) if !ok || state.CurrentReleaseID == "" { diff --git a/apps/api/internal/hostedpublishing/service_assets.go b/apps/api/internal/hostedpublishing/service_assets.go index 07a0e6b7..78a9d749 100644 --- a/apps/api/internal/hostedpublishing/service_assets.go +++ b/apps/api/internal/hostedpublishing/service_assets.go @@ -3,6 +3,7 @@ package hostedpublishing import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -189,6 +190,16 @@ func (s *Service) OpenAsset(ctx context.Context, assetID, digest string) (AssetO } body, err := s.objects.Open(ctx, asset.StorageKey) if err != nil { + var missing interface{ ObjectNotFound() bool } + if errors.As(err, &missing) && missing.ObjectNotFound() { + // A referenced object that is not in the bucket is a data-integrity + // problem for the operator (see the missing-object detection in + // scripts/restore-objects.sh) but a plain "not found" for the caller. + zerolog.Ctx(ctx).Error().Err(err).Str("asset_id", asset.ID). + Str("storage_key", asset.StorageKey). + Msg("asset object is referenced by the database but missing from object storage") + return AssetObject{}, ErrAssetObjectMissing + } zerolog.Ctx(ctx).Error().Err(err).Str("asset_id", asset.ID).Msg("asset storage read failed") return AssetObject{}, ErrAssetStorage } diff --git a/apps/api/internal/platform/analyticspostgres/queue_metrics.go b/apps/api/internal/platform/analyticspostgres/queue_metrics.go new file mode 100644 index 00000000..3027ad44 --- /dev/null +++ b/apps/api/internal/platform/analyticspostgres/queue_metrics.go @@ -0,0 +1,71 @@ +package analyticspostgres + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// queueMetricTimeout bounds the observation query so a slow database cannot +// stall the metric export pipeline. +const queueMetricTimeout = 5 * time.Second + +// analyticsQueues maps a metric queue label to its backing table. All four +// tables share the status/available_at/created_at job shape. +var analyticsQueues = map[string]string{ + "aggregate": "analytics_aggregation_jobs", + "export": "analytics_export_jobs", + "deletion": "analytics_deletion_jobs", + "retention": "analytics_retention_runs", +} + +// RegisterQueueMetrics publishes backlog depth and oldest-job age per analytics +// queue. Oldest age is the documented worker-backlog alert signal: depth alone +// does not distinguish a busy queue from a stuck one. +func (r *Repository) RegisterQueueMetrics() error { + meter := otel.Meter("mosaic/analytics") + 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 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 queue age 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 analyticsQueues { + var count int64 + var age *float64 + row := r.pool.QueryRow(ctx, + `SELECT count(*), max(extract(epoch from (now()-created_at))) FROM `+table+ + ` WHERE status IN ('queued','leased')`) + if err := row.Scan(&count, &age); err != nil { + continue + } + attributes := metric.WithAttributes( + attribute.String("family", "analytics"), + attribute.String("queue", queue), + ) + observer.ObserveInt64(depth, count, attributes) + seconds := 0.0 + if age != nil { + seconds = *age + } + observer.ObserveFloat64(oldest, seconds, attributes) + } + return nil + }, depth, oldest) + if err != nil { + return fmt.Errorf("register queue metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/authn/principal.go b/apps/api/internal/platform/authn/principal.go index 8cb284a4..33e57a6b 100644 --- a/apps/api/internal/platform/authn/principal.go +++ b/apps/api/internal/platform/authn/principal.go @@ -54,10 +54,19 @@ func Middleware(resolver Resolver) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, err := resolver.Resolve(r) if err != nil && !errors.Is(err, ErrUnauthenticated) { + // The resolver error is deliberately reduced to its type here: + // an authentication resolver failure can carry connection + // strings or credential fragments in its message. Because + // response.Error logs the cause behind every 5xx, handing it + // the raw error would put exactly what this line redacts into + // the operator log. Respond with a cause-free internal error. zerolog.Ctx(r.Context()).Error(). Str("resolver_error_type", fmt.Sprintf("%T", err)). Msg("authentication resolver failed") - response.Error(w, r, err) + response.Error(w, r, &response.APIError{ + Status: http.StatusInternalServerError, Code: "internal_error", + Message: "An unexpected error occurred.", + }) return } if err == nil && principal.Authenticated() { diff --git a/apps/api/internal/platform/buildinfo/buildinfo.go b/apps/api/internal/platform/buildinfo/buildinfo.go new file mode 100644 index 00000000..658a2a4c --- /dev/null +++ b/apps/api/internal/platform/buildinfo/buildinfo.go @@ -0,0 +1,63 @@ +// Package buildinfo carries the release identity stamped into Mosaic binaries +// at build time so operators can tell exactly which artifact is running. +// +// Values are set with -ldflags at image build time, for example: +// +// go build -ldflags "-X github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo.version=v1.0.0 \ +// -X github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo.commit=abc1234" +package buildinfo + +import ( + "runtime/debug" + "strings" +) + +var ( + version = "" + commit = "" + date = "" +) + +// Info is the resolved build identity. It never contains credentials and is +// safe to expose on /health/live and as OpenTelemetry resource attributes. +type Info struct { + Version string `json:"version"` + Commit string `json:"commit,omitempty"` + Date string `json:"date,omitempty"` +} + +// Current resolves the stamped identity, falling back to Go module build +// metadata (available for `go install`ed binaries) and finally to "dev". +func Current() Info { + info := Info{ + Version: strings.TrimSpace(version), + Commit: strings.TrimSpace(commit), + Date: strings.TrimSpace(date), + } + if info.Version == "" || info.Commit == "" { + if build, ok := debug.ReadBuildInfo(); ok { + if info.Version == "" && build.Main.Version != "" && build.Main.Version != "(devel)" { + info.Version = build.Main.Version + } + for _, setting := range build.Settings { + switch setting.Key { + case "vcs.revision": + if info.Commit == "" { + info.Commit = setting.Value + } + case "vcs.time": + if info.Date == "" { + info.Date = setting.Value + } + } + } + } + } + if info.Version == "" { + info.Version = "dev" + } + return info +} + +// Version is a convenience accessor for the resolved version string. +func Version() string { return Current().Version } diff --git a/apps/api/internal/platform/cloudworkspacememory/repository.go b/apps/api/internal/platform/cloudworkspacememory/repository.go index da162693..3f9f82b9 100644 --- a/apps/api/internal/platform/cloudworkspacememory/repository.go +++ b/apps/api/internal/platform/cloudworkspacememory/repository.go @@ -184,6 +184,16 @@ func (r reader) Organizations() []cloudworkspace.Organization { return sortedValues(r.state.organizations, func(value cloudworkspace.Organization) string { return value.ID }) } +func (r reader) OrganizationsForActor(actorID string) []cloudworkspace.Organization { + values := make([]cloudworkspace.Organization, 0) + for _, organization := range r.Organizations() { + if _, ok := r.Membership(organization.ID, actorID); ok { + values = append(values, organization) + } + } + return values +} + func membershipKey(organizationID, actorID string) string { return organizationID + "\x00" + actorID } func (r reader) Membership(organizationID, actorID string) (cloudworkspace.Membership, bool) { diff --git a/apps/api/internal/platform/cloudworkspacepostgres/audit_metadata_test.go b/apps/api/internal/platform/cloudworkspacepostgres/audit_metadata_test.go new file mode 100644 index 00000000..952388f5 --- /dev/null +++ b/apps/api/internal/platform/cloudworkspacepostgres/audit_metadata_test.go @@ -0,0 +1,48 @@ +package cloudworkspacepostgres + +import "testing" + +// Audit history is immutable, so a reader that cannot decode what a writer +// stored breaks the trail permanently. The Experiment writers record numbers +// (`revision`) and booleans (`valid`) in audit metadata while AuditEvent +// declares map[string]string, which made +// GET /v1/organizations/{id}/audit-events return 500 for the whole +// Organization from the first Experiment action onward. This pins the lenient +// decode that makes already-written history readable. +func TestAuditMetadataDecodesValuesWritersActuallyStore(t *testing.T) { + for name, testCase := range map[string]struct { + stored string + want map[string]string + }{ + "experiment draft update writes a number and a boolean": { + `{"valid":true,"revision":7}`, map[string]string{"valid": "true", "revision": "7"}, + }, + "experiment publish writes mixed scalars": { + `{"releaseId":"release_000004","sourceRevision":7}`, + map[string]string{"releaseId": "release_000004", "sourceRevision": "7"}, + }, + "plain string metadata is unchanged": { + `{"releaseNumber":"3"}`, map[string]string{"releaseNumber": "3"}, + }, + "structured values are kept as JSON text rather than dropped": { + `{"blockers":["a","b"]}`, map[string]string{"blockers": `["a","b"]`}, + }, + "null metadata": {`null`, nil}, + "empty metadata": {``, nil}, + } { + t.Run(name, func(t *testing.T) { + got, err := decodeAuditMetadata([]byte(testCase.stored)) + if err != nil { + t.Fatalf("decode %s: %v", testCase.stored, err) + } + if len(got) != len(testCase.want) { + t.Fatalf("decoded %#v, want %#v", got, testCase.want) + } + for key, want := range testCase.want { + if got[key] != want { + t.Errorf("metadata[%q] = %q, want %q", key, got[key], want) + } + } + }) + } +} diff --git a/apps/api/internal/platform/cloudworkspacepostgres/keyring.go b/apps/api/internal/platform/cloudworkspacepostgres/keyring.go new file mode 100644 index 00000000..24a81790 --- /dev/null +++ b/apps/api/internal/platform/cloudworkspacepostgres/keyring.go @@ -0,0 +1,82 @@ +package cloudworkspacepostgres + +import ( + "context" + "fmt" + "time" + + "github.com/Mujhtech/mosaic/apps/api/internal/cloudworkspace" +) + +const credentialColumns = `connection_id,project_id,organization_id,credential_class,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_at,rotated_at,revoked_at,updated_at` + +// CredentialCountsByKeyID reports how many provider-credential envelopes are +// sealed under each keyring key. It supports the keyring inspect command and +// never returns key material or ciphertext. +func (r *Repository) CredentialCountsByKeyID(ctx context.Context) (map[string]int64, error) { + rows, err := r.pool.Query(ctx, `SELECT key_id, count(*) FROM provider_connection_credentials GROUP BY key_id ORDER BY key_id`) + if err != nil { + return nil, fmt.Errorf("count credential envelopes: %w", err) + } + defer rows.Close() + counts := make(map[string]int64) + for rows.Next() { + var keyID string + var count int64 + if err := rows.Scan(&keyID, &count); err != nil { + return nil, fmt.Errorf("scan credential envelope count: %w", err) + } + counts[keyID] = count + } + return counts, rows.Err() +} + +// CredentialsNotUnderKey returns up to limit envelopes sealed under a key other +// than keyID, ordered stably so rotation can page deterministically. +func (r *Repository) CredentialsNotUnderKey(ctx context.Context, keyID string, limit int) ([]cloudworkspace.ProviderCredentialRecord, error) { + rows, err := r.pool.Query(ctx, + `SELECT `+credentialColumns+` FROM provider_connection_credentials + WHERE key_id <> $1 AND revoked_at IS NULL ORDER BY connection_id LIMIT $2`, keyID, limit) + if err != nil { + return nil, fmt.Errorf("read credential envelopes: %w", err) + } + defer rows.Close() + records := make([]cloudworkspace.ProviderCredentialRecord, 0, limit) + for rows.Next() { + record, err := scanProviderCredential(rows) + if err != nil { + return nil, fmt.Errorf("scan credential envelope: %w", err) + } + records = append(records, record) + } + return records, rows.Err() +} + +// ReplaceCredentialEnvelopes rewrites a batch of envelopes in one transaction. +// A batch either lands completely or not at all, so an interrupted rotation can +// be resumed without leaving a connection with a half-written envelope. +func (r *Repository) ReplaceCredentialEnvelopes(ctx context.Context, records []cloudworkspace.ProviderCredentialRecord, now time.Time) error { + if len(records) == 0 { + return nil + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin credential rotation: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + for _, record := range records { + tag, err := tx.Exec(ctx, + `UPDATE provider_connection_credentials + SET envelope_version=$2,algorithm=$3,key_id=$4,nonce=$5,ciphertext=$6,fingerprint=$7,rotated_at=$8,updated_at=$8 + WHERE connection_id=$1`, + record.ConnectionID, record.Version, record.Algorithm, record.KeyID, + record.Nonce, record.Ciphertext, record.Fingerprint, now) + if err != nil { + return fmt.Errorf("rewrite credential envelope: %w", err) + } + if tag.RowsAffected() != 1 { + return fmt.Errorf("credential envelope for connection %s disappeared during rotation", record.ConnectionID) + } + } + return tx.Commit(ctx) +} diff --git a/apps/api/internal/platform/cloudworkspacepostgres/repository.go b/apps/api/internal/platform/cloudworkspacepostgres/repository.go index 832ecac0..61ed5bc2 100644 --- a/apps/api/internal/platform/cloudworkspacepostgres/repository.go +++ b/apps/api/internal/platform/cloudworkspacepostgres/repository.go @@ -120,6 +120,17 @@ func (r reader) Organizations() []cloudworkspace.Organization { return many(r, `SELECT id,name,created_at,updated_at FROM organizations ORDER BY id`, scanOrganization) } +// OrganizationsForActor joins membership in SQL so the cost of listing a user's +// Organizations is proportional to their memberships, not to every tenant in +// the installation. +func (r reader) OrganizationsForActor(actorID string) []cloudworkspace.Organization { + return many(r, `SELECT o.id,o.name,o.created_at,o.updated_at + FROM organizations o + JOIN organization_members m ON m.organization_id = o.id + WHERE m.actor_id = $1 + ORDER BY o.id`, scanOrganization, actorID) +} + func scanMembership(row pgx.Row) (cloudworkspace.Membership, error) { var v cloudworkspace.Membership err := row.Scan(&v.OrganizationID, &v.ActorID, &v.Role, &v.CreatedAt, &v.UpdatedAt) @@ -429,18 +440,62 @@ func (r reader) ProviderMetadataSnapshot(id string) (cloudworkspace.ProviderProd func scanProviderMappingObservation(row pgx.Row) (cloudworkspace.ProviderMappingObservation, error) { var v cloudworkspace.ProviderMappingObservation var metadata []byte + // diagnostic_code is written as SQL NULL for an observation that carries no + // diagnostic -- that is, every successful one -- so scanning it into a + // string failed with "cannot scan NULL into *string" and listing + // observations broke as soon as one succeeded. + var diagnosticCode *string err := row.Scan( &v.ID, &v.ProjectID, &v.MappingID, &v.EnvironmentID, &v.ApplicationID, &v.Platform, &v.Provider, &v.AdapterVersion, &v.StoreContext, &v.Result, - &v.DiagnosticCode, &v.CorrelationID, &metadata, &v.ObservedAt, + &diagnosticCode, &v.CorrelationID, &metadata, &v.ObservedAt, &v.ExpiresAt, &v.ReceivedAt, &v.CreatedByActorID, ) if err == nil { + if diagnosticCode != nil { + v.DiagnosticCode = *diagnosticCode + } err = json.Unmarshal(metadata, &v.Metadata) } return v, err } +// decodeAuditMetadata reads stored audit metadata leniently. +// +// AuditEvent.Metadata is map[string]string, but the Experiment writers record +// map[string]any values including revision numbers and validation booleans. +// Decoding straight into map[string]string therefore failed with "cannot +// unmarshal number into Go value of type string", and because audit history is +// immutable, one Experiment action made +// GET /v1/organizations/{id}/audit-events return 500 for that Organization +// permanently -- the audit trail became unreadable exactly where it matters. +// +// Non-string scalars are rendered as their JSON text and structured values as +// compact JSON, so history written before this fix is readable and the public +// contract stays a string map. +func decodeAuditMetadata(raw []byte) (map[string]string, error) { + if len(raw) == 0 { + return nil, nil + } + var values map[string]json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + if values == nil { + return nil, nil + } + result := make(map[string]string, len(values)) + for key, value := range values { + var text string + if err := json.Unmarshal(value, &text); err == nil { + result[key] = text + continue + } + result[key] = string(value) + } + return result, nil +} + const providerMappingObservationColumns = `id,project_id,mapping_id,environment_id,application_id,platform,provider,adapter_version,store_context,result,diagnostic_code,correlation_id,metadata,observed_at,expires_at,received_at,created_by_actor_id` func (r reader) ProviderMappingObservation(id string) (cloudworkspace.ProviderMappingObservation, bool) { @@ -547,7 +602,7 @@ func scanAudit(row pgx.Row) (cloudworkspace.AuditEvent, error) { if environmentID != nil { v.EnvironmentID = *environmentID } - err = json.Unmarshal(metadata, &v.Metadata) + v.Metadata, err = decodeAuditMetadata(metadata) } return v, err } @@ -637,6 +692,19 @@ func (t *transaction) SaveProductReplacement(v cloudworkspace.ProductReplacement func (t *transaction) DeleteProductGrant(productID, entitlementID string) { t.exec(`DELETE FROM product_entitlement_grants WHERE product_id=$1 AND entitlement_id=$2`, productID, entitlementID) } + +// jsonObjectOrEmpty keeps a nil or empty JSON document out of a NOT NULL jsonb +// column. `provider_product_metadata_snapshots.normalized_metadata` is +// NOT NULL with a `{}` default and an "is an object" CHECK, but an explicit +// NULL overrides a column default, so a snapshot recorded for a provider +// Product that carries no normalized metadata failed the insert outright. +func jsonObjectOrEmpty(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return raw +} + func emptyStringAsNil(value string) any { if value == "" { return nil @@ -744,7 +812,7 @@ func (t *transaction) SaveProviderMetadataSnapshot(v cloudworkspace.ProviderProd VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, v.ID, v.ProjectID, v.MappingID, v.Source, v.Digest, v.Availability, v.ObservedAt, v.SyncedAt, v.StaleAt, v.ExpiresAt, emptyStringAsNil(string(v.LastErrorCode)), - v.Metadata, v.CreatedAt, + jsonObjectOrEmpty(v.Metadata), v.CreatedAt, ) } func (t *transaction) SaveProviderEntitlementMapping(v cloudworkspace.ProviderEntitlementMapping) { diff --git a/apps/api/internal/platform/cloudworkspacepostgres/repository_integration_test.go b/apps/api/internal/platform/cloudworkspacepostgres/repository_integration_test.go index 481a84df..2b033433 100644 --- a/apps/api/internal/platform/cloudworkspacepostgres/repository_integration_test.go +++ b/apps/api/internal/platform/cloudworkspacepostgres/repository_integration_test.go @@ -47,8 +47,12 @@ func TestPhase3APersistenceRisks(t *testing.T) { if err := goose.SetDialect("postgres"); err != nil { t.Fatal(err) } - if err := goose.DownToContext(ctx, db, ".", 0); err != nil { - t.Fatalf("reset migrations: %v", err) + // Reset by dropping the schema rather than rolling migrations down: since + // Phase 8, irreversible down migrations correctly refuse when affected data + // exists, so a rollback is not a usable test reset. DATABASE_TEST_URL is + // documented as a throwaway database. + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must be a throwaway database): %v", err) } if err := goose.UpToContext(ctx, db, ".", 7); err != nil { t.Fatalf("apply accepted migrations through 00007: %v", err) @@ -337,10 +341,16 @@ func TestPhase3APersistenceRisks(t *testing.T) { production = environment } } + // A server-connected RevenueCat connection requires the external project id; + // the invariant landed after this call site was written, which is why it + // failed with providerProjectInvalid. connection, err := service.CreateProviderConnection(ctx, owner, project.ID, cloudworkspace.CreateProviderConnectionInput{ Name: "RevenueCat sandbox", Provider: cloudworkspace.ProviderRevenueCat, IntegrationMode: cloudworkspace.ProviderServerConnected, Mode: cloudworkspace.ProviderSandbox, - EnvironmentIDs: []string{development.ID, production.ID}, ApplicationIDs: []string{application.ID}, + ExternalProjectID: "proj_phase3a", + // A sandbox connection may not be scoped to a production Environment, + // so this covers development and staging. + EnvironmentIDs: []string{development.ID, staging.ID}, ApplicationIDs: []string{application.ID}, }) if err != nil { t.Fatalf("persist provider connection: %v", err) @@ -349,12 +359,12 @@ func TestPhase3APersistenceRisks(t *testing.T) { t.Fatalf("persist provider assignment: %v", err) } if _, err := service.ReplaceProviderConnectionScopes(ctx, owner, connection.ID, cloudworkspace.ReplaceProviderConnectionScopesInput{ - EnvironmentIDs: []string{development.ID, production.ID}, ApplicationIDs: []string{application.ID}, + EnvironmentIDs: []string{development.ID, staging.ID}, ApplicationIDs: []string{application.ID}, }); err != nil { t.Fatalf("idempotently retain in-use provider scopes: %v", err) } if _, err := service.ReplaceProviderConnectionScopes(ctx, owner, connection.ID, cloudworkspace.ReplaceProviderConnectionScopesInput{ - EnvironmentIDs: []string{production.ID}, ApplicationIDs: []string{application.ID}, + EnvironmentIDs: []string{staging.ID}, ApplicationIDs: []string{application.ID}, }); !errors.Is(err, cloudworkspace.ErrScopeMismatch) { t.Fatalf("remove in-use provider Environment scope error=%v, want scope mismatch", err) } @@ -372,7 +382,9 @@ func TestPhase3APersistenceRisks(t *testing.T) { ID: "provider_snapshot_000001", ProjectID: project.ID, MappingID: mapping.ID, Source: cloudworkspace.ProviderMetadataProvider, Digest: strings.Repeat("a", 64), Availability: cloudworkspace.ProviderAvailabilityAvailable, - ObservedAt: now, SyncedAt: now, CreatedAt: now, + // stale_at is NOT NULL and must not precede observed_at; a zero value + // violates the freshness ordering the schema enforces. + ObservedAt: now, SyncedAt: now, StaleAt: now.Add(time.Hour), CreatedAt: now, } if err := repository.Transact(ctx, func(tx cloudworkspace.Transaction) error { tx.SaveProviderMetadataSnapshot(snapshot) @@ -391,7 +403,9 @@ func TestPhase3APersistenceRisks(t *testing.T) { 'connected_out_of_scope',$1,$2,$3,'revenuecat','out.of.scope','active', $4,$5,'ios',now(),now() )`, - project.ID, replacement.ID, application.ID, connection.ID, staging.ID, + // production is deliberately outside this sandbox connection's scope + // (development + staging), so the database must refuse the mapping. + project.ID, replacement.ID, application.ID, connection.ID, production.ID, ); err == nil { t.Fatal("database accepted connected mapping outside its connection Environment scope") } @@ -492,7 +506,7 @@ func TestPhase3APersistenceRisks(t *testing.T) { t.Fatal("database accepted mutation of an archived provider mapping") } if got, err := reconstructed.GetProviderConnection(ctx, owner, connection.ID); err != nil || - !reflect.DeepEqual(got.EnvironmentIDs, []string{development.ID, production.ID}) || + !reflect.DeepEqual(got.EnvironmentIDs, []string{development.ID, staging.ID}) || !reflect.DeepEqual(got.ApplicationIDs, []string{application.ID}) { t.Fatalf("reconstructed provider connection = %#v, %v", got, err) } @@ -567,8 +581,12 @@ func TestPhase4AProviderPersistenceRisks(t *testing.T) { if err := goose.SetDialect("postgres"); err != nil { t.Fatal(err) } - if err := goose.DownToContext(ctx, db, ".", 0); err != nil { - t.Fatalf("reset migrations: %v", err) + // Reset by dropping the schema rather than rolling migrations down: since + // Phase 8, irreversible down migrations correctly refuse when affected data + // exists, so a rollback is not a usable test reset. DATABASE_TEST_URL is + // documented as a throwaway database. + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must be a throwaway database): %v", err) } if err := goose.UpContext(ctx, db, "."); err != nil { t.Fatalf("apply Phase 4A migrations: %v", err) @@ -741,6 +759,14 @@ func TestPhase4AProviderPersistenceRisks(t *testing.T) { if err != nil { t.Fatalf("create replacement provider connection: %v", err) } + // A never-tested connection is deliberately not ready: ProviderReadiness + // reports providerUnavailable until a successful test, which is the safe + // direction for commerce Product resolution. The first connection is tested + // above; the replacement must be too, or this asserts the untested state + // rather than the connection swap it exists to protect. + if _, err := service.TestProviderConnection(ctx, actor, secondConnection.ID); err != nil { + t.Fatalf("test replacement provider connection: %v", err) + } secondImport, err := service.ImportProviderProducts(ctx, actor, project.ID, secondConnection.ID, cloudworkspace.ImportProviderProductsInput{ IdempotencyKey: "phase4a-postgres-second-connection-import", Items: []cloudworkspace.ProviderProductImportInput{{ @@ -794,8 +820,12 @@ func TestPhase3BPublishingPersistenceRisks(t *testing.T) { if err := goose.SetDialect("postgres"); err != nil { t.Fatal(err) } - if err := goose.DownToContext(ctx, db, ".", 0); err != nil { - t.Fatalf("reset migrations: %v", err) + // Reset by dropping the schema rather than rolling migrations down: since + // Phase 8, irreversible down migrations correctly refuse when affected data + // exists, so a rollback is not a usable test reset. DATABASE_TEST_URL is + // documented as a throwaway database. + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must be a throwaway database): %v", err) } if err := goose.UpContext(ctx, db, "."); err != nil { t.Fatalf("apply Phase 3B migrations: %v", err) diff --git a/apps/api/internal/platform/config/config.go b/apps/api/internal/platform/config/config.go index eda213c9..309a8a81 100644 --- a/apps/api/internal/platform/config/config.go +++ b/apps/api/internal/platform/config/config.go @@ -11,6 +11,8 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" + + "github.com/Mujhtech/mosaic/apps/api/internal/providercredential" ) const ( @@ -18,6 +20,10 @@ const ( defaultAuthBurst = 4 defaultObjectStoreAccessKey = "mosaic" defaultObjectStoreSecretKey = "mosaic_dev_secret" + + // maxTransportUploadBytes is the hard ceiling the HTTP asset-upload route + // will accept regardless of MOSAIC_ASSET_MAX_UPLOAD_BYTES. + maxTransportUploadBytes = 256 << 20 ) var defaultAllowedOrigins = []string{ @@ -25,6 +31,10 @@ var defaultAllowedOrigins = []string{ "http://127.0.0.1:3000", } +var logLevels = map[string]struct{}{ + "trace": {}, "debug": {}, "info": {}, "warn": {}, "error": {}, "fatal": {}, "panic": {}, +} + type Config struct { Environment string `envconfig:"MOSAIC_ENVIRONMENT" default:"development"` HTTP HTTPConfig @@ -37,11 +47,18 @@ type Config struct { Delivery DeliveryConfig Analytics AnalyticsConfig Providers ProviderConfig + Worker WorkerConfig +} + +// ProductionLike reports whether the deployment must satisfy the strict +// production configuration guards. +func (cfg Config) ProductionLike() bool { + return cfg.Environment != "development" && cfg.Environment != "test" } type AnalyticsConfig struct { - EventSchemaPath string `envconfig:"MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH" default:"../../protocol/schema/analytics-event/v1/event.schema.json"` - EventV2SchemaPath string `envconfig:"MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH" default:"../../protocol/schema/analytics-event/v2/event.schema.json"` + EventSchemaPath string `envconfig:"MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH"` + EventV2SchemaPath string `envconfig:"MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH"` IPRequestsPerMinute int `envconfig:"MOSAIC_ANALYTICS_IP_REQUESTS_PER_MINUTE" default:"30"` IPBurst int `envconfig:"MOSAIC_ANALYTICS_IP_BURST" default:"10"` KeyBatchesPerMinute int `envconfig:"MOSAIC_ANALYTICS_KEY_BATCHES_PER_MINUTE" default:"60"` @@ -61,12 +78,15 @@ type BrowserAuthConfig struct { LimiterEntries int `envconfig:"MOSAIC_AUTH_LIMITER_ENTRIES" default:"10000"` } +// ProtocolConfig holds optional filesystem overrides for the canonical protocol +// schemas. Every value defaults to empty, meaning "use the schema embedded in +// the binary"; an override is only for operators pinning a local file. type ProtocolConfig struct { - V02SchemaPath string `envconfig:"MOSAIC_PROTOCOL_V02_SCHEMA_PATH" default:"../../protocol/schema/v0.2/paywall.schema.json"` - CommerceProviderSchemaPath string `envconfig:"MOSAIC_COMMERCE_PROVIDER_SCHEMA_PATH" default:"../../protocol/schema/commerce-provider/v1/contract.schema.json"` - CommerceConfigurationSchemaPath string `envconfig:"MOSAIC_COMMERCE_CONFIGURATION_SCHEMA_PATH" default:"../../protocol/schema/commerce-configuration/v1/configuration.schema.json"` - CommerceProviderV2SchemaPath string `envconfig:"MOSAIC_COMMERCE_PROVIDER_V2_SCHEMA_PATH" default:"../../protocol/schema/commerce-provider/v2/contract.schema.json"` - CommerceConfigurationV2SchemaPath string `envconfig:"MOSAIC_COMMERCE_CONFIGURATION_V2_SCHEMA_PATH" default:"../../protocol/schema/commerce-configuration/v2/configuration.schema.json"` + V02SchemaPath string `envconfig:"MOSAIC_PROTOCOL_V02_SCHEMA_PATH"` + CommerceProviderSchemaPath string `envconfig:"MOSAIC_COMMERCE_PROVIDER_SCHEMA_PATH"` + CommerceConfigurationSchemaPath string `envconfig:"MOSAIC_COMMERCE_CONFIGURATION_SCHEMA_PATH"` + CommerceProviderV2SchemaPath string `envconfig:"MOSAIC_COMMERCE_PROVIDER_V2_SCHEMA_PATH"` + CommerceConfigurationV2SchemaPath string `envconfig:"MOSAIC_COMMERCE_CONFIGURATION_V2_SCHEMA_PATH"` } type ProviderConfig struct { @@ -83,37 +103,84 @@ type ProviderConfig struct { } type ObjectStoreConfig struct { - Endpoint string `envconfig:"MOSAIC_OBJECT_STORAGE_ENDPOINT" default:"localhost:9000"` - AccessKey string `envconfig:"MOSAIC_OBJECT_STORAGE_ACCESS_KEY" default:"mosaic"` - SecretKey string `envconfig:"MOSAIC_OBJECT_STORAGE_SECRET_KEY" default:"mosaic_dev_secret"` - Bucket string `envconfig:"MOSAIC_OBJECT_STORAGE_BUCKET" default:"mosaic-assets"` - UseTLS bool `envconfig:"MOSAIC_OBJECT_STORAGE_TLS" default:"false"` - PublicAssetBaseURL string `envconfig:"MOSAIC_PUBLIC_ASSET_BASE_URL" default:"https://localhost:8443/v1/sdk/assets"` - MaxUploadBytes int64 `envconfig:"MOSAIC_ASSET_MAX_UPLOAD_BYTES" default:"10485760"` + Endpoint string `envconfig:"MOSAIC_OBJECT_STORAGE_ENDPOINT" default:"localhost:9000"` + AccessKey string `envconfig:"MOSAIC_OBJECT_STORAGE_ACCESS_KEY" default:"mosaic"` + SecretKey string `envconfig:"MOSAIC_OBJECT_STORAGE_SECRET_KEY" default:"mosaic_dev_secret"` + Bucket string `envconfig:"MOSAIC_OBJECT_STORAGE_BUCKET" default:"mosaic-assets"` + UseTLS bool `envconfig:"MOSAIC_OBJECT_STORAGE_TLS" default:"false"` + AllowInsecure bool `envconfig:"MOSAIC_OBJECT_STORAGE_ALLOW_INSECURE" default:"false"` + PublicAssetBaseURL string `envconfig:"MOSAIC_PUBLIC_ASSET_BASE_URL" default:"https://localhost:8443/v1/sdk/assets"` + MaxUploadBytes int64 `envconfig:"MOSAIC_ASSET_MAX_UPLOAD_BYTES" default:"10485760"` + OperationTimeout time.Duration `envconfig:"MOSAIC_OBJECT_STORAGE_OPERATION_TIMEOUT" default:"30s"` + CheckTimeout time.Duration `envconfig:"MOSAIC_OBJECT_STORAGE_CHECK_TIMEOUT" default:"5s"` } type DeliveryConfig struct { RequestsPerMinute int `envconfig:"MOSAIC_DELIVERY_REQUESTS_PER_MINUTE" default:"120"` Burst int `envconfig:"MOSAIC_DELIVERY_BURST" default:"30"` LimiterEntries int `envconfig:"MOSAIC_DELIVERY_LIMITER_ENTRIES" default:"10000"` + // Baseline limit for authenticated dashboard/API surfaces that previously + // had no limiter at all. + APIRequestsPerMinute int `envconfig:"MOSAIC_API_REQUESTS_PER_MINUTE" default:"600"` + APIBurst int `envconfig:"MOSAIC_API_BURST" default:"120"` + // Limit for Placement/Experiment decision reads. + DecisionRequestsPerMinute int `envconfig:"MOSAIC_DECISION_REQUESTS_PER_MINUTE" default:"600"` + DecisionBurst int `envconfig:"MOSAIC_DECISION_BURST" default:"120"` + // Asset upload is bounded separately from the baseline API limit: each + // request may carry MOSAIC_ASSET_MAX_UPLOAD_BYTES of body, hold a long + // upload timeout, and write to object storage, so the baseline 600/minute + // would allow one actor to saturate storage bandwidth on its own. + UploadRequestsPerMinute int `envconfig:"MOSAIC_UPLOAD_REQUESTS_PER_MINUTE" default:"30"` + UploadBurst int `envconfig:"MOSAIC_UPLOAD_BURST" default:"10"` + // Export and privacy-request submissions each enqueue an asynchronous job + // that scans analytics history, so they are far more expensive than the + // dashboard reads sharing the baseline API bucket. + ExportRequestsPerMinute int `envconfig:"MOSAIC_EXPORT_REQUESTS_PER_MINUTE" default:"10"` + ExportBurst int `envconfig:"MOSAIC_EXPORT_BURST" default:"5"` } type DatabaseConfig struct { - URL string `envconfig:"DATABASE_URL" required:"true"` - MaxConnections int32 `envconfig:"DATABASE_MAX_CONNECTIONS" default:"10"` - MinConnections int32 `envconfig:"DATABASE_MIN_CONNECTIONS" default:"2"` - ConnectTimeout time.Duration `envconfig:"DATABASE_CONNECT_TIMEOUT" default:"5s"` + URL string `envconfig:"DATABASE_URL" required:"true"` + MaxConnections int32 `envconfig:"DATABASE_MAX_CONNECTIONS" default:"10"` + MinConnections int32 `envconfig:"DATABASE_MIN_CONNECTIONS" default:"2"` + ConnectTimeout time.Duration `envconfig:"DATABASE_CONNECT_TIMEOUT" default:"5s"` + MaxConnLifetime time.Duration `envconfig:"DATABASE_MAX_CONN_LIFETIME" default:"30m"` + MaxConnIdleTime time.Duration `envconfig:"DATABASE_MAX_CONN_IDLE_TIME" default:"5m"` + HealthCheckPeriod time.Duration `envconfig:"DATABASE_HEALTH_CHECK_PERIOD" default:"30s"` + StatementTimeout time.Duration `envconfig:"DATABASE_STATEMENT_TIMEOUT" default:"30s"` + LockTimeout time.Duration `envconfig:"DATABASE_LOCK_TIMEOUT" default:"5s"` + CloseTimeout time.Duration `envconfig:"DATABASE_CLOSE_TIMEOUT" default:"5s"` + // AllowInsecure permits a production DATABASE_URL without sslmode. + AllowInsecure bool `envconfig:"MOSAIC_DATABASE_ALLOW_INSECURE" default:"false"` } type HTTPConfig struct { - Address string `envconfig:"MOSAIC_HTTP_ADDRESS" default:":8080"` - ReadHeaderTimeout time.Duration `envconfig:"MOSAIC_HTTP_READ_HEADER_TIMEOUT" default:"5s"` - ReadTimeout time.Duration `envconfig:"MOSAIC_HTTP_READ_TIMEOUT" default:"15s"` - WriteTimeout time.Duration `envconfig:"MOSAIC_HTTP_WRITE_TIMEOUT" default:"15s"` - IdleTimeout time.Duration `envconfig:"MOSAIC_HTTP_IDLE_TIMEOUT" default:"60s"` - HandlerTimeout time.Duration `envconfig:"MOSAIC_HTTP_HANDLER_TIMEOUT" default:"10s"` - ShutdownTimeout time.Duration `envconfig:"MOSAIC_HTTP_SHUTDOWN_TIMEOUT" default:"10s"` - CORSAllowedOrigins []string `envconfig:"MOSAIC_CORS_ALLOWED_ORIGINS" default:"http://localhost:3000,http://127.0.0.1:3000"` + Address string `envconfig:"MOSAIC_HTTP_ADDRESS" default:":8080"` + ReadHeaderTimeout time.Duration `envconfig:"MOSAIC_HTTP_READ_HEADER_TIMEOUT" default:"5s"` + ReadTimeout time.Duration `envconfig:"MOSAIC_HTTP_READ_TIMEOUT" default:"120s"` + WriteTimeout time.Duration `envconfig:"MOSAIC_HTTP_WRITE_TIMEOUT" default:"120s"` + IdleTimeout time.Duration `envconfig:"MOSAIC_HTTP_IDLE_TIMEOUT" default:"60s"` + HandlerTimeout time.Duration `envconfig:"MOSAIC_HTTP_HANDLER_TIMEOUT" default:"10s"` + UploadTimeout time.Duration `envconfig:"MOSAIC_HTTP_UPLOAD_TIMEOUT" default:"90s"` + IngestTimeout time.Duration `envconfig:"MOSAIC_HTTP_INGEST_TIMEOUT" default:"30s"` + // DrainDelay is how long the instance keeps serving after readiness flips + // to draining and before the HTTP listener closes. Without it the listener + // closes in the same instant readiness flips, so a load balancer polling + // readiness sees connection-refused rather than a clean 503 and routes + // traffic into a closing instance. 0 disables the wait. + DrainDelay time.Duration `envconfig:"MOSAIC_HTTP_DRAIN_DELAY" default:"5s"` + ShutdownTimeout time.Duration `envconfig:"MOSAIC_HTTP_SHUTDOWN_TIMEOUT" default:"20s"` + TelemetryShutdownTimeout time.Duration `envconfig:"MOSAIC_TELEMETRY_SHUTDOWN_TIMEOUT" default:"5s"` + CORSAllowedOrigins []string `envconfig:"MOSAIC_CORS_ALLOWED_ORIGINS" default:"http://localhost:3000,http://127.0.0.1:3000"` + // TrustedProxyCIDRs lists peer networks whose X-Forwarded-For/X-Real-IP + // headers may be trusted. Empty (the default) means never trust them. + TrustedProxyCIDRs []string `envconfig:"MOSAIC_TRUSTED_PROXY_CIDRS"` +} + +type WorkerConfig struct { + HealthAddress string `envconfig:"MOSAIC_WORKER_HEALTH_ADDRESS" default:":8081"` + JobShutdownBudget time.Duration `envconfig:"MOSAIC_WORKER_JOB_SHUTDOWN_BUDGET" default:"30s"` + ScheduleLease time.Duration `envconfig:"MOSAIC_WORKER_SCHEDULE_LEASE" default:"2m"` } type LogConfig struct { @@ -126,6 +193,18 @@ type TelemetryConfig struct { OTLPEndpoint string `envconfig:"OTEL_EXPORTER_OTLP_ENDPOINT"` } +// ValidationError aggregates every configuration problem found at startup so an +// operator can fix them in one pass. It never contains configured values, only +// variable names and the reason they were rejected. +type ValidationError struct{ Problems []string } + +func (e *ValidationError) Error() string { + return fmt.Sprintf( + "invalid Mosaic configuration (%d problem(s)):\n - %s", + len(e.Problems), strings.Join(e.Problems, "\n - "), + ) +} + func Load() (Config, error) { if err := godotenv.Load(); err != nil && !errors.Is(err, fs.ErrNotExist) { return Config{}, fmt.Errorf("load .env: %w", err) @@ -160,13 +239,9 @@ func load() (Config, error) { cfg.ObjectStore.SecretKey = strings.TrimSpace(cfg.ObjectStore.SecretKey) cfg.ObjectStore.Bucket = strings.TrimSpace(cfg.ObjectStore.Bucket) cfg.ObjectStore.PublicAssetBaseURL = strings.TrimSpace(cfg.ObjectStore.PublicAssetBaseURL) - origins := cfg.HTTP.CORSAllowedOrigins[:0] - for _, origin := range cfg.HTTP.CORSAllowedOrigins { - if origin = strings.TrimSpace(origin); origin != "" { - origins = append(origins, origin) - } - } - cfg.HTTP.CORSAllowedOrigins = origins + cfg.Worker.HealthAddress = strings.TrimSpace(cfg.Worker.HealthAddress) + cfg.HTTP.CORSAllowedOrigins = nonEmpty(cfg.HTTP.CORSAllowedOrigins) + cfg.HTTP.TrustedProxyCIDRs = nonEmpty(cfg.HTTP.TrustedProxyCIDRs) if err := cfg.validate(); err != nil { return Config{}, err @@ -175,157 +250,323 @@ func load() (Config, error) { return cfg, nil } +func nonEmpty(values []string) []string { + result := values[:0] + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + result = append(result, value) + } + } + return result +} + +type problems struct{ list []string } + +func (p *problems) add(format string, args ...any) { + p.list = append(p.list, fmt.Sprintf(format, args...)) +} + +func (p *problems) requirePositive(values map[string]time.Duration) { + for _, key := range sortedDurationKeys(values) { + if values[key] <= 0 { + p.add("%s must be greater than zero", key) + } + } +} + +func (p *problems) requirePositiveInts(values map[string]int) { + for _, key := range sortedIntKeys(values) { + if values[key] <= 0 { + p.add("%s must be a positive integer", key) + } + } +} + +func sortedDurationKeys(values map[string]time.Duration) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sortStrings(keys) + return keys +} + +func sortedIntKeys(values map[string]int) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sortStrings(keys) + return keys +} + +func sortStrings(values []string) { + for i := 1; i < len(values); i++ { + for j := i; j > 0 && values[j] < values[j-1]; j-- { + values[j], values[j-1] = values[j-1], values[j] + } + } +} + func (cfg Config) validate() error { + report := &problems{} + productionLike := cfg.ProductionLike() + if strings.TrimSpace(cfg.Environment) == "" { - return fmt.Errorf("MOSAIC_ENVIRONMENT must not be empty") + report.add("MOSAIC_ENVIRONMENT must not be empty") } - if _, _, err := net.SplitHostPort(cfg.HTTP.Address); err != nil { - return fmt.Errorf("MOSAIC_HTTP_ADDRESS must be a host:port address: %w", err) + cfg.validateHTTP(report, productionLike) + cfg.validateLogging(report) + cfg.validateDatabase(report, productionLike) + cfg.validateObjectStore(report, productionLike) + cfg.validateProviders(report, productionLike) + cfg.validateAnalytics(report) + cfg.validateWorker(report) + + if strings.TrimSpace(cfg.Telemetry.ServiceName) == "" { + report.add("OTEL_SERVICE_NAME must not be empty") + } + if cfg.BrowserAuth.SessionLifetime <= 0 { + report.add("MOSAIC_SESSION_LIFETIME must be greater than zero") + } + if productionLike && !cfg.BrowserAuth.CookieSecure { + report.add("MOSAIC_SESSION_COOKIE_SECURE must be true outside development and test") } + report.requirePositiveInts(map[string]int{ + "MOSAIC_AUTH_REQUESTS_PER_MINUTE": cfg.BrowserAuth.RequestsPerMinute, + "MOSAIC_AUTH_BURST": cfg.BrowserAuth.Burst, + "MOSAIC_AUTH_LIMITER_ENTRIES": cfg.BrowserAuth.LimiterEntries, + "MOSAIC_DELIVERY_REQUESTS_PER_MINUTE": cfg.Delivery.RequestsPerMinute, + "MOSAIC_DELIVERY_BURST": cfg.Delivery.Burst, + "MOSAIC_DELIVERY_LIMITER_ENTRIES": cfg.Delivery.LimiterEntries, + "MOSAIC_API_REQUESTS_PER_MINUTE": cfg.Delivery.APIRequestsPerMinute, + "MOSAIC_API_BURST": cfg.Delivery.APIBurst, + "MOSAIC_DECISION_REQUESTS_PER_MINUTE": cfg.Delivery.DecisionRequestsPerMinute, + "MOSAIC_DECISION_BURST": cfg.Delivery.DecisionBurst, + "MOSAIC_UPLOAD_REQUESTS_PER_MINUTE": cfg.Delivery.UploadRequestsPerMinute, + "MOSAIC_UPLOAD_BURST": cfg.Delivery.UploadBurst, + "MOSAIC_EXPORT_REQUESTS_PER_MINUTE": cfg.Delivery.ExportRequestsPerMinute, + "MOSAIC_EXPORT_BURST": cfg.Delivery.ExportBurst, + }) + + if len(report.list) > 0 { + return &ValidationError{Problems: report.list} + } + return nil +} - if cfg.HTTP.HandlerTimeout >= cfg.HTTP.WriteTimeout { - return fmt.Errorf( - "MOSAIC_HTTP_HANDLER_TIMEOUT must be shorter than MOSAIC_HTTP_WRITE_TIMEOUT", - ) +func (cfg Config) validateHTTP(report *problems, productionLike bool) { + if _, _, err := net.SplitHostPort(cfg.HTTP.Address); err != nil { + report.add("MOSAIC_HTTP_ADDRESS must be a host:port address") + } + report.requirePositive(map[string]time.Duration{ + "MOSAIC_HTTP_READ_HEADER_TIMEOUT": cfg.HTTP.ReadHeaderTimeout, + "MOSAIC_HTTP_READ_TIMEOUT": cfg.HTTP.ReadTimeout, + "MOSAIC_HTTP_WRITE_TIMEOUT": cfg.HTTP.WriteTimeout, + "MOSAIC_HTTP_IDLE_TIMEOUT": cfg.HTTP.IdleTimeout, + "MOSAIC_HTTP_HANDLER_TIMEOUT": cfg.HTTP.HandlerTimeout, + "MOSAIC_HTTP_UPLOAD_TIMEOUT": cfg.HTTP.UploadTimeout, + "MOSAIC_HTTP_INGEST_TIMEOUT": cfg.HTTP.IngestTimeout, + "MOSAIC_HTTP_SHUTDOWN_TIMEOUT": cfg.HTTP.ShutdownTimeout, + "MOSAIC_TELEMETRY_SHUTDOWN_TIMEOUT": cfg.HTTP.TelemetryShutdownTimeout, + }) + if cfg.HTTP.DrainDelay < 0 { + report.add("MOSAIC_HTTP_DRAIN_DELAY must not be negative") } for key, value := range map[string]time.Duration{ - "MOSAIC_HTTP_READ_HEADER_TIMEOUT": cfg.HTTP.ReadHeaderTimeout, - "MOSAIC_HTTP_READ_TIMEOUT": cfg.HTTP.ReadTimeout, - "MOSAIC_HTTP_WRITE_TIMEOUT": cfg.HTTP.WriteTimeout, - "MOSAIC_HTTP_IDLE_TIMEOUT": cfg.HTTP.IdleTimeout, - "MOSAIC_HTTP_HANDLER_TIMEOUT": cfg.HTTP.HandlerTimeout, - "MOSAIC_HTTP_SHUTDOWN_TIMEOUT": cfg.HTTP.ShutdownTimeout, + "MOSAIC_HTTP_HANDLER_TIMEOUT": cfg.HTTP.HandlerTimeout, + "MOSAIC_HTTP_UPLOAD_TIMEOUT": cfg.HTTP.UploadTimeout, + "MOSAIC_HTTP_INGEST_TIMEOUT": cfg.HTTP.IngestTimeout, } { - if value <= 0 { - return fmt.Errorf("%s must be greater than zero", key) + if value > 0 && cfg.HTTP.WriteTimeout > 0 && value >= cfg.HTTP.WriteTimeout { + report.add("%s must be shorter than MOSAIC_HTTP_WRITE_TIMEOUT", key) } } + // An empty list disables CORS entirely, which is valid for deployments with + // no browser client. Only non-empty entries are checked. + for _, origin := range cfg.HTTP.CORSAllowedOrigins { + if origin == "*" { + report.add("MOSAIC_CORS_ALLOWED_ORIGINS must not contain the wildcard origin because Mosaic sends credentialed requests") + continue + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + report.add("MOSAIC_CORS_ALLOWED_ORIGINS entries must be absolute http(s) origins") + continue + } + if productionLike && parsed.Scheme != "https" { + report.add("MOSAIC_CORS_ALLOWED_ORIGINS must use https outside development and test") + } + } + for _, entry := range cfg.HTTP.TrustedProxyCIDRs { + if _, _, err := net.ParseCIDR(entry); err != nil { + if net.ParseIP(entry) == nil { + report.add("MOSAIC_TRUSTED_PROXY_CIDRS entries must be CIDR blocks or IP addresses") + } + } + } +} +func (cfg Config) validateLogging(report *problems) { switch cfg.Log.Format { case "json", "console": default: - return fmt.Errorf("MOSAIC_LOG_FORMAT must be json or console") + report.add("MOSAIC_LOG_FORMAT must be json or console") } - - if strings.TrimSpace(cfg.Log.Level) == "" { - return fmt.Errorf("MOSAIC_LOG_LEVEL must not be empty") + if _, ok := logLevels[cfg.Log.Level]; !ok { + report.add("MOSAIC_LOG_LEVEL must be one of trace, debug, info, warn, error, fatal, panic") } +} - if strings.TrimSpace(cfg.Telemetry.ServiceName) == "" { - return fmt.Errorf("OTEL_SERVICE_NAME must not be empty") - } - if cfg.Database.MinConnections > cfg.Database.MaxConnections { - return fmt.Errorf("DATABASE_MIN_CONNECTIONS must not exceed DATABASE_MAX_CONNECTIONS") - } +func (cfg Config) validateDatabase(report *problems, productionLike bool) { if strings.TrimSpace(cfg.Database.URL) == "" { - return fmt.Errorf("DATABASE_URL is required") + report.add("DATABASE_URL is required") + } else if productionLike && !cfg.Database.AllowInsecure { + if mode := databaseSSLMode(cfg.Database.URL); mode == "" || mode == "disable" || mode == "allow" || mode == "prefer" { + report.add( + "DATABASE_URL must set a verifying sslmode (require, verify-ca, or verify-full) outside " + + "development and test; set MOSAIC_DATABASE_ALLOW_INSECURE=true only for a trusted private network", + ) + } } if cfg.Database.MaxConnections <= 0 { - return fmt.Errorf("DATABASE_MAX_CONNECTIONS must be greater than zero") + report.add("DATABASE_MAX_CONNECTIONS must be greater than zero") } if cfg.Database.MinConnections < 0 { - return fmt.Errorf("DATABASE_MIN_CONNECTIONS must not be negative") + report.add("DATABASE_MIN_CONNECTIONS must not be negative") } - if cfg.Database.ConnectTimeout <= 0 { - return fmt.Errorf("DATABASE_CONNECT_TIMEOUT must be greater than zero") - } - if strings.TrimSpace(cfg.Protocol.V02SchemaPath) == "" { - return fmt.Errorf("MOSAIC_PROTOCOL_V02_SCHEMA_PATH must not be empty") + if cfg.Database.MinConnections > cfg.Database.MaxConnections { + report.add("DATABASE_MIN_CONNECTIONS must not exceed DATABASE_MAX_CONNECTIONS") + } + report.requirePositive(map[string]time.Duration{ + "DATABASE_CONNECT_TIMEOUT": cfg.Database.ConnectTimeout, + "DATABASE_MAX_CONN_LIFETIME": cfg.Database.MaxConnLifetime, + "DATABASE_MAX_CONN_IDLE_TIME": cfg.Database.MaxConnIdleTime, + "DATABASE_HEALTH_CHECK_PERIOD": cfg.Database.HealthCheckPeriod, + "DATABASE_STATEMENT_TIMEOUT": cfg.Database.StatementTimeout, + "DATABASE_LOCK_TIMEOUT": cfg.Database.LockTimeout, + "DATABASE_CLOSE_TIMEOUT": cfg.Database.CloseTimeout, + }) + if cfg.Database.LockTimeout > 0 && cfg.Database.StatementTimeout > 0 && + cfg.Database.LockTimeout > cfg.Database.StatementTimeout { + report.add("DATABASE_LOCK_TIMEOUT must not exceed DATABASE_STATEMENT_TIMEOUT") } - if cfg.Protocol.CommerceProviderSchemaPath == "" { - return fmt.Errorf("MOSAIC_COMMERCE_PROVIDER_SCHEMA_PATH must not be empty") +} + +// databaseSSLMode extracts sslmode from either a URL or a key/value DSN without +// retaining or logging any credential material. +func databaseSSLMode(raw string) string { + if parsed, err := url.Parse(raw); err == nil && parsed.Scheme != "" && parsed.Host != "" { + return strings.ToLower(strings.TrimSpace(parsed.Query().Get("sslmode"))) + } + for _, field := range strings.Fields(raw) { + name, value, found := strings.Cut(field, "=") + if found && strings.EqualFold(strings.TrimSpace(name), "sslmode") { + return strings.ToLower(strings.Trim(strings.TrimSpace(value), `'"`)) + } } - if cfg.Protocol.CommerceConfigurationSchemaPath == "" { - return fmt.Errorf("MOSAIC_COMMERCE_CONFIGURATION_SCHEMA_PATH must not be empty") + return "" +} + +func (cfg Config) validateObjectStore(report *problems, productionLike bool) { + if cfg.ObjectStore.Endpoint == "" || cfg.ObjectStore.AccessKey == "" || + cfg.ObjectStore.SecretKey == "" || cfg.ObjectStore.Bucket == "" { + report.add("MOSAIC_OBJECT_STORAGE_ENDPOINT, _ACCESS_KEY, _SECRET_KEY, and _BUCKET must all be set") } - if cfg.Protocol.CommerceProviderV2SchemaPath == "" || cfg.Protocol.CommerceConfigurationV2SchemaPath == "" { - return errors.New("Commerce Configuration v2 schema paths are required") + if cfg.ObjectStore.MaxUploadBytes <= 0 { + report.add("MOSAIC_ASSET_MAX_UPLOAD_BYTES must be a positive integer") } - if cfg.Analytics.EventSchemaPath == "" { - return errors.New("MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH must not be empty") + if cfg.ObjectStore.MaxUploadBytes > maxTransportUploadBytes { + report.add("MOSAIC_ASSET_MAX_UPLOAD_BYTES must not exceed the %d byte transport ceiling", maxTransportUploadBytes) } - if cfg.Analytics.EventV2SchemaPath == "" { - return errors.New("MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH must not be empty") + report.requirePositive(map[string]time.Duration{ + "MOSAIC_OBJECT_STORAGE_OPERATION_TIMEOUT": cfg.ObjectStore.OperationTimeout, + "MOSAIC_OBJECT_STORAGE_CHECK_TIMEOUT": cfg.ObjectStore.CheckTimeout, + }) + assetURL, err := url.Parse(cfg.ObjectStore.PublicAssetBaseURL) + if err != nil || assetURL.Host == "" || assetURL.User != nil || assetURL.Scheme != "https" { + report.add("MOSAIC_PUBLIC_ASSET_BASE_URL must be an absolute HTTPS URL without credentials") } - for key, value := range map[string]int{ - "MOSAIC_ANALYTICS_IP_REQUESTS_PER_MINUTE": cfg.Analytics.IPRequestsPerMinute, - "MOSAIC_ANALYTICS_IP_BURST": cfg.Analytics.IPBurst, - "MOSAIC_ANALYTICS_KEY_BATCHES_PER_MINUTE": cfg.Analytics.KeyBatchesPerMinute, - "MOSAIC_ANALYTICS_KEY_BATCH_BURST": cfg.Analytics.KeyBatchBurst, - "MOSAIC_ANALYTICS_KEY_EVENTS_PER_MINUTE": cfg.Analytics.KeyEventsPerMinute, - "MOSAIC_ANALYTICS_KEY_EVENT_BURST": cfg.Analytics.KeyEventBurst, - "MOSAIC_ANALYTICS_LIMITER_ENTRIES": cfg.Analytics.LimiterEntries, - } { - if value <= 0 { - return fmt.Errorf("%s must be greater than zero", key) + if productionLike { + if cfg.ObjectStore.AccessKey == defaultObjectStoreAccessKey || cfg.ObjectStore.SecretKey == defaultObjectStoreSecretKey { + report.add("development object-storage credentials must not be used outside development and test") + } + if !cfg.ObjectStore.UseTLS && !cfg.ObjectStore.AllowInsecure { + report.add( + "MOSAIC_OBJECT_STORAGE_TLS must be true outside development and test; set " + + "MOSAIC_OBJECT_STORAGE_ALLOW_INSECURE=true only when object storage is reached over a trusted private network", + ) } } - if cfg.Analytics.WorkerPollInterval <= 0 { - return errors.New("MOSAIC_ANALYTICS_WORKER_POLL_INTERVAL must be greater than zero") - } - if cfg.Providers.Enabled && cfg.Providers.CredentialKeyring == "" { - return fmt.Errorf("MOSAIC_PROVIDER_CREDENTIAL_KEYRING is required when provider integrations are enabled") +} + +func (cfg Config) validateProviders(report *problems, productionLike bool) { + switch { + case cfg.Providers.Enabled && cfg.Providers.CredentialKeyring == "": + report.add("MOSAIC_PROVIDER_CREDENTIAL_KEYRING is required when provider integrations are enabled") + case cfg.Providers.CredentialKeyring != "": + if err := providercredential.ValidateKeyring(cfg.Providers.CredentialKeyring); err != nil { + report.add( + "MOSAIC_PROVIDER_CREDENTIAL_KEYRING is not a valid version 1 keyring: it must be JSON with " + + "version, activeKeyId, and keys mapping each key ID to a base64url 32-byte key including the active one", + ) + } } if cfg.Providers.RevenueCatBaseURL == "" { - return fmt.Errorf("MOSAIC_REVENUECAT_BASE_URL must not be empty") - } - providerBaseURL, err := url.Parse(cfg.Providers.RevenueCatBaseURL) - if err != nil || providerBaseURL.Host == "" || providerBaseURL.User != nil || - providerBaseURL.Scheme != "https" && providerBaseURL.Scheme != "http" { - return fmt.Errorf("MOSAIC_REVENUECAT_BASE_URL must be an absolute HTTP(S) URL without credentials") - } - if cfg.Environment != "development" && cfg.Environment != "test" && providerBaseURL.Scheme != "https" { - return fmt.Errorf("MOSAIC_REVENUECAT_BASE_URL must use HTTPS outside development and test") - } - if cfg.Providers.RequestTimeout <= 0 || cfg.Providers.OperationTimeout <= 0 || cfg.Providers.ConnectTimeout <= 0 || - cfg.Providers.SnapshotTTL <= 0 || cfg.Providers.WorkerPollInterval <= 0 { - return fmt.Errorf("provider timeout, freshness, and worker intervals must be greater than zero") + report.add("MOSAIC_REVENUECAT_BASE_URL must not be empty") + } else { + providerBaseURL, err := url.Parse(cfg.Providers.RevenueCatBaseURL) + switch { + case err != nil || providerBaseURL.Host == "" || providerBaseURL.User != nil || + (providerBaseURL.Scheme != "https" && providerBaseURL.Scheme != "http"): + report.add("MOSAIC_REVENUECAT_BASE_URL must be an absolute HTTP(S) URL without credentials") + case productionLike && providerBaseURL.Scheme != "https": + report.add("MOSAIC_REVENUECAT_BASE_URL must use HTTPS outside development and test") + } } + report.requirePositive(map[string]time.Duration{ + "MOSAIC_PROVIDER_REQUEST_TIMEOUT": cfg.Providers.RequestTimeout, + "MOSAIC_PROVIDER_OPERATION_TIMEOUT": cfg.Providers.OperationTimeout, + "MOSAIC_PROVIDER_CONNECT_TIMEOUT": cfg.Providers.ConnectTimeout, + "MOSAIC_PROVIDER_SNAPSHOT_TTL": cfg.Providers.SnapshotTTL, + "MOSAIC_PROVIDER_WORKER_POLL_INTERVAL": cfg.Providers.WorkerPollInterval, + }) if cfg.Providers.OperationTimeout < cfg.Providers.RequestTimeout { - return fmt.Errorf("MOSAIC_PROVIDER_OPERATION_TIMEOUT must be greater than or equal to MOSAIC_PROVIDER_REQUEST_TIMEOUT") + report.add("MOSAIC_PROVIDER_OPERATION_TIMEOUT must be greater than or equal to MOSAIC_PROVIDER_REQUEST_TIMEOUT") } if cfg.Providers.OperationTimeout > 5*time.Minute { - return fmt.Errorf("MOSAIC_PROVIDER_OPERATION_TIMEOUT must not exceed 5m") + report.add("MOSAIC_PROVIDER_OPERATION_TIMEOUT must not exceed 5m") } if cfg.Providers.MaxResponseBytes <= 0 { - return fmt.Errorf("MOSAIC_PROVIDER_MAX_RESPONSE_BYTES must be greater than zero") + report.add("MOSAIC_PROVIDER_MAX_RESPONSE_BYTES must be greater than zero") } if cfg.Providers.MaxAttempts < 1 || cfg.Providers.MaxAttempts > 5 { - return fmt.Errorf("MOSAIC_PROVIDER_MAX_ATTEMPTS must be between 1 and 5") - } - if strings.TrimSpace(cfg.ObjectStore.Endpoint) == "" || strings.TrimSpace(cfg.ObjectStore.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStore.SecretKey) == "" || strings.TrimSpace(cfg.ObjectStore.Bucket) == "" { - return fmt.Errorf("S3-compatible object-storage configuration must not be empty") - } - if cfg.ObjectStore.MaxUploadBytes <= 0 { - return fmt.Errorf("MOSAIC_ASSET_MAX_UPLOAD_BYTES must be a positive integer") + report.add("MOSAIC_PROVIDER_MAX_ATTEMPTS must be between 1 and 5") } - if cfg.BrowserAuth.SessionLifetime <= 0 { - return fmt.Errorf("MOSAIC_SESSION_LIFETIME must be greater than zero") - } - for key, value := range map[string]int{ - "MOSAIC_AUTH_REQUESTS_PER_MINUTE": cfg.BrowserAuth.RequestsPerMinute, - "MOSAIC_AUTH_BURST": cfg.BrowserAuth.Burst, - "MOSAIC_AUTH_LIMITER_ENTRIES": cfg.BrowserAuth.LimiterEntries, - "MOSAIC_DELIVERY_REQUESTS_PER_MINUTE": cfg.Delivery.RequestsPerMinute, - "MOSAIC_DELIVERY_BURST": cfg.Delivery.Burst, - "MOSAIC_DELIVERY_LIMITER_ENTRIES": cfg.Delivery.LimiterEntries, - } { - if value <= 0 { - return fmt.Errorf("%s must be a positive integer", key) - } - } - assetURL, err := url.Parse(cfg.ObjectStore.PublicAssetBaseURL) - if err != nil || assetURL.Host == "" || assetURL.User != nil || assetURL.Scheme != "https" { - return fmt.Errorf("MOSAIC_PUBLIC_ASSET_BASE_URL must be an absolute HTTPS URL without credentials") - } - productionLike := cfg.Environment != "development" && cfg.Environment != "test" - if productionLike && !cfg.BrowserAuth.CookieSecure { - return fmt.Errorf("MOSAIC_SESSION_COOKIE_SECURE must be true outside development and test") - } - if productionLike && (cfg.ObjectStore.AccessKey == defaultObjectStoreAccessKey || cfg.ObjectStore.SecretKey == defaultObjectStoreSecretKey) { - return fmt.Errorf("development object-storage credentials must not be used outside development and test") +} + +func (cfg Config) validateAnalytics(report *problems) { + report.requirePositiveInts(map[string]int{ + "MOSAIC_ANALYTICS_IP_REQUESTS_PER_MINUTE": cfg.Analytics.IPRequestsPerMinute, + "MOSAIC_ANALYTICS_IP_BURST": cfg.Analytics.IPBurst, + "MOSAIC_ANALYTICS_KEY_BATCHES_PER_MINUTE": cfg.Analytics.KeyBatchesPerMinute, + "MOSAIC_ANALYTICS_KEY_BATCH_BURST": cfg.Analytics.KeyBatchBurst, + "MOSAIC_ANALYTICS_KEY_EVENTS_PER_MINUTE": cfg.Analytics.KeyEventsPerMinute, + "MOSAIC_ANALYTICS_KEY_EVENT_BURST": cfg.Analytics.KeyEventBurst, + "MOSAIC_ANALYTICS_LIMITER_ENTRIES": cfg.Analytics.LimiterEntries, + }) + if cfg.Analytics.WorkerPollInterval <= 0 { + report.add("MOSAIC_ANALYTICS_WORKER_POLL_INTERVAL must be greater than zero") } +} - return nil +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") + } + report.requirePositive(map[string]time.Duration{ + "MOSAIC_WORKER_JOB_SHUTDOWN_BUDGET": cfg.Worker.JobShutdownBudget, + "MOSAIC_WORKER_SCHEDULE_LEASE": cfg.Worker.ScheduleLease, + }) } diff --git a/apps/api/internal/platform/config/config_test.go b/apps/api/internal/platform/config/config_test.go index db8ce6b6..95b517e6 100644 --- a/apps/api/internal/platform/config/config_test.go +++ b/apps/api/internal/platform/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "errors" "os" "path/filepath" "reflect" @@ -226,6 +227,20 @@ func clearConfigEnvironment(t *testing.T) { "MOSAIC_PROVIDER_OPERATION_TIMEOUT", "MOSAIC_PROVIDER_CONNECT_TIMEOUT", "MOSAIC_PROVIDER_MAX_RESPONSE_BYTES", "MOSAIC_PROVIDER_MAX_ATTEMPTS", "MOSAIC_PROVIDER_SNAPSHOT_TTL", "MOSAIC_PROVIDER_WORKER_POLL_INTERVAL", + "MOSAIC_ANALYTICS_EVENT_SCHEMA_PATH", "MOSAIC_ANALYTICS_EVENT_V2_SCHEMA_PATH", + "MOSAIC_ANALYTICS_IP_REQUESTS_PER_MINUTE", "MOSAIC_ANALYTICS_IP_BURST", + "MOSAIC_ANALYTICS_KEY_BATCHES_PER_MINUTE", "MOSAIC_ANALYTICS_KEY_BATCH_BURST", + "MOSAIC_ANALYTICS_KEY_EVENTS_PER_MINUTE", "MOSAIC_ANALYTICS_KEY_EVENT_BURST", + "MOSAIC_ANALYTICS_LIMITER_ENTRIES", "MOSAIC_ANALYTICS_WORKER_POLL_INTERVAL", + "MOSAIC_COMMERCE_PROVIDER_V2_SCHEMA_PATH", "MOSAIC_COMMERCE_CONFIGURATION_V2_SCHEMA_PATH", + "MOSAIC_HTTP_UPLOAD_TIMEOUT", "MOSAIC_HTTP_INGEST_TIMEOUT", "MOSAIC_TELEMETRY_SHUTDOWN_TIMEOUT", + "MOSAIC_TRUSTED_PROXY_CIDRS", "MOSAIC_DATABASE_ALLOW_INSECURE", + "MOSAIC_OBJECT_STORAGE_ALLOW_INSECURE", "MOSAIC_OBJECT_STORAGE_OPERATION_TIMEOUT", + "MOSAIC_OBJECT_STORAGE_CHECK_TIMEOUT", "MOSAIC_API_REQUESTS_PER_MINUTE", "MOSAIC_API_BURST", + "MOSAIC_DECISION_REQUESTS_PER_MINUTE", "MOSAIC_DECISION_BURST", + "MOSAIC_WORKER_HEALTH_ADDRESS", "MOSAIC_WORKER_JOB_SHUTDOWN_BUDGET", "MOSAIC_WORKER_SCHEDULE_LEASE", + "DATABASE_MAX_CONN_LIFETIME", "DATABASE_MAX_CONN_IDLE_TIME", "DATABASE_HEALTH_CHECK_PERIOD", + "DATABASE_STATEMENT_TIMEOUT", "DATABASE_LOCK_TIMEOUT", "DATABASE_CLOSE_TIMEOUT", } for _, key := range keys { value, existed := os.LookupEnv(key) @@ -242,3 +257,158 @@ func clearConfigEnvironment(t *testing.T) { }) } } + +// Production guards are the last line of defence between an operator's typo and +// a GA deployment that leaks credentials over plaintext transport or accepts +// requests from any origin. Each case asserts one guard fires and that the +// error names the variable without echoing its value. +func TestProductionConfigurationGuards(t *testing.T) { + secureProduction := map[string]string{ + "MOSAIC_ENVIRONMENT": "production", + "MOSAIC_CORS_ALLOWED_ORIGINS": "https://studio.example", + "MOSAIC_SESSION_COOKIE_SECURE": "true", + "MOSAIC_PUBLIC_ASSET_BASE_URL": "https://assets.example/v1/sdk/assets", + "MOSAIC_OBJECT_STORAGE_ACCESS_KEY": "production-access", + "MOSAIC_OBJECT_STORAGE_SECRET_KEY": "production-secret", + "MOSAIC_OBJECT_STORAGE_TLS": "true", + "DATABASE_URL": "postgres://mosaic:secret-password@db.example:5432/mosaic?sslmode=verify-full", + } + + for name, test := range map[string]struct { + overrides map[string]string + want string + accepted bool + }{ + "baseline production configuration is accepted": { + overrides: map[string]string{}, + accepted: true, + }, + "wildcard CORS origin is rejected": { + overrides: map[string]string{"MOSAIC_CORS_ALLOWED_ORIGINS": "*"}, + want: "MOSAIC_CORS_ALLOWED_ORIGINS", + }, + "plaintext CORS origin is rejected": { + overrides: map[string]string{"MOSAIC_CORS_ALLOWED_ORIGINS": "http://studio.example"}, + want: "MOSAIC_CORS_ALLOWED_ORIGINS", + }, + "DATABASE_URL without sslmode is rejected": { + overrides: map[string]string{"DATABASE_URL": "postgres://mosaic:secret-password@db.example:5432/mosaic"}, + want: "DATABASE_URL", + }, + "DATABASE_URL with sslmode=disable is rejected": { + overrides: map[string]string{"DATABASE_URL": "postgres://mosaic:secret-password@db.example:5432/mosaic?sslmode=disable"}, + want: "DATABASE_URL", + }, + "DATABASE_URL escape hatch is honoured": { + overrides: map[string]string{ + "DATABASE_URL": "postgres://mosaic:secret-password@db.example:5432/mosaic", + "MOSAIC_DATABASE_ALLOW_INSECURE": "true", + }, + accepted: true, + }, + "object storage without TLS is rejected": { + overrides: map[string]string{"MOSAIC_OBJECT_STORAGE_TLS": "false"}, + want: "MOSAIC_OBJECT_STORAGE_TLS", + }, + "object storage escape hatch is honoured": { + overrides: map[string]string{ + "MOSAIC_OBJECT_STORAGE_TLS": "false", + "MOSAIC_OBJECT_STORAGE_ALLOW_INSECURE": "true", + }, + accepted: true, + }, + "unknown log level is rejected": { + overrides: map[string]string{"MOSAIC_LOG_LEVEL": "verbose"}, + want: "MOSAIC_LOG_LEVEL", + }, + "malformed keyring is rejected": { + overrides: map[string]string{"MOSAIC_PROVIDER_CREDENTIAL_KEYRING": `{"version":1,"activeKeyId":"k1"}`}, + want: "MOSAIC_PROVIDER_CREDENTIAL_KEYRING", + }, + "upload bytes above the transport ceiling are rejected": { + overrides: map[string]string{"MOSAIC_ASSET_MAX_UPLOAD_BYTES": "1073741824"}, + want: "MOSAIC_ASSET_MAX_UPLOAD_BYTES", + }, + "upload timeout at or above the write timeout is rejected": { + overrides: map[string]string{"MOSAIC_HTTP_UPLOAD_TIMEOUT": "120s", "MOSAIC_HTTP_WRITE_TIMEOUT": "120s"}, + want: "MOSAIC_HTTP_UPLOAD_TIMEOUT", + }, + "malformed trusted proxy CIDR is rejected": { + overrides: map[string]string{"MOSAIC_TRUSTED_PROXY_CIDRS": "not-a-network"}, + want: "MOSAIC_TRUSTED_PROXY_CIDRS", + }, + } { + t.Run(name, func(t *testing.T) { + values := make(map[string]string, len(secureProduction)+len(test.overrides)) + for key, value := range secureProduction { + values[key] = value + } + for key, value := range test.overrides { + values[key] = value + } + _, err := loadTestConfig(t, values) + if test.accepted { + if err != nil { + t.Fatalf("secure production configuration rejected: %v", err) + } + return + } + if err == nil { + t.Fatalf("configuration accepted, want %s rejected", test.want) + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %q, want it to name %s", err, test.want) + } + for _, secret := range []string{"secret-password", "production-secret"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("startup error leaked a secret value: %q", err) + } + } + }) + } +} + +func TestValidationErrorReportsEveryProblem(t *testing.T) { + _, err := loadTestConfig(t, map[string]string{ + "MOSAIC_LOG_LEVEL": "verbose", + "MOSAIC_LOG_FORMAT": "yaml", + "MOSAIC_WORKER_HEALTH_ADDRESS": "not-an-address", + }) + var validationError *ValidationError + if !errors.As(err, &validationError) { + t.Fatalf("error = %v, want *ValidationError", err) + } + if len(validationError.Problems) < 3 { + t.Fatalf("problems = %#v, want every problem reported in one pass", validationError.Problems) + } +} + +// A rolling restart sheds traffic at the edge unless readiness reports 503 for +// long enough that a load balancer notices before the listener closes. The +// default must therefore be non-zero; 0 is a deliberate opt-out, and a negative +// value is a configuration error rather than a silently-ignored one. +func TestDrainDelayDefaultsToAnObservableWindow(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://mosaic:mosaic@localhost:5432/mosaic?sslmode=disable") + cfg, err := Load() + if err != nil { + t.Fatalf("load configuration: %v", err) + } + if cfg.HTTP.DrainDelay <= 0 { + t.Fatalf("MOSAIC_HTTP_DRAIN_DELAY default = %s, want a non-zero window", cfg.HTTP.DrainDelay) + } + if cfg.HTTP.DrainDelay >= cfg.HTTP.ShutdownTimeout { + t.Fatalf("drain delay %s must leave room inside the shutdown timeout %s", + cfg.HTTP.DrainDelay, cfg.HTTP.ShutdownTimeout) + } + + t.Setenv("MOSAIC_HTTP_DRAIN_DELAY", "-1s") + if _, err := Load(); err == nil { + t.Fatal("a negative drain delay was accepted") + } + + t.Setenv("MOSAIC_HTTP_DRAIN_DELAY", "0s") + cfg, err = Load() + if err != nil || cfg.HTTP.DrainDelay != 0 { + t.Fatalf("0 must be an accepted opt-out: delay=%s err=%v", cfg.HTTP.DrainDelay, err) + } +} diff --git a/apps/api/internal/platform/database/database.go b/apps/api/internal/platform/database/database.go index 59c43967..2017f7ea 100644 --- a/apps/api/internal/platform/database/database.go +++ b/apps/api/internal/platform/database/database.go @@ -2,14 +2,19 @@ package database import ( "context" + "errors" "fmt" + "strconv" "time" "github.com/exaring/otelpgx" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + + "github.com/Mujhtech/mosaic/apps/api/migrations" ) type Config struct { @@ -17,6 +22,15 @@ type Config struct { MaxConnections int32 MinConnections int32 ConnectTimeout time.Duration + // MaxConnLifetime, MaxConnIdleTime, and HealthCheckPeriod keep the pool from + // pinning connections to a failed-over PostgreSQL instance indefinitely. + MaxConnLifetime time.Duration + MaxConnIdleTime time.Duration + HealthCheckPeriod time.Duration + // StatementTimeout and LockTimeout bound every session so one pathological + // query cannot hold a connection or a lock for the life of the process. + StatementTimeout time.Duration + LockTimeout time.Duration } func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) { @@ -26,6 +40,24 @@ func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) { } poolConfig.MaxConns = cfg.MaxConnections poolConfig.MinConns = cfg.MinConnections + if cfg.MaxConnLifetime > 0 { + poolConfig.MaxConnLifetime = cfg.MaxConnLifetime + } + if cfg.MaxConnIdleTime > 0 { + poolConfig.MaxConnIdleTime = cfg.MaxConnIdleTime + } + if cfg.HealthCheckPeriod > 0 { + poolConfig.HealthCheckPeriod = cfg.HealthCheckPeriod + } + if poolConfig.ConnConfig.RuntimeParams == nil { + poolConfig.ConnConfig.RuntimeParams = map[string]string{} + } + if cfg.StatementTimeout > 0 { + setDefaultRuntimeParam(poolConfig, "statement_timeout", milliseconds(cfg.StatementTimeout)) + } + if cfg.LockTimeout > 0 { + setDefaultRuntimeParam(poolConfig, "lock_timeout", milliseconds(cfg.LockTimeout)) + } poolConfig.ConnConfig.Tracer = otelpgx.NewTracer() connectCtx, cancel := context.WithTimeout(ctx, cfg.ConnectTimeout) defer cancel() @@ -40,6 +72,19 @@ func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) { return pool, nil } +// setDefaultRuntimeParam keeps an operator-supplied DATABASE_URL parameter as +// the authority; Mosaic only fills in a default when none was configured. +func setDefaultRuntimeParam(cfg *pgxpool.Config, name, value string) { + if _, ok := cfg.ConnConfig.RuntimeParams[name]; ok { + return + } + cfg.ConnConfig.RuntimeParams[name] = value +} + +func milliseconds(value time.Duration) string { + return strconv.FormatInt(value.Milliseconds(), 10) +} + type Pinger interface{ Ping(context.Context) error } type HealthChecker struct{ Pinger Pinger } @@ -55,3 +100,56 @@ func Ping(ctx context.Context, pinger Pinger) error { } return nil } + +// ErrMigrationsPending reports that the database schema is older than the +// schema this binary expects. Mosaic fails readiness rather than serving +// against an incompatible schema, and never migrates implicitly. +var ErrMigrationsPending = errors.New("database schema is behind the expected migration version") + +// ErrSchemaAhead reports a database migrated past what this binary understands, +// which happens when a rollback was not accompanied by a restore. +var ErrSchemaAhead = errors.New("database schema is ahead of the expected migration version") + +type RowQuerier interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +// AppliedMigrationVersion reads the highest applied Goose version. It returns +// zero when the Goose bookkeeping table does not exist yet. +func AppliedMigrationVersion(ctx context.Context, q RowQuerier) (int64, error) { + var exists bool + if err := q.QueryRow(ctx, `SELECT to_regclass('public.goose_db_version') IS NOT NULL`).Scan(&exists); err != nil { + return 0, fmt.Errorf("inspect migration bookkeeping: %w", err) + } + if !exists { + return 0, nil + } + var version *int64 + if err := q.QueryRow(ctx, `SELECT max(version_id) FROM goose_db_version WHERE is_applied`).Scan(&version); err != nil { + return 0, fmt.Errorf("read applied migration version: %w", err) + } + if version == nil { + return 0, nil + } + return *version, nil +} + +// MigrationCompatibility verifies the applied schema matches the migrations +// embedded in this binary. +func MigrationCompatibility(ctx context.Context, q RowQuerier) error { + expected, err := migrations.ExpectedVersion() + if err != nil { + return err + } + applied, err := AppliedMigrationVersion(ctx, q) + if err != nil { + return err + } + switch { + case applied < expected: + return fmt.Errorf("%w: applied %d, expected %d", ErrMigrationsPending, applied, expected) + case applied > expected: + return fmt.Errorf("%w: applied %d, expected %d", ErrSchemaAhead, applied, expected) + } + return nil +} diff --git a/apps/api/internal/platform/database/metrics.go b/apps/api/internal/platform/database/metrics.go new file mode 100644 index 00000000..83815f19 --- /dev/null +++ b/apps/api/internal/platform/database/metrics.go @@ -0,0 +1,80 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +// Stats is the subset of pgxpool.Stat Mosaic publishes. The interface keeps the +// observer testable without a live pool. +type Stats interface{ Stat() *pgxpool.Stat } + +// RegisterPoolMetrics publishes connection-pool gauges so operators can see +// saturation before it turns into request latency. EmptyAcquireCount rising is +// the documented pool-exhaustion alert signal. +func RegisterPoolMetrics(pool Stats) error { + if pool == nil { + return nil + } + meter := otel.Meter("mosaic/database") + + acquired, err := meter.Int64ObservableGauge("mosaic.db.pool.acquired_connections", + metric.WithDescription("Connections currently checked out of the pool.")) + if err != nil { + return fmt.Errorf("register pool gauge: %w", err) + } + idle, err := meter.Int64ObservableGauge("mosaic.db.pool.idle_connections", + metric.WithDescription("Connections currently idle in the pool.")) + if err != nil { + return fmt.Errorf("register pool gauge: %w", err) + } + total, err := meter.Int64ObservableGauge("mosaic.db.pool.total_connections", + metric.WithDescription("Connections currently owned by the pool.")) + if err != nil { + return fmt.Errorf("register pool gauge: %w", err) + } + maximum, err := meter.Int64ObservableGauge("mosaic.db.pool.max_connections", + metric.WithDescription("Configured maximum pool size.")) + if err != nil { + return fmt.Errorf("register pool gauge: %w", err) + } + emptyAcquires, err := meter.Int64ObservableCounter("mosaic.db.pool.empty_acquire_count", + metric.WithDescription("Acquires that had to wait because the pool was empty.")) + if err != nil { + return fmt.Errorf("register pool counter: %w", err) + } + canceledAcquires, err := meter.Int64ObservableCounter("mosaic.db.pool.canceled_acquire_count", + metric.WithDescription("Acquires cancelled before a connection became available.")) + if err != nil { + return fmt.Errorf("register pool counter: %w", err) + } + acquireDuration, err := meter.Float64ObservableCounter("mosaic.db.pool.acquire_duration_seconds", + metric.WithDescription("Cumulative time spent waiting to acquire a connection."), + metric.WithUnit("s")) + if err != nil { + return fmt.Errorf("register pool counter: %w", err) + } + + _, err = meter.RegisterCallback(func(_ context.Context, observer metric.Observer) error { + stat := pool.Stat() + if stat == nil { + return nil + } + observer.ObserveInt64(acquired, int64(stat.AcquiredConns())) + observer.ObserveInt64(idle, int64(stat.IdleConns())) + observer.ObserveInt64(total, int64(stat.TotalConns())) + observer.ObserveInt64(maximum, int64(stat.MaxConns())) + observer.ObserveInt64(emptyAcquires, stat.EmptyAcquireCount()) + observer.ObserveInt64(canceledAcquires, stat.CanceledAcquireCount()) + observer.ObserveFloat64(acquireDuration, stat.AcquireDuration().Seconds()) + return nil + }, acquired, idle, total, maximum, emptyAcquires, canceledAcquires, acquireDuration) + if err != nil { + return fmt.Errorf("register pool metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/database/migration_integration_test.go b/apps/api/internal/platform/database/migration_integration_test.go new file mode 100644 index 00000000..8a652027 --- /dev/null +++ b/apps/api/internal/platform/database/migration_integration_test.go @@ -0,0 +1,160 @@ +package database_test + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +func openMigrationTestDB(t *testing.T) (*sql.DB, context.Context) { + t.Helper() + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + config, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatalf("parse DATABASE_TEST_URL: %v", err) + } + db := stdlib.OpenDB(*config) + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if err := db.PingContext(ctx); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + goose.SetBaseFS(migrations.Files) + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + // Start from an empty schema. Seeded Configuration Releases cannot be + // deleted (an immutability trigger correctly refuses), so a refused rollback + // in a previous run would otherwise block every later run. + resetSchema(t, ctx, db) + return db, ctx +} + +func resetSchema(t *testing.T, ctx context.Context, db *sql.DB) { + t.Helper() + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must point at a throwaway database): %v", err) + } +} + +// Down migrations are not a rollback strategy: migrations 00006, 00010, and +// 00018 would destroy commerce configuration, rewrite immutable Configuration +// Releases, or delete Experiment attribution. A silent, apparently-successful +// rollback is worse than a refusal because the operator believes they recovered. +// This asserts 00018 refuses cleanly when affected data exists, and names the +// restore path. +func TestMigration00018DownRefusesWhenAffectedDataExists(t *testing.T) { + db, ctx := openMigrationTestDB(t) + + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("apply all migrations: %v", err) + } + + // A Delivery v3 Release is only representable from 00018 onward. The Phase 6 + // schema cannot describe it, so rolling back would have to destroy it. + if _, err := db.ExecContext(ctx, ` + INSERT INTO organizations(id,name,created_at,updated_at) + VALUES('rollback_org','Rollback',now(),now()); + INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES('rollback_project','rollback_org','rollback','Rollback','active',now(),now()); + INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES('rollback_environment','rollback_project','development','Development','development',now(),now()); + INSERT INTO configuration_releases( + id,project_id,environment_id,release_number,delivery_contract_version, + payload,payload_bytes,content_hash,published_by_actor_id,published_at + ) VALUES( + 'rollback_release','rollback_project','rollback_environment',1,'3', + '{}'::jsonb,'\x7b7d'::bytea,repeat('a',64),'rollback_actor',now() + ); + `); err != nil { + t.Fatalf("seed a Delivery v3 Release: %v", err) + } + + err := goose.DownToContext(ctx, db, ".", 17) + if err == nil { + t.Fatal("rolling back 00018 succeeded while a Delivery v3 Release existed; " + + "the rollback silently destroyed data it cannot represent") + } + message := err.Error() + if !strings.Contains(message, "cannot be rolled back") { + t.Fatalf("refusal did not identify itself as a refusal: %v", err) + } + if !strings.Contains(message, "backup-restore") { + t.Fatalf("refusal did not name the restore-from-backup path: %v", err) + } + + // Migrations above 00018 roll back cleanly; 00018 itself must remain applied + // so the data it represents is still readable. + version, err := goose.GetDBVersionContext(ctx, db) + if err != nil { + t.Fatal(err) + } + if version < 18 { + t.Fatalf("schema version after refused rollback = %d, want 00018 still applied", version) + } + var releases int + if err := db.QueryRowContext(ctx, + `SELECT count(*) FROM configuration_releases WHERE delivery_contract_version='3'`).Scan(&releases); err != nil { + t.Fatalf("the refused rollback left the schema unusable: %v", err) + } + if releases != 1 { + t.Fatalf("Delivery v3 Releases after refused rollback = %d, want the seeded row intact", releases) + } + + // Leave the database at the expected version for any later suite. + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("reapply migrations after the refused rollback: %v", err) + } +} + +// The full down/up cycle on an empty database is the only thing that proves every +// down migration is syntactically valid and that the refusal guards' own queries +// reference real columns. A guard that raised a SQL error instead of a clean +// refusal would be indistinguishable from a broken migration to an operator. +func TestMigrationDownUpCycleOnAnEmptyDatabase(t *testing.T) { + db, ctx := openMigrationTestDB(t) + + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("apply all migrations: %v", err) + } + if err := goose.DownToContext(ctx, db, ".", 0); err != nil { + t.Fatalf("roll every migration back on an empty database: %v", err) + } + version, err := goose.GetDBVersionContext(ctx, db) + if err != nil { + t.Fatal(err) + } + if version != 0 { + t.Fatalf("version after full rollback = %d, want 0", version) + } + + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("reapply every migration: %v", err) + } + version, err = goose.GetDBVersionContext(ctx, db) + if err != nil { + t.Fatal(err) + } + expected, err := migrations.ExpectedVersion() + if err != nil { + t.Fatal(err) + } + if version != expected { + t.Fatalf("version after reapply = %d, want %d", version, expected) + } +} diff --git a/apps/api/internal/platform/experimentpostgres/delivery_v1_projection_test.go b/apps/api/internal/platform/experimentpostgres/delivery_v1_projection_test.go new file mode 100644 index 00000000..5eb6e2d7 --- /dev/null +++ b/apps/api/internal/platform/experimentpostgres/delivery_v1_projection_test.go @@ -0,0 +1,93 @@ +package experimentpostgres + +import ( + "encoding/json" + "testing" +) + +// Negotiation promises a client the highest representation it can read. When an +// Experiment published, the resulting Release carried only v2 and v3 +// representations, so a v1-only SDK was answered 406 and could not fetch +// configuration at all until it upgraded -- the opposite of the compatibility +// guarantee. This pins the v1 projection: it keeps the Placement-to-Paywall +// bindings a legacy client renders, and drops the vocabulary v1 does not define. +func TestDeliveryV1ProjectionKeepsLegacyClientsServed(t *testing.T) { + release := map[string]any{ + "id": "release_000009", "number": float64(9), + "environment": map[string]any{"id": "env_000001", "key": "development", "mode": "development"}, + "publishedAt": "2026-07-27T17:52:18.000Z", + "contentDigest": "sha256:stale-value-that-must-be-recomputed", + "compatibility": map[string]any{ + "paywallProtocols": []any{map[string]any{"version": "0.2"}}, + "acceptance": "atomic", + "placementDecisionContracts": []any{map[string]any{"version": "1"}}, + "experimentAssignmentContracts": []any{map[string]any{"version": "1"}}, + }, + "placements": []any{map[string]any{"key": "drill_onboarding", "paywallVersionId": "version_000003"}}, + "paywallVersions": []any{map[string]any{"id": "version_000003"}}, + "productReferences": []any{}, + "assetReferences": []any{}, + "placementDecisions": []any{map[string]any{"ruleSet": "…"}}, + "experimentAssignments": []any{map[string]any{"experimentId": "experiment_000003"}}, + "entitlementReferences": []any{map[string]any{"key": "pro"}}, + } + + projected, err := deliveryV1Projection(release) + if err != nil { + t.Fatalf("project v1: %v", err) + } + + // What a v1 client needs to render must survive. + for _, key := range []string{"id", "number", "environment", "publishedAt", + "compatibility", "placements", "paywallVersions", "productReferences", "assetReferences"} { + if _, ok := projected[key]; !ok { + t.Errorf("v1 projection dropped %q, which a v1 client requires", key) + } + } + // Vocabulary v1 does not define must not leak into the legacy view. + for _, key := range []string{"placementDecisions", "experimentAssignments", "entitlementReferences"} { + if _, ok := projected[key]; ok { + t.Errorf("v1 projection leaked the non-v1 member %q", key) + } + } + compatibility := projected["compatibility"].(map[string]any) + for _, key := range []string{"placementDecisionContracts", "experimentAssignmentContracts"} { + if _, ok := compatibility[key]; ok { + t.Errorf("v1 compatibility leaked %q", key) + } + } + if compatibility["acceptance"] != "atomic" { + t.Errorf("v1 acceptance = %v, want atomic", compatibility["acceptance"]) + } + if _, ok := compatibility["paywallProtocols"]; !ok { + t.Error("v1 compatibility lost its Paywall protocols") + } + // The environment member is narrower in v1. + if _, ok := projected["environment"].(map[string]any)["mode"]; ok { + t.Error("v1 environment leaked the v2-only mode member") + } + // The digest must describe the projection, not the Release it came from. + digest, _ := projected["contentDigest"].(string) + if digest == "" || digest == "sha256:stale-value-that-must-be-recomputed" { + t.Fatalf("contentDigest = %q, want a digest recomputed over the projection", digest) + } + + // The Placement bindings a v1 client resolves against are the whole point + // of the legacy view; a projection that drops them is served but useless. + // (The publisher prefers carrying the previous Release's v1 forward, which + // keeps them verbatim; this asserts the fallback path keeps whatever the + // source envelope had.) + if placements, ok := projected["placements"].([]any); !ok || len(placements) == 0 { + t.Fatalf("v1 projection lost the Placement bindings: %#v", projected["placements"]) + } + + // And the projection must be a valid v1 payload by the same check the + // publisher applies before storing it. + payload, err := json.Marshal(map[string]any{"configurationDeliveryVersion": "1", "release": projected}) + if err != nil { + t.Fatal(err) + } + if err := validateDeliveryPayload(payload, "1"); err != nil { + t.Fatalf("the stored v1 projection would be rejected: %v", err) + } +} diff --git a/apps/api/internal/platform/experimentpostgres/queue_metrics.go b/apps/api/internal/platform/experimentpostgres/queue_metrics.go new file mode 100644 index 00000000..3cf8e34f --- /dev/null +++ b/apps/api/internal/platform/experimentpostgres/queue_metrics.go @@ -0,0 +1,67 @@ +package experimentpostgres + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const queueMetricTimeout = 5 * time.Second + +// RegisterQueueMetrics publishes backlog depth, oldest-job age, and the +// dead-letter count for Experiment scheduling. A scheduled start or completion +// that never runs silently invalidates an Experiment, so the backlog needs to +// be visible without inspecting the database. +func (r *Repository) RegisterQueueMetrics() error { + meter := otel.Meter("mosaic/experiment") + 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 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 queue age gauge: %w", err) + } + deadLettered, err := meter.Int64ObservableGauge("mosaic.worker.queue.dead_lettered", + metric.WithDescription("Jobs that exhausted their retry budget and stopped retrying.")) + if err != nil { + return fmt.Errorf("register dead-letter gauge: %w", err) + } + attributes := metric.WithAttributes( + attribute.String("family", "experiment"), + attribute.String("queue", "schedule"), + ) + _, err = meter.RegisterCallback(func(ctx context.Context, observer metric.Observer) error { + ctx, cancel := context.WithTimeout(ctx, queueMetricTimeout) + defer cancel() + var pending, failed int64 + var age *float64 + err := r.pool.QueryRow(ctx, `SELECT + count(*) FILTER (WHERE status IN ('queued','leased')), + count(*) FILTER (WHERE status='failed'), + max(extract(epoch from (now()-scheduled_at))) FILTER (WHERE status IN ('queued','leased')) + FROM experiment_scheduling_jobs`).Scan(&pending, &failed, &age) + if err != nil { + return nil + } + observer.ObserveInt64(depth, pending, attributes) + observer.ObserveInt64(deadLettered, failed, attributes) + seconds := 0.0 + if age != nil { + seconds = *age + } + observer.ObserveFloat64(oldest, seconds, attributes) + return nil + }, depth, oldest, deadLettered) + if err != nil { + return fmt.Errorf("register queue metric callback: %w", err) + } + return nil +} diff --git a/apps/api/internal/platform/experimentpostgres/repository.go b/apps/api/internal/platform/experimentpostgres/repository.go index b443d1b0..6cc3f20a 100644 --- a/apps/api/internal/platform/experimentpostgres/repository.go +++ b/apps/api/internal/platform/experimentpostgres/repository.go @@ -343,7 +343,7 @@ func (r *Repository) Publish(ctx context.Context, scope experiment.Scope, input var ok bool e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM paywall_versions v JOIN paywalls p ON p.id=v.paywall_id WHERE v.id=$1 AND v.paywall_id=$2 AND v.project_id=$3 AND v.environment_id=$4 AND p.status='active')`, variant.PaywallVersionID, variant.PaywallID, scope.ProjectID, scope.EnvironmentID).Scan(&ok) if e != nil || !ok { - return experiment.PublishOutput{}, fmt.Errorf("paywall version is not publishable: %w", experiment.ErrInvalid) + return experiment.PublishOutput{}, experiment.Invalid("variant_paywall_version_not_publishable") } var unsafe int e = tx.QueryRow(ctx, `SELECT count(*) FROM paywall_version_products pvp @@ -368,7 +368,7 @@ func (r *Repository) Publish(ctx context.Context, scope experiment.Scope, input ) )`, variant.PaywallVersionID, scope.EnvironmentID).Scan(&unsafe) if e != nil || unsafe > 0 { - return experiment.PublishOutput{}, fmt.Errorf("paywall version has unsafe products: %w", experiment.ErrInvalid) + return experiment.PublishOutput{}, experiment.Invalid("variant_paywall_products_not_ready") } } var overlap int @@ -391,7 +391,7 @@ func (r *Repository) Publish(ctx context.Context, scope experiment.Scope, input return experiment.PublishOutput{}, persistence(e) } if !groupValid { - return experiment.PublishOutput{}, fmt.Errorf("mutual exclusion group is invalid: %w", experiment.ErrInvalid) + return experiment.PublishOutput{}, experiment.Invalid("mutual_exclusion_group_invalid") } var outsideGroup int e = tx.QueryRow(ctx, `SELECT count(*) FROM experiments other @@ -453,7 +453,7 @@ func (r *Repository) Publish(ctx context.Context, scope experiment.Scope, input var snapshot []byte e = tx.QueryRow(ctx, `SELECT jsonb_build_object('id',id,'version',version,'name',name,'numeratorEvent',numerator_event,'denominatorEvent',denominator_event,'assignmentUnit',assignment_unit,'authority',authority,'availability',availability,'eventFilter',event_filter,'attributionWindowSeconds',attribution_window_seconds,'freshnessSeconds',freshness_seconds,'definition',definition) FROM experiment_metric_definitions WHERE id=$1 AND version=$2 AND availability='available' AND (($3='primary' AND primary_eligible) OR ($3='guardrail' AND guardrail_eligible))`, id, mv, kind).Scan(&snapshot) if e != nil { - return experiment.PublishOutput{}, fmt.Errorf("metric snapshot is unavailable: %w", experiment.ErrInvalid) + return experiment.PublishOutput{}, experiment.Invalid("metric_definition_unavailable") } _, e = tx.Exec(ctx, `INSERT INTO experiment_metric_snapshots(experiment_version_id,project_id,metric_id,metric_version,kind,snapshot) VALUES($1,$2,$3,$4,$5,$6)`, versionID, scope.ProjectID, id, mv, kind, snapshot) if e != nil { @@ -480,7 +480,7 @@ func (r *Repository) Publish(ctx context.Context, scope experiment.Scope, input if jobErr != nil { return experiment.PublishOutput{}, persistence(jobErr) } - _, e = tx.Exec(ctx, `INSERT INTO experiment_scheduling_jobs(id,experiment_id,project_id,environment_id,action,scheduled_at,status,actor_id,created_at,updated_at) VALUES($1,$2,$3,$4,'start',$5,'queued',$6,$7,$7)`, jobID, input.Experiment.ID, scope.ProjectID, scope.EnvironmentID, input.Document.Schedule.StartsAt, input.ActorID, input.Now) + _, e = tx.Exec(ctx, scheduleJobStartInsert, jobID, input.Experiment.ID, scope.ProjectID, scope.EnvironmentID, input.Document.Schedule.StartsAt, input.ActorID, input.Now) if e != nil { return experiment.PublishOutput{}, persistence(e) } @@ -490,7 +490,7 @@ func (r *Repository) Publish(ctx context.Context, scope experiment.Scope, input if jobErr != nil { return experiment.PublishOutput{}, persistence(jobErr) } - _, e = tx.Exec(ctx, `INSERT INTO experiment_scheduling_jobs(id,experiment_id,project_id,environment_id,action,scheduled_at,status,actor_id,created_at,updated_at) VALUES($1,$2,$3,$4,'complete',$5,'queued',$6,$7,$7)`, jobID, input.Experiment.ID, scope.ProjectID, scope.EnvironmentID, input.Document.Schedule.EndsAt, input.ActorID, input.Now) + _, e = tx.Exec(ctx, scheduleJobCompleteInsert, jobID, input.Experiment.ID, scope.ProjectID, scope.EnvironmentID, input.Document.Schedule.EndsAt, input.ActorID, input.Now) if e != nil { return experiment.PublishOutput{}, persistence(e) } @@ -518,7 +518,7 @@ func (r *Repository) publishRelease(ctx context.Context, tx pgx.Tx, scope experi var number int64 e := tx.QueryRow(ctx, `SELECT current_release_id,last_release_number FROM environment_release_state WHERE environment_id=$1 FOR UPDATE`, scope.EnvironmentID).Scan(&oldID, &number) if e != nil || oldID == "" { - return "", fmt.Errorf("environment has no current release: %w", experiment.ErrInvalid) + return "", experiment.Invalid("environment_has_no_current_release") } var base []byte e = tx.QueryRow(ctx, `SELECT COALESCE((SELECT payload_bytes FROM configuration_release_representations WHERE release_id=$1 AND delivery_contract_version='2'),(SELECT payload_bytes FROM configuration_releases WHERE id=$1))`, oldID).Scan(&base) @@ -527,11 +527,11 @@ func (r *Repository) publishRelease(ctx context.Context, tx pgx.Tx, scope experi } var envelope map[string]any if e = json.Unmarshal(base, &envelope); e != nil { - return "", fmt.Errorf("current release bytes are invalid: %w", experiment.ErrInvalid) + return "", experiment.Invalid("current_release_payload_unreadable") } release, ok := envelope["release"].(map[string]any) if !ok { - return "", fmt.Errorf("current release envelope is missing release metadata: %w", experiment.ErrInvalid) + return "", experiment.Invalid("current_release_metadata_missing") } releaseID, e := nextID(ctx, tx, "release") if e != nil { @@ -545,6 +545,14 @@ func (r *Repository) publishRelease(ctx context.Context, tx pgx.Tx, scope experi if e != nil { return "", fmt.Errorf("marshal delivery v2: %w", e) } + // The base above falls back to the Release's v1 payload when the + // Environment has no v2 representation, which only happens when no + // Placement rule set has been published there. Publishing an Experiment + // then cannot produce a v3 Release. Say so: the generic + // `emitted_delivery_invalid` sent the caller looking at the Experiment. + if version, _ := envelope["configurationDeliveryVersion"].(string); version != "2" { + return "", experiment.ErrPlacementDecisionRequired + } if e = validateDeliveryPayload(v2, "2"); e != nil { return "", e } @@ -652,24 +660,69 @@ func (r *Repository) publishRelease(ctx context.Context, tx pgx.Tx, scope experi if e != nil { return "", persistence(e) } - _, e = tx.Exec(ctx, `INSERT INTO configuration_release_placements SELECT $1,project_id,environment_id,placement_id,placement_key,paywall_version_id FROM configuration_release_placements WHERE release_id=$2; INSERT INTO configuration_release_products SELECT $1,environment_id,project_id,product_id FROM configuration_release_products WHERE release_id=$2; INSERT INTO configuration_release_assets SELECT $1,environment_id,project_id,asset_id FROM configuration_release_assets WHERE release_id=$2; INSERT INTO configuration_release_rule_set_versions SELECT $1,environment_id,project_id,rule_set_version_id,placement_id FROM configuration_release_rule_set_versions WHERE release_id=$2`, releaseID, oldID) - if e != nil { + // Legacy projection: a v1-only SDK must still receive the highest + // representation it can read. + // + // The preceding Release's v1 representation is the authoritative v1 view: + // publishing an Experiment does not change the Environment's + // Placement-to-Paywall bindings, and `placements` exists only in v1 (v2 + // replaces it with placementDecisions), so projecting from the v2 envelope + // alone produces a v1 payload with no Placements at all -- served, but + // useless to the client it exists for. Carry the previous v1 forward and + // restamp its identity; fall back to the projection only when there is no + // previous v1 to carry. + var previousV1 []byte + if e = tx.QueryRow(ctx, `SELECT payload_bytes FROM configuration_release_representations WHERE release_id=$1 AND delivery_contract_version='1'`, oldID).Scan(&previousV1); e != nil && !errors.Is(e, pgx.ErrNoRows) { return "", persistence(e) } - _, e = tx.Exec(ctx, ` - INSERT INTO configuration_release_products(release_id,environment_id,project_id,product_id) - SELECT DISTINCT $1,$2,$3,pvp.product_id FROM experiment_versions ev - JOIN experiment_variants variant ON variant.experiment_version_id=ev.id - JOIN paywall_version_products pvp ON pvp.version_id=variant.paywall_version_id - WHERE ev.id=ANY($4::text[]) ON CONFLICT DO NOTHING; - INSERT INTO configuration_release_assets(release_id,environment_id,project_id,asset_id) - SELECT DISTINCT $1,$2,$3,pva.asset_id FROM experiment_versions ev - JOIN experiment_variants variant ON variant.experiment_version_id=ev.id - JOIN paywall_version_assets pva ON pva.version_id=variant.paywall_version_id - WHERE ev.id=ANY($4::text[]) ON CONFLICT DO NOTHING`, releaseID, scope.EnvironmentID, scope.ProjectID, activeVersions) + var v1Release map[string]any + if len(previousV1) > 0 { + var previousEnvelope map[string]any + if e = json.Unmarshal(previousV1, &previousEnvelope); e != nil { + return "", experiment.Invalid("previous_v1_representation_unreadable") + } + v1Release, _ = previousEnvelope["release"].(map[string]any) + } + if v1Release == nil { + v1Release, e = deliveryV1Projection(release) + if e != nil { + return "", e + } + } else { + v1Release["id"] = releaseID + v1Release["number"] = number + 1 + v1Release["publishedAt"] = now.Format("2006-01-02T15:04:05.000Z") + if e = setReleaseContentDigest(v1Release); e != nil { + return "", e + } + } + v1, e := json.Marshal(map[string]any{"configurationDeliveryVersion": "1", "release": v1Release}) + if e != nil { + return "", fmt.Errorf("marshal delivery v1: %w", e) + } + if e = validateDeliveryPayload(v1, "1"); e != nil { + return "", e + } + sum1 := sha256.Sum256(v1) + _, e = tx.Exec(ctx, `INSERT INTO configuration_release_representations(release_id,environment_id,delivery_contract_version,payload,payload_bytes,content_hash,created_at) VALUES($1,$2,'1',$3::jsonb,$4::bytea,$5,$6)`, releaseID, scope.EnvironmentID, string(v1), v1, hex.EncodeToString(sum1[:]), now) if e != nil { return "", persistence(e) } + // pgx uses the extended protocol, which accepts exactly one statement per + // parameterized Exec. These four carries were previously one semicolon-joined + // string, so every Experiment publish failed with SQLSTATE 42601 ("cannot + // insert multiple commands into a prepared statement") and no Experiment could + // ever reach a published Version. + for _, statement := range carryForwardReleaseMaterialStatements { + if _, e = tx.Exec(ctx, statement, releaseID, oldID); e != nil { + return "", persistence(e) + } + } + for _, statement := range experimentVariantReleaseMaterialStatements { + if _, e = tx.Exec(ctx, statement, releaseID, scope.EnvironmentID, scope.ProjectID, activeVersions); e != nil { + return "", persistence(e) + } + } for _, versionID := range activeVersions { if _, e = tx.Exec(ctx, `INSERT INTO configuration_release_experiment_versions(release_id,experiment_version_id,project_id,environment_id) VALUES($1,$2,$3,$4)`, releaseID, versionID, scope.ProjectID, scope.EnvironmentID); e != nil { return "", persistence(e) @@ -679,6 +732,56 @@ func (r *Repository) publishRelease(ctx context.Context, tx pgx.Tx, scope experi return releaseID, persistence(e) } +// deliveryV1Keys is the exact member set of a Delivery v1 Release. The v1 +// projection is built by whitelisting these rather than deleting the v2/v3 +// members, so a future contract addition cannot leak into the legacy view. +var deliveryV1Keys = []string{ + "id", "number", "environment", "publishedAt", + "compatibility", "placements", "paywallVersions", "productReferences", "assetReferences", +} + +// deliveryV1Projection renders the Delivery v1 view of a v2/v3 Release. +// +// Negotiation selects the highest representation a client can read, and a +// v1-only SDK can read v1. Before this, publishing an Experiment produced a +// Release carrying only v2 and v3 representations, so every v1-only SDK was +// answered 406 and could not fetch configuration at all until it upgraded -- +// the opposite of the stated compatibility guarantee. The v1 projection simply +// contains no Placement decisions and no Experiments: a legacy client keeps +// receiving the Environment's Placement-to-Paywall bindings and renders them. +func deliveryV1Projection(release map[string]any) (map[string]any, error) { + projected := make(map[string]any, len(deliveryV1Keys)) + for _, key := range deliveryV1Keys { + if value, ok := release[key]; ok { + projected[key] = value + } + } + // v1 compatibility declares Paywall protocols and atomic acceptance only; + // the Placement-decision and Experiment contracts are not v1 vocabulary. + compatibility, _ := release["compatibility"].(map[string]any) + v1Compatibility := map[string]any{"acceptance": "atomic"} + if compatibility != nil { + if protocols, ok := compatibility["paywallProtocols"]; ok { + v1Compatibility["paywallProtocols"] = protocols + } + } + projected["compatibility"] = v1Compatibility + // The environment member is narrower in v1: identity only. + if environment, ok := release["environment"].(map[string]any); ok { + v1Environment := map[string]any{} + for _, key := range []string{"id", "key"} { + if value, present := environment[key]; present { + v1Environment[key] = value + } + } + projected["environment"] = v1Environment + } + if err := setReleaseContentDigest(projected); err != nil { + return nil, err + } + return projected, nil +} + func setReleaseContentDigest(release map[string]any) error { delete(release, "contentDigest") canonical, err := json.Marshal(release) @@ -701,6 +804,77 @@ func objectArray(value any) []map[string]any { return out } +// carryForwardReleaseMaterialStatements copy the previous Release's material onto +// the new Experiment-bearing Release. Each is a separate statement because pgx +// speaks the extended protocol; a semicolon-joined parameterized statement is +// rejected outright. +var carryForwardReleaseMaterialStatements = []string{ + `INSERT INTO configuration_release_placements SELECT $1,project_id,environment_id,placement_id,placement_key,paywall_version_id FROM configuration_release_placements WHERE release_id=$2`, + `INSERT INTO configuration_release_products SELECT $1,environment_id,project_id,product_id FROM configuration_release_products WHERE release_id=$2`, + `INSERT INTO configuration_release_assets SELECT $1,environment_id,project_id,asset_id FROM configuration_release_assets WHERE release_id=$2`, + `INSERT INTO configuration_release_rule_set_versions SELECT $1,environment_id,project_id,rule_set_version_id,placement_id FROM configuration_release_rule_set_versions WHERE release_id=$2`, +} + +// experimentVariantReleaseMaterialStatements add the Products and Assets that only +// the Experiment Variants' Paywall Versions reference, so the Release is closed +// over everything an SDK must resolve. +var experimentVariantReleaseMaterialStatements = []string{ + `INSERT INTO configuration_release_products(release_id,environment_id,project_id,product_id) + SELECT DISTINCT $1,$2,$3,pvp.product_id FROM experiment_versions ev + JOIN experiment_variants variant ON variant.experiment_version_id=ev.id + JOIN paywall_version_products pvp ON pvp.version_id=variant.paywall_version_id + WHERE ev.id=ANY($4::text[]) ON CONFLICT DO NOTHING`, + `INSERT INTO configuration_release_assets(release_id,environment_id,project_id,asset_id) + SELECT DISTINCT $1,$2,$3,pva.asset_id FROM experiment_versions ev + JOIN experiment_variants variant ON variant.experiment_version_id=ev.id + JOIN paywall_version_assets pva ON pva.version_id=variant.paywall_version_id + WHERE ev.id=ANY($4::text[]) ON CONFLICT DO NOTHING`, +} + +// The Experiment publish release-closure path reads Paywall Version material +// straight out of the hosted-publishing tables. None of it had a test, and one +// statement selected `a.url` from a column actually named `public_url`, so every +// Experiment publish failed with a 500 the moment the closure needed to load a +// Variant's Paywall Version. The statements are named constants so an +// integration test can prepare each one against the migrated schema. +const ( + closurePaywallVersionQuery = `SELECT paywall_id,protocol_version,document,document_hash FROM paywall_versions WHERE id=$1 AND project_id=$2 AND environment_id=$3` + + closurePaywallProductsQuery = `SELECT p.id,p.type,p.internal_name,p.readiness_ready FROM paywall_version_products pvp JOIN products p ON p.id=pvp.product_id AND p.project_id=pvp.project_id WHERE pvp.version_id=$1 ORDER BY p.id` + + closurePaywallAssetsQuery = `SELECT pva.document_asset_id,a.id,a.kind,a.media_type,a.byte_length,a.content_digest,a.public_url FROM paywall_version_assets pva JOIN assets a ON a.id=pva.asset_id AND a.project_id=pva.project_id WHERE pva.version_id=$1 ORDER BY pva.document_asset_id` + + closureEntitlementGrantsQuery = `SELECT DISTINCT e.id,e.key FROM product_entitlement_grants peg JOIN entitlements e ON e.id=peg.entitlement_id AND e.project_id=peg.project_id WHERE peg.product_id=ANY($1::text[]) ORDER BY e.id` +) + +// Migration 00020 made experiment_scheduling_jobs.available_at NOT NULL so an +// expired lease can be reclaimed and a transient failure requeued with backoff. +// The writer was never updated to set it, so every Experiment publish carrying a +// schedule failed with a not-null violation and no Experiment could be +// scheduled. available_at starts equal to scheduled_at: the job is eligible the +// moment it is due, and each retry pushes it forward. +const ( + scheduleJobStartInsert = `INSERT INTO experiment_scheduling_jobs(id,experiment_id,project_id,environment_id,action,scheduled_at,available_at,status,actor_id,created_at,updated_at) VALUES($1,$2,$3,$4,'start',$5,$5,'queued',$6,$7,$7)` + scheduleJobCompleteInsert = `INSERT INTO experiment_scheduling_jobs(id,experiment_id,project_id,environment_id,action,scheduled_at,available_at,status,actor_id,created_at,updated_at) VALUES($1,$2,$3,$4,'complete',$5,$5,'queued',$6,$7,$7)` +) + +// ScheduleJobInsertStatements exposes the scheduling-job writes so an +// integration test can execute them against the real schema. +var ScheduleJobInsertStatements = []string{scheduleJobStartInsert, scheduleJobCompleteInsert} + +// ReleaseClosureStatements lists every statement above so +// TestReleaseClosureStatementsMatchTheSchema can prepare them all. +var ReleaseClosureStatements = func() []string { + statements := []string{ + closurePaywallVersionQuery, + closurePaywallProductsQuery, + closurePaywallAssetsQuery, + closureEntitlementGrantsQuery, + } + statements = append(statements, carryForwardReleaseMaterialStatements...) + return append(statements, experimentVariantReleaseMaterialStatements...) +}() + func (r *Repository) ensureExperimentReleaseClosure(ctx context.Context, tx pgx.Tx, scope experiment.Scope, release map[string]any, assignments []any) error { paywalls := objectArray(release["paywallVersions"]) products := objectArray(release["productReferences"]) @@ -742,11 +916,11 @@ func (r *Repository) ensureExperimentReleaseClosure(ctx context.Context, tx pgx. } var paywallID, protocolVersion, documentHash string var document []byte - if err := tx.QueryRow(ctx, `SELECT paywall_id,protocol_version,document,document_hash FROM paywall_versions WHERE id=$1 AND project_id=$2 AND environment_id=$3`, id, scope.ProjectID, scope.EnvironmentID).Scan(&paywallID, &protocolVersion, &document, &documentHash); err != nil { + if err := tx.QueryRow(ctx, closurePaywallVersionQuery, id, scope.ProjectID, scope.EnvironmentID).Scan(&paywallID, &protocolVersion, &document, &documentHash); err != nil { return persistence(err) } productIDs := []string{} - rows, err := tx.Query(ctx, `SELECT p.id,p.type,p.internal_name,p.readiness_ready FROM paywall_version_products pvp JOIN products p ON p.id=pvp.product_id AND p.project_id=pvp.project_id WHERE pvp.version_id=$1 ORDER BY p.id`, id) + rows, err := tx.Query(ctx, closurePaywallProductsQuery, id) if err != nil { return persistence(err) } @@ -769,7 +943,7 @@ func (r *Repository) ensureExperimentReleaseClosure(ctx context.Context, tx pgx. } rows.Close() bindings := []any{} - rows, err = tx.Query(ctx, `SELECT pva.document_asset_id,a.id,a.kind,a.media_type,a.byte_length,a.content_digest,a.url FROM paywall_version_assets pva JOIN assets a ON a.id=pva.asset_id AND a.project_id=pva.project_id WHERE pva.version_id=$1 ORDER BY pva.document_asset_id`, id) + rows, err = tx.Query(ctx, closurePaywallAssetsQuery, id) if err != nil { return persistence(err) } @@ -794,7 +968,7 @@ func (r *Repository) ensureExperimentReleaseClosure(ctx context.Context, tx pgx. paywalls = append(paywalls, map[string]any{"id": id, "paywallId": paywallID, "protocolVersion": protocolVersion, "documentDigest": "sha256:" + documentHash, "document": decoded, "productReferenceIds": productIDs, "assetBindings": bindings}) paywallSet[id] = true } - rows, err := tx.Query(ctx, `SELECT DISTINCT e.id,e.key FROM product_entitlement_grants peg JOIN entitlements e ON e.id=peg.entitlement_id AND e.project_id=peg.project_id WHERE peg.product_id=ANY($1::text[]) ORDER BY e.id`, mapKeys(productSet)) + rows, err := tx.Query(ctx, closureEntitlementGrantsQuery, mapKeys(productSet)) if err != nil { return persistence(err) } @@ -848,7 +1022,7 @@ func validateExperimentDeliveryPayload(payload []byte) error { } `json:"release"` } if err := json.Unmarshal(payload, &envelope); err != nil || envelope.Version != "3" { - return fmt.Errorf("emitted delivery v3 is invalid: %w", experiment.ErrInvalid) + return experiment.Invalid("emitted_delivery_v3_invalid") } paywalls, products := map[string]bool{}, map[string]bool{} for _, value := range envelope.Release.PaywallVersions { @@ -862,11 +1036,11 @@ func validateExperimentDeliveryPayload(payload []byte) error { for _, assignment := range envelope.Release.Assignments { for _, variant := range assignment.Variants { if !paywalls[variant.PaywallVersionID] { - return fmt.Errorf("experiment paywall closure is incomplete: %w", experiment.ErrInvalid) + return experiment.Invalid("experiment_paywall_closure_incomplete") } for _, id := range variant.Compatibility.ProductIDs { if !products[id] { - return fmt.Errorf("experiment product closure is incomplete: %w", experiment.ErrInvalid) + return experiment.Invalid("experiment_product_closure_incomplete") } } } @@ -877,15 +1051,15 @@ func validateExperimentDeliveryPayload(payload []byte) error { func validateDeliveryPayload(payload []byte, expectedVersion string) error { var root map[string]any if err := json.Unmarshal(payload, &root); err != nil || root["configurationDeliveryVersion"] != expectedVersion { - return fmt.Errorf("emitted delivery v%s is invalid: %w", expectedVersion, experiment.ErrInvalid) + return experiment.Invalid("emitted_delivery_invalid") } release, ok := root["release"].(map[string]any) if !ok { - return fmt.Errorf("emitted delivery v%s has no release: %w", expectedVersion, experiment.ErrInvalid) + return experiment.Invalid("emitted_delivery_has_no_release") } declared, _ := release["contentDigest"].(string) if err := setReleaseContentDigest(release); err != nil || release["contentDigest"] != declared { - return fmt.Errorf("emitted delivery v%s digest is invalid: %w", expectedVersion, experiment.ErrInvalid) + return experiment.Invalid("emitted_delivery_digest_invalid") } return nil } @@ -1352,6 +1526,10 @@ func (r *Repository) RevokeOverride(ctx context.Context, scope experiment.Scope, return persistence(tx.Commit(ctx)) } +// LeaseSchedule claims the next due scheduling job. It also reclaims leases +// whose owner died before finishing, which the original query could not do: an +// expired lease left the row stuck in 'leased' forever and the scheduled +// Experiment start or completion was silently lost. func (r *Repository) LeaseSchedule(ctx context.Context, worker string, now, expires time.Time) (experiment.ScheduleJob, bool, error) { tx, err := r.pool.Begin(ctx) if err != nil { @@ -1359,7 +1537,12 @@ func (r *Repository) LeaseSchedule(ctx context.Context, worker string, now, expi } defer tx.Rollback(ctx) var job experiment.ScheduleJob - err = tx.QueryRow(ctx, `SELECT id,experiment_id,project_id,environment_id,action,actor_id FROM experiment_scheduling_jobs WHERE status='queued' AND scheduled_at<=$1 ORDER BY scheduled_at,id FOR UPDATE SKIP LOCKED LIMIT 1`, now).Scan(&job.ID, &job.ExperimentID, &job.ProjectID, &job.EnvironmentID, &job.Action, &job.ActorID) + err = tx.QueryRow(ctx, `SELECT id,experiment_id,project_id,environment_id,action,actor_id,attempt_count,max_attempts + FROM experiment_scheduling_jobs + WHERE (status='queued' OR (status='leased' AND lease_expires_at<=$1)) + AND scheduled_at<=$1 AND available_at<=$1 AND attempt_count cap { + return cap + } + return delay +} + +// FinishSchedule closes out a leased job. A transient failure is requeued with +// backoff until the retry budget is spent, at which point the job is terminally +// failed with a diagnostic code instead of disappearing. +func (r *Repository) FinishSchedule(ctx context.Context, job experiment.ScheduleJob, success bool, code string, now time.Time) error { if success { - status = "completed" + tag, err := r.pool.Exec(ctx, `UPDATE experiment_scheduling_jobs SET status='completed',lease_owner=NULL,lease_expires_at=NULL,last_error_code=NULL,updated_at=$2 WHERE id=$1 AND status='leased'`, job.ID, now) + if err != nil { + return persistence(err) + } + if tag.RowsAffected() != 1 { + return experiment.ErrConflict + } + return nil + } + if code == "" { + code = "schedule_transition_failed" } - tag, err := r.pool.Exec(ctx, `UPDATE experiment_scheduling_jobs SET status=$2,lease_owner=NULL,lease_expires_at=NULL,updated_at=$3 WHERE id=$1 AND status='leased'`, id, status, now) + tag, err := r.pool.Exec(ctx, `UPDATE experiment_scheduling_jobs + SET status=CASE WHEN attempt_count>=max_attempts THEN 'failed' ELSE 'queued' END, + lease_owner=NULL,lease_expires_at=NULL,last_error_code=$2, + available_at=$3::timestamptz+$4::interval,updated_at=$3 + WHERE id=$1 AND status='leased'`, + job.ID, code, now, scheduleBackoff(job.AttemptCount).String()) if err != nil { return persistence(err) } diff --git a/apps/api/internal/platform/experimentpostgres/repository_integration_test.go b/apps/api/internal/platform/experimentpostgres/repository_integration_test.go new file mode 100644 index 00000000..1b411131 --- /dev/null +++ b/apps/api/internal/platform/experimentpostgres/repository_integration_test.go @@ -0,0 +1,464 @@ +package experimentpostgres_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + + "github.com/Mujhtech/mosaic/apps/api/internal/experiment" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/experimentpostgres" + "github.com/Mujhtech/mosaic/apps/api/migrations" +) + +// seedSQL builds the minimum valid tenant chain an Experiment version needs. +// Every identifier is prefixed so the fixture is obvious in a shared database. +const seedSQL = ` +INSERT INTO organizations(id,name,created_at,updated_at) + VALUES('xp_org','Experiment',now(),now()); +INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES('xp_project','xp_org','experiment','Experiment','active',now(),now()); +INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES('xp_env','xp_project','development','Development','development',now(),now()); +INSERT INTO environments(id,project_id,key,name,mode,created_at,updated_at) + VALUES('xp_other_env','xp_project','staging','Staging','development',now(),now()); +INSERT INTO placements(id,project_id,key,name,status,created_by_actor_id,created_at,updated_at) + VALUES('xp_placement','xp_project','checkout','Checkout','active','xp_actor',now(),now()); + +INSERT INTO paywalls(id,project_id,key,name,status,created_by_actor_id,created_at,updated_at) + VALUES('xp_paywall','xp_project','control','Control','active','xp_actor',now(),now()); +INSERT INTO paywall_drafts( + id,project_id,paywall_id,environment_id,status,current_revision,current_protocol_version, + validation_status,created_by_actor_id,updated_by_actor_id,created_at,updated_at +) VALUES( + 'xp_paywall_draft','xp_project','xp_paywall','xp_env','published',1,'0.2', + 'valid','xp_actor','xp_actor',now(),now() +); +INSERT INTO paywall_draft_revisions( + draft_id,revision,project_id,protocol_version,document,document_hash, + validation_status,mutation_key_hash,request_hash,actor_id,created_at +) VALUES( + 'xp_paywall_draft',1,'xp_project','0.2','{}'::jsonb,repeat('a',64), + 'valid',repeat('b',64),repeat('c',64),'xp_actor',now() +); +INSERT INTO paywall_versions( + id,project_id,paywall_id,environment_id,version_number,source_draft_id,source_revision, + protocol_version,document,document_hash,created_by_actor_id,created_at +) VALUES( + 'xp_paywall_version','xp_project','xp_paywall','xp_env',1,'xp_paywall_draft',1, + '0.2','{}'::jsonb,repeat('a',64),'xp_actor',now() +); + +INSERT INTO experiment_metric_definitions( + id,version,name,numerator_event,denominator_event,assignment_unit,authority,availability, + attribution_window_seconds,freshness_seconds,definition,primary_eligible,guardrail_eligible +) VALUES( + 'xp_metric',1,'Purchase rate','purchase_completed_client','paywall_presented','assignment_key', + 'client_observed','available',86400,3600,'Purchases per exposure',true,true +); + +INSERT INTO experiments( + id,project_id,environment_id,placement_id,name,state, + created_by_actor_id,updated_by_actor_id,created_at,updated_at +) VALUES( + 'xp_experiment','xp_project','xp_env','xp_placement','Checkout test','draft', + 'xp_actor','xp_actor',now(),now() +); +INSERT INTO experiment_drafts( + id,experiment_id,project_id,environment_id,current_revision,status, + created_by_actor_id,updated_by_actor_id,created_at,updated_at +) VALUES( + 'xp_draft','xp_experiment','xp_project','xp_env',1,'published','xp_actor','xp_actor',now(),now() +); +INSERT INTO experiment_draft_revisions( + draft_id,revision,experiment_id,project_id,document,canonical_digest,validation, + mutation_key_digest,request_digest,actor_id,created_at +) VALUES( + 'xp_draft',1,'xp_experiment','xp_project','{}'::jsonb,sha256('doc'::bytea),'{}'::jsonb, + sha256('mutation'::bytea),sha256('request'::bytea),'xp_actor',now() +); +INSERT INTO experiment_versions( + id,experiment_id,project_id,environment_id,placement_id,version_number,source_draft_id, + source_revision,canonical_digest,assignment_key_policy,bucketing_algorithm,allocation_version, + primary_metric_id,primary_metric_version,fallback,compatibility,published_by_actor_id,published_at +) VALUES( + 'xp_version','xp_experiment','xp_project','xp_env','xp_placement',1,'xp_draft', + 1,sha256('doc'::bytea),'installation','experiment_sha256_length_prefixed_v1','allocation_1', + 'xp_metric',1,'normal_placement','{}'::jsonb,'xp_actor',now() +); +INSERT INTO experiment_variants( + id,experiment_version_id,project_id,role,name,paywall_id,paywall_version_id, + allocation_start,allocation_end,compatibility +) VALUES( + 'xp_control','xp_version','xp_project','control','Control','xp_paywall','xp_paywall_version', + 0,5000,'{}'::jsonb +),( + 'xp_treatment','xp_version','xp_project','treatment','Treatment','xp_paywall','xp_paywall_version', + 5000,10000,'{}'::jsonb +); +` + +func setup(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(), 3*time.Minute) + t.Cleanup(cancel) + + config, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatalf("parse DATABASE_TEST_URL: %v", err) + } + db := stdlib.OpenDB(*config) + db.SetMaxOpenConns(1) + defer db.Close() + if err := db.PingContext(ctx); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + // Immutability triggers correctly refuse to delete published Experiment + // versions, so the fixture cannot be torn down row by row. Resetting the + // schema is the only reliable isolation, and DATABASE_TEST_URL is documented + // as a throwaway database. + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must be a throwaway database): %v", err) + } + goose.SetBaseFS(migrations.Files) + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err := goose.UpContext(ctx, db, "."); err != nil { + t.Fatalf("apply migrations: %v", err) + } + if _, err := db.ExecContext(ctx, seedSQL); err != nil { + t.Fatalf("seed Experiment fixture: %v", err) + } + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("open pool: %v", err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +func execute(t *testing.T, ctx context.Context, pool *pgxpool.Pool, statement string, arguments ...any) error { + t.Helper() + _, err := pool.Exec(ctx, statement, arguments...) + return err +} + +// The Experiment schema carries the tenant-scoping and immutability invariants +// that make Experiment results trustworthy, and none of that SQL had an +// integration test. Each assertion below covers one invariant the application +// cannot enforce alone. +func TestExperimentSchemaInvariants(t *testing.T) { + pool, ctx := setup(t) + + t.Run("composite tenant keys reject a cross-environment Experiment version", func(t *testing.T) { + // The version's (id, project, environment) tuple must agree with its + // Experiment's. Without the composite foreign key, an Experiment in one + // Environment could publish a version attributed to another, which is a + // cross-tenant boundary violation. + err := execute(t, ctx, pool, ` + INSERT INTO experiment_versions( + id,experiment_id,project_id,environment_id,placement_id,version_number,source_draft_id, + source_revision,canonical_digest,assignment_key_policy,bucketing_algorithm,allocation_version, + primary_metric_id,primary_metric_version,fallback,compatibility,published_by_actor_id,published_at + ) VALUES( + 'xp_version_cross','xp_experiment','xp_project','xp_other_env','xp_placement',2,'xp_draft', + 1,sha256('doc'::bytea),'installation','experiment_sha256_length_prefixed_v1','allocation_2', + 'xp_metric',1,'normal_placement','{}'::jsonb,'xp_actor',now() + )`) + if err == nil { + t.Fatal("an Experiment version was published into an Environment its Experiment does not belong to") + } + }) + + t.Run("composite tenant keys reject a cross-project variant", func(t *testing.T) { + if err := execute(t, ctx, pool, ` + INSERT INTO organizations(id,name,created_at,updated_at) + VALUES('xp_other_org','Other',now(),now()); + INSERT INTO projects(id,organization_id,key,name,status,created_at,updated_at) + VALUES('xp_other_project','xp_other_org','other','Other','active',now(),now())`); err != nil { + t.Fatal(err) + } + err := execute(t, ctx, pool, ` + INSERT INTO experiment_variants( + id,experiment_version_id,project_id,role,name,paywall_id,paywall_version_id, + allocation_start,allocation_end,compatibility + ) VALUES( + 'xp_cross_variant','xp_version','xp_other_project','treatment','Cross','xp_paywall', + 'xp_paywall_version',0,10000,'{}'::jsonb + )`) + if err == nil { + t.Fatal("a variant was attached to an Experiment version in a different Project") + } + }) + + t.Run("published Experiment versions are immutable", func(t *testing.T) { + // A published version is the allocation contract every assignment is + // attributed to. Mutating it retroactively rewrites history for exposures + // already recorded. + if err := execute(t, ctx, pool, + `UPDATE experiment_versions SET allocation_version='rewritten' WHERE id='xp_version'`); err == nil { + t.Fatal("a published Experiment version was mutated in place") + } else if !strings.Contains(err.Error(), "immutable") { + t.Fatalf("mutation was rejected for the wrong reason: %v", err) + } + if err := execute(t, ctx, pool, + `DELETE FROM experiment_versions WHERE id='xp_version'`); err == nil { + t.Fatal("a published Experiment version was deleted") + } + }) + + t.Run("published variants are immutable", func(t *testing.T) { + if err := execute(t, ctx, pool, + `UPDATE experiment_variants SET allocation_end=10000 WHERE id='xp_control'`); err == nil { + t.Fatal("a published variant's allocation was rewritten in place") + } + }) + + t.Run("allocation invariants are enforced", func(t *testing.T) { + for name, statement := range map[string]string{ + "allocation above the bucket space": ` + INSERT INTO experiment_variants( + id,experiment_version_id,project_id,role,name,paywall_id,paywall_version_id, + allocation_start,allocation_end,compatibility + ) VALUES('xp_over','xp_version','xp_project','treatment','Over','xp_paywall','xp_paywall_version', + 0,10001,'{}'::jsonb)`, + "inverted allocation range": ` + INSERT INTO experiment_variants( + id,experiment_version_id,project_id,role,name,paywall_id,paywall_version_id, + allocation_start,allocation_end,compatibility + ) VALUES('xp_inverted','xp_version','xp_project','treatment','Inverted','xp_paywall','xp_paywall_version', + 8000,8000,'{}'::jsonb)`, + "negative allocation start": ` + INSERT INTO experiment_variants( + id,experiment_version_id,project_id,role,name,paywall_id,paywall_version_id, + allocation_start,allocation_end,compatibility + ) VALUES('xp_negative','xp_version','xp_project','treatment','Negative','xp_paywall','xp_paywall_version', + -1,5000,'{}'::jsonb)`, + "unknown bucketing algorithm": ` + INSERT INTO experiment_versions( + id,experiment_id,project_id,environment_id,placement_id,version_number,source_draft_id, + source_revision,canonical_digest,assignment_key_policy,bucketing_algorithm,allocation_version, + primary_metric_id,primary_metric_version,fallback,compatibility,published_by_actor_id,published_at + ) VALUES('xp_bad_algorithm','xp_experiment','xp_project','xp_env','xp_placement',3,'xp_draft', + 1,sha256('doc'::bytea),'installation','md5','allocation_3','xp_metric',1,'normal_placement', + '{}'::jsonb,'xp_actor',now())`, + } { + t.Run(name, func(t *testing.T) { + if err := execute(t, ctx, pool, statement); err == nil { + t.Fatal("the database accepted a row violating an allocation invariant") + } + }) + } + }) +} + +// An Experiment start or completion that never runs silently invalidates the +// Experiment. Before Phase 8 an expired lease stranded the job in 'leased' +// forever and a single transient failure marked it permanently failed, so both +// failures were silent. This exercises reclaim, requeue with backoff, and the +// terminal dead-letter through the real repository. +func TestScheduleLeaseReclaimAndRequeue(t *testing.T) { + pool, ctx := setup(t) + repository := experimentpostgres.New(pool) + now := time.Now().UTC() + + if _, err := pool.Exec(ctx, ` + INSERT INTO experiment_scheduling_jobs( + id,experiment_id,project_id,environment_id,action,scheduled_at,status,actor_id, + attempt_count,max_attempts,available_at,created_at,updated_at + ) VALUES('xp_job','xp_experiment','xp_project','xp_env','start',$1,'queued','xp_actor', + 0,3,$1,$1,$1)`, now.Add(-time.Hour)); err != nil { + t.Fatalf("seed scheduling job: %v", err) + } + + job, leased, err := repository.LeaseSchedule(ctx, "worker_a", now, now.Add(2*time.Minute)) + if err != nil || !leased { + t.Fatalf("lease a due job: leased=%v err=%v", leased, err) + } + if job.ID != "xp_job" || job.AttemptCount != 1 || job.MaxAttempts != 3 { + t.Fatalf("leased job = %+v, want xp_job attempt 1 of 3", job) + } + + // A second worker must not steal a live lease. + if _, leased, err := repository.LeaseSchedule(ctx, "worker_b", now, now.Add(2*time.Minute)); err != nil || leased { + t.Fatalf("a live lease was stolen: leased=%v err=%v", leased, err) + } + + // After the lease expires the job must be reclaimable; otherwise a worker + // that died mid-job strands the scheduled transition forever. + afterExpiry := now.Add(5 * time.Minute) + reclaimed, leased, err := repository.LeaseSchedule(ctx, "worker_b", afterExpiry, afterExpiry.Add(2*time.Minute)) + if err != nil || !leased { + t.Fatalf("an expired lease was not reclaimed: leased=%v err=%v", leased, err) + } + if reclaimed.AttemptCount != 2 { + t.Fatalf("reclaimed attempt count = %d, want 2", reclaimed.AttemptCount) + } + + // A transient failure with budget remaining requeues with backoff. + if err := repository.FinishSchedule(ctx, reclaimed, false, "schedule_transition_failed", afterExpiry); err != nil { + t.Fatalf("finish with a transient failure: %v", err) + } + var status, code string + var availableAt time.Time + if err := pool.QueryRow(ctx, + `SELECT status,last_error_code,available_at FROM experiment_scheduling_jobs WHERE id='xp_job'`). + Scan(&status, &code, &availableAt); err != nil { + t.Fatal(err) + } + if status != "queued" { + t.Fatalf("status after a transient failure = %q, want queued for retry", status) + } + if code != "schedule_transition_failed" { + t.Fatalf("diagnostic code = %q, want the failure recorded", code) + } + if !availableAt.After(afterExpiry) { + t.Fatalf("available_at = %s, want backoff past %s", availableAt, afterExpiry) + } + // Backoff must actually hold the job back. + if _, leased, err := repository.LeaseSchedule(ctx, "worker_c", afterExpiry, afterExpiry.Add(time.Minute)); err != nil || leased { + t.Fatalf("backoff did not hold the job back: leased=%v err=%v", leased, err) + } + + // Exhausting the budget must dead-letter the job rather than retry forever. + afterBackoff := availableAt.Add(time.Minute) + final, leased, err := repository.LeaseSchedule(ctx, "worker_c", afterBackoff, afterBackoff.Add(2*time.Minute)) + if err != nil || !leased { + t.Fatalf("lease after backoff: leased=%v err=%v", leased, err) + } + if final.AttemptCount != 3 { + t.Fatalf("final attempt count = %d, want 3", final.AttemptCount) + } + if err := repository.FinishSchedule(ctx, final, false, "experiment_state_conflict", afterBackoff); err != nil { + t.Fatalf("finish with the budget exhausted: %v", err) + } + if err := pool.QueryRow(ctx, + `SELECT status,last_error_code FROM experiment_scheduling_jobs WHERE id='xp_job'`). + Scan(&status, &code); err != nil { + t.Fatal(err) + } + if status != "failed" { + t.Fatalf("status after exhausting the retry budget = %q, want failed", status) + } + if code != "experiment_state_conflict" { + t.Fatalf("terminal diagnostic code = %q, want the last failure recorded", code) + } + // A dead-lettered job must not be leased again. + if _, leased, err := repository.LeaseSchedule(ctx, "worker_d", afterBackoff.Add(time.Hour), afterBackoff.Add(2*time.Hour)); err != nil || leased { + t.Fatalf("a dead-lettered job was leased again: leased=%v err=%v", leased, err) + } +} + +// A successful run must close the job out cleanly so it is never replayed. +func TestScheduleSuccessCompletesTheJob(t *testing.T) { + pool, ctx := setup(t) + repository := experimentpostgres.New(pool) + now := time.Now().UTC() + + if _, err := pool.Exec(ctx, ` + INSERT INTO experiment_scheduling_jobs( + id,experiment_id,project_id,environment_id,action,scheduled_at,status,actor_id, + attempt_count,max_attempts,available_at,created_at,updated_at + ) VALUES('xp_job_ok','xp_experiment','xp_project','xp_env','complete',$1,'queued','xp_actor', + 0,3,$1,$1,$1)`, now.Add(-time.Hour)); err != nil { + t.Fatalf("seed scheduling job: %v", err) + } + job, leased, err := repository.LeaseSchedule(ctx, "worker_a", now, now.Add(2*time.Minute)) + if err != nil || !leased { + t.Fatalf("lease: leased=%v err=%v", leased, err) + } + if err := repository.FinishSchedule(ctx, job, true, "", now); err != nil { + t.Fatalf("finish successfully: %v", err) + } + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM experiment_scheduling_jobs WHERE id='xp_job_ok'`).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "completed" { + t.Fatalf("status = %q, want completed", status) + } + if _, leased, err := repository.LeaseSchedule(ctx, "worker_b", now.Add(time.Hour), now.Add(2*time.Hour)); err != nil || leased { + t.Fatalf("a completed job was leased again: leased=%v err=%v", leased, err) + } + // Finishing an unleased job is a conflict, not a silent no-op. + if err := repository.FinishSchedule(ctx, job, true, "", now); err == nil { + t.Fatal("finishing an already-completed job succeeded") + } +} + +var _ experiment.Repository = (*experimentpostgres.Repository)(nil) + +// TestReleaseClosureStatementsMatchTheSchema guards the Experiment publish +// release SQL against the real schema and against pgx's extended protocol. This +// path had no test and carried two defects that made every Experiment publish +// return 500, so no Experiment could ever reach a published Version: one +// statement selected `assets.url`, a column that does not exist (it is +// `public_url`), and two others were semicolon-joined parameterized statements, +// which PostgreSQL rejects with SQLSTATE 42601. Both are only reported when the +// statement is prepared, which is exactly what this test does. +func TestReleaseClosureStatementsMatchTheSchema(t *testing.T) { + pool, ctx := setup(t) + + connection, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire connection: %v", err) + } + defer connection.Release() + + for index, statement := range experimentpostgres.ReleaseClosureStatements { + if _, err := connection.Conn().Prepare(ctx, fmt.Sprintf("closure_%d", index), statement); err != nil { + t.Errorf("release-closure statement %d does not match the schema: %v\n %s", index, err, statement) + } + } +} + +// Preparing a statement proves its columns exist; it does not prove the row it +// writes satisfies the table's constraints. Migration 00020 added a NOT NULL +// available_at to experiment_scheduling_jobs and the publish writer was never +// updated, so every Experiment published with a schedule failed with a 500 and +// no Experiment could be scheduled at all. The lease-recovery test above missed +// it because that test seeds its own row and supplies the column the production +// writer omitted. This executes the production statements themselves. +func TestScheduleJobInsertsSatisfyTheSchema(t *testing.T) { + pool, ctx := setup(t) + now := time.Now().UTC() + + for index, statement := range experimentpostgres.ScheduleJobInsertStatements { + transaction, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + _, err = transaction.Exec(ctx, statement, + fmt.Sprintf("xp_insert_job_%d", index), "xp_experiment", "xp_project", "xp_env", + now.Add(time.Hour), "xp_actor", now) + if err != nil { + t.Errorf("scheduling-job statement %d was rejected by the schema: %v\n %s", index, err, statement) + } + // The row must be immediately leasable once due, which is what + // available_at exists to express. + if err == nil { + var due bool + if err := transaction.QueryRow(ctx, + `SELECT available_at = scheduled_at FROM experiment_scheduling_jobs WHERE id=$1`, + fmt.Sprintf("xp_insert_job_%d", index)).Scan(&due); err != nil { + t.Errorf("read back statement %d: %v", index, err) + } else if !due { + t.Errorf("statement %d wrote available_at out of step with scheduled_at", index) + } + } + _ = transaction.Rollback(ctx) + } +} diff --git a/apps/api/internal/platform/experimentpostgres/repository_test.go b/apps/api/internal/platform/experimentpostgres/repository_test.go index 736af988..07610969 100644 --- a/apps/api/internal/platform/experimentpostgres/repository_test.go +++ b/apps/api/internal/platform/experimentpostgres/repository_test.go @@ -2,8 +2,9 @@ package experimentpostgres import ( "encoding/json" - "strings" "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/experiment" ) func TestSetReleaseContentDigestChangesWithReleaseMaterial(t *testing.T) { @@ -41,8 +42,12 @@ func TestValidateExperimentDeliveryPayloadRequiresExactClosure(t *testing.T) { if err != nil { t.Fatal(err) } - if err = validateExperimentDeliveryPayload(raw); err == nil || !strings.Contains(err.Error(), "paywall closure") { - t.Fatalf("missing treatment closure error = %v", err) + // Assert the machine-readable reason rather than the prose: the reason is + // the contract the API surfaces as `details.reason` on a 422. + err = validateExperimentDeliveryPayload(raw) + reason, ok := experiment.InvalidReason(err) + if err == nil || !ok || reason != "experiment_paywall_closure_incomplete" { + t.Fatalf("missing treatment closure error = %v (reason %q)", err, reason) } release["paywallVersions"] = []any{map[string]any{"id": "paywall_version_treatment"}} diff --git a/apps/api/internal/platform/httpserver/httpmiddleware/rate_limit.go b/apps/api/internal/platform/httpserver/httpmiddleware/rate_limit.go new file mode 100644 index 00000000..50a48d65 --- /dev/null +++ b/apps/api/internal/platform/httpserver/httpmiddleware/rate_limit.go @@ -0,0 +1,77 @@ +package httpmiddleware + +import ( + "math" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" +) + +// Limiter is the token-bucket contract shared by every Mosaic rate limiter. +type Limiter interface { + Allow(string) (bool, time.Duration) +} + +var rateLimitRejections = func() metric.Int64Counter { + counter, _ := otel.Meter("mosaic/http").Int64Counter( + "mosaic.http.rate_limit.rejections", + metric.WithDescription("Requests rejected by a Mosaic rate limiter, by surface."), + ) + return counter +}() + +// RateLimit applies a per-surface token bucket. Rejections are observable +// (counter plus a structured log line) and always carry safe retry metadata so +// a well-behaved client can back off instead of hot-looping. +// +// surface names the protected area (auth, delivery, ingestion, api, decision) +// so limits stay per-surface rather than one identical global limit. +func RateLimit(surface string, limiter Limiter, key func(*http.Request) string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if limiter == nil || key == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bucket := key(r) + if bucket == "" { + next.ServeHTTP(w, r) + return + } + allowed, retryAfter := limiter.Allow(bucket) + if allowed { + next.ServeHTTP(w, r) + return + } + Reject(w, r, surface, retryAfter) + }) + } +} + +// Reject writes Mosaic's standard rate-limit response. It is exported so +// handlers with their own limiter bookkeeping stay consistent with the +// middleware. +func Reject(w http.ResponseWriter, r *http.Request, surface string, retryAfter time.Duration) { + seconds := int(math.Ceil(retryAfter.Seconds())) + if seconds < 1 { + seconds = 1 + } + w.Header().Set("Retry-After", strconv.Itoa(seconds)) + rateLimitRejections.Add(r.Context(), 1, metric.WithAttributes(attribute.String("surface", surface))) + zerolog.Ctx(r.Context()).Warn(). + Str("rate_limit_surface", surface). + Int("retry_after_seconds", seconds). + Msg("request rejected by rate limiter") + response.Error(w, r, &response.APIError{ + Status: http.StatusTooManyRequests, + Code: "rate_limited", + Message: "Too many requests. Retry after the interval in the Retry-After header.", + Details: map[string]any{"retryAfterSeconds": seconds}, + }) +} diff --git a/apps/api/internal/platform/httpserver/httpmiddleware/request_logging.go b/apps/api/internal/platform/httpserver/httpmiddleware/request_logging.go index f10f6952..6baafe57 100644 --- a/apps/api/internal/platform/httpserver/httpmiddleware/request_logging.go +++ b/apps/api/internal/platform/httpserver/httpmiddleware/request_logging.go @@ -23,7 +23,9 @@ func RequestLogging(base zerolog.Logger) func(http.Handler) http.Handler { Str("request_id", requestID). Str("http_method", r.Method). Str("http_path", r.URL.Path). - Str("remote_ip", r.RemoteAddr) + // ClientIP reflects the trusted-proxy decision, so a forged + // X-Forwarded-For never appears here as the client address. + Str("remote_ip", ClientIP(r)) if spanContext.IsValid() { context = context.Str("trace_id", spanContext.TraceID().String()) } diff --git a/apps/api/internal/platform/httpserver/httpmiddleware/security_headers.go b/apps/api/internal/platform/httpserver/httpmiddleware/security_headers.go index 7471bd9d..d134a555 100644 --- a/apps/api/internal/platform/httpserver/httpmiddleware/security_headers.go +++ b/apps/api/internal/platform/httpserver/httpmiddleware/security_headers.go @@ -2,12 +2,36 @@ package httpmiddleware import "net/http" -func SecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - next.ServeHTTP(w, r) - }) +// hstsMaxAge is one year, the value browsers require for preload eligibility. +const hstsMaxAge = "max-age=31536000; includeSubDomains" + +// permissionsPolicy denies every powerful browser capability. The Mosaic API +// serves JSON and immutable Assets and needs none of them; an explicit denial +// limits the blast radius of anything injected into an API response. +const permissionsPolicy = "accelerometer=(), autoplay=(), camera=(), display-capture=(), " + + "encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), " + + "microphone=(), midi=(), payment=(), publickey-credentials-get=(), screen-wake-lock=(), " + + "usb=(), xr-spatial-tracking=()" + +// SecurityHeaders sets Mosaic's baseline response headers. +// +// enableHSTS must only be true for a deployment reached over TLS. A +// Strict-Transport-Security header emitted from a plaintext development origin +// pins that host to HTTPS in the operator's browser for a year, so the decision +// is made once from configuration rather than from a forgeable request header. +func SecurityHeaders(enableHSTS bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := w.Header() + header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + header.Set("Referrer-Policy", "no-referrer") + header.Set("X-Content-Type-Options", "nosniff") + header.Set("X-Frame-Options", "DENY") + header.Set("Permissions-Policy", permissionsPolicy) + if enableHSTS { + header.Set("Strict-Transport-Security", hstsMaxAge) + } + next.ServeHTTP(w, r) + }) + } } diff --git a/apps/api/internal/platform/httpserver/httpmiddleware/timeout.go b/apps/api/internal/platform/httpserver/httpmiddleware/timeout.go index ee4f84c2..f1ae2aa2 100644 --- a/apps/api/internal/platform/httpserver/httpmiddleware/timeout.go +++ b/apps/api/internal/platform/httpserver/httpmiddleware/timeout.go @@ -27,3 +27,10 @@ func Timeout(timeout time.Duration) func(http.Handler) http.Handler { }) } } + +// RouteTimeout overrides the global handler timeout for a subtree of routes. +// Asset upload and analytics ingestion legitimately take longer than the +// default request budget, and the global timeout must not bound them. +func RouteTimeout(timeout time.Duration) func(http.Handler) http.Handler { + return Timeout(timeout) +} diff --git a/apps/api/internal/platform/httpserver/httpmiddleware/trusted_proxy.go b/apps/api/internal/platform/httpserver/httpmiddleware/trusted_proxy.go new file mode 100644 index 00000000..c3b800a0 --- /dev/null +++ b/apps/api/internal/platform/httpserver/httpmiddleware/trusted_proxy.go @@ -0,0 +1,118 @@ +package httpmiddleware + +import ( + "net" + "net/http" + "strings" +) + +// RealIP rewrites r.RemoteAddr from X-Forwarded-For or X-Real-IP, but only when +// the direct TCP peer is inside one of the configured trusted networks. +// +// Chi's own RealIP middleware trusts those headers unconditionally, which lets +// any client forge the value Mosaic uses for rate-limit bucketing and for the +// remote_ip field in request logs. With no trusted networks configured (the +// default), the forwarded headers are ignored entirely. +func RealIP(trustedCIDRs []string) func(http.Handler) http.Handler { + networks := parseTrustedNetworks(trustedCIDRs) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(networks) > 0 && peerIsTrusted(r.RemoteAddr, networks) { + if forwarded := forwardedClientIP(r, networks); forwarded != "" { + r = r.Clone(r.Context()) + r.RemoteAddr = forwarded + } + } + next.ServeHTTP(w, r) + }) + } +} + +func parseTrustedNetworks(entries []string) []*net.IPNet { + networks := make([]*net.IPNet, 0, len(entries)) + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if _, network, err := net.ParseCIDR(entry); err == nil { + networks = append(networks, network) + continue + } + if ip := net.ParseIP(entry); ip != nil { + bits := 32 + if ip.To4() == nil { + bits = 128 + } + networks = append(networks, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) + } + } + return networks +} + +func peerIsTrusted(remoteAddr string, networks []*net.IPNet) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return false + } + return ipIsTrusted(ip, networks) +} + +// forwardedClientIP walks X-Forwarded-For right-to-left, discarding entries that +// are themselves trusted infrastructure, and returns the first untrusted address. +// It falls back to X-Real-IP and returns an empty string when neither header +// holds a usable address. +// +// Taking the left-most entry instead would let a client prepend an arbitrary +// address ("X-Forwarded-For: ") and have the real edge proxy append the +// true source behind it, so the forged value would win. Only the right-hand end +// of the chain is written by infrastructure Mosaic actually trusts, so the scan +// must start there and stop at the first hop that is not a trusted proxy. +func forwardedClientIP(r *http.Request, trusted []*net.IPNet) string { + if value := r.Header.Get("X-Forwarded-For"); value != "" { + entries := strings.Split(value, ",") + for index := len(entries) - 1; index >= 0; index-- { + ip := net.ParseIP(strings.Trim(strings.TrimSpace(entries[index]), "[]")) + if ip == nil { + // A malformed hop makes everything to its left unverifiable, so + // the chain stops being trustworthy here. + break + } + if ipIsTrusted(ip, trusted) { + continue + } + return net.JoinHostPort(ip.String(), "0") + } + // Every hop was trusted infrastructure: fall through to X-Real-IP rather + // than attributing the request to a proxy address. + } + if value := strings.TrimSpace(r.Header.Get("X-Real-IP")); value != "" { + if ip := net.ParseIP(strings.Trim(value, "[]")); ip != nil { + return net.JoinHostPort(ip.String(), "0") + } + } + return "" +} + +func ipIsTrusted(ip net.IP, networks []*net.IPNet) bool { + for _, network := range networks { + if network.Contains(ip) { + return true + } + } + return false +} + +// ClientIP is the canonical limiter/log key for a request. It always reflects +// the trusted-proxy decision because RealIP has already normalised RemoteAddr. +func ClientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/apps/api/internal/platform/httpserver/httpmiddleware/trusted_proxy_test.go b/apps/api/internal/platform/httpserver/httpmiddleware/trusted_proxy_test.go new file mode 100644 index 00000000..7d002e0c --- /dev/null +++ b/apps/api/internal/platform/httpserver/httpmiddleware/trusted_proxy_test.go @@ -0,0 +1,119 @@ +package httpmiddleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type singleTokenLimiter struct{ used map[string]int } + +func (l *singleTokenLimiter) Allow(key string) (bool, time.Duration) { + l.used[key]++ + return l.used[key] <= 1, time.Second +} + +// A spoofable client key lets any caller bypass every Mosaic rate limit by +// rotating X-Forwarded-For, which is the abuse-protection bypass Phase 8 closes. +// With no trusted proxy configured, two requests from the same TCP peer must +// share one limiter bucket no matter what they claim in forwarded headers. +func TestForwardedHeadersAreIgnoredFromAnUntrustedPeer(t *testing.T) { + limiter := &singleTokenLimiter{used: map[string]int{}} + handler := RealIP(nil)(RateLimit("test", limiter, func(r *http.Request) string { + return ClientIP(r) + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }))) + + statuses := make([]int, 0, 2) + for _, forwarded := range []string{"203.0.113.10", "198.51.100.77"} { + request := httptest.NewRequest(http.MethodGet, "/v1/anything", nil) + request.RemoteAddr = "10.9.8.7:54321" + request.Header.Set("X-Forwarded-For", forwarded) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + statuses = append(statuses, recorder.Code) + } + + if statuses[0] != http.StatusNoContent { + t.Fatalf("first request status = %d, want 204", statuses[0]) + } + if statuses[1] != http.StatusTooManyRequests { + t.Fatalf("second request status = %d, want 429 from the shared bucket", statuses[1]) + } + if len(limiter.used) != 1 { + t.Fatalf("limiter buckets = %#v, want exactly one shared bucket", limiter.used) + } +} + +func TestForwardedHeadersAreHonouredFromATrustedPeer(t *testing.T) { + var seen string + handler := RealIP([]string{"10.9.0.0/16"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = ClientIP(r) + })) + request := httptest.NewRequest(http.MethodGet, "/v1/anything", nil) + request.RemoteAddr = "10.9.8.7:54321" + request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.9.8.7") + handler.ServeHTTP(httptest.NewRecorder(), request) + if seen != "203.0.113.10" { + t.Fatalf("client IP = %q, want the forwarded client address from a trusted proxy", seen) + } +} + +// A client behind a trusted edge proxy can prepend its own X-Forwarded-For +// value; the proxy appends the true source to the right of it. Reading the +// left-most entry would therefore hand the attacker control of the limiter +// bucket and the remote_ip log field even with trusted proxies configured. The +// right-to-left scan must skip trusted hops and stop at the real client. +func TestForgedForwardedPrefixLosesToTheRealClientAddress(t *testing.T) { + for _, testCase := range []struct { + name string + forwarded string + want string + }{ + { + name: "forged prefix from the client", + forwarded: "203.0.113.10, 198.51.100.77", + want: "198.51.100.77", + }, + { + name: "trusted hops on the right are discarded", + forwarded: "198.51.100.77, 10.9.1.1, 10.9.8.7", + want: "198.51.100.77", + }, + { + name: "malformed hop stops the scan", + forwarded: "203.0.113.10, not-an-ip, 10.9.8.7", + want: "10.9.8.7", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + var seen string + handler := RealIP([]string{"10.9.0.0/16"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = ClientIP(r) + })) + request := httptest.NewRequest(http.MethodGet, "/v1/anything", nil) + request.RemoteAddr = "10.9.8.7:54321" + request.Header.Set("X-Forwarded-For", testCase.forwarded) + handler.ServeHTTP(httptest.NewRecorder(), request) + if seen != testCase.want { + t.Fatalf("client IP = %q, want %q", seen, testCase.want) + } + }) + } +} + +func TestRateLimitRejectionCarriesRetryMetadata(t *testing.T) { + limiter := &singleTokenLimiter{used: map[string]int{"ip:1.2.3.4": 5}} + handler := RateLimit("auth", limiter, func(*http.Request) string { return "ip:1.2.3.4" })( + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)) + if recorder.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429", recorder.Code) + } + if recorder.Header().Get("Retry-After") == "" { + t.Fatal("rejection is missing Retry-After metadata") + } +} diff --git a/apps/api/internal/platform/httpserver/response/response.go b/apps/api/internal/platform/httpserver/response/response.go index d2f4f2d2..b513d6e2 100644 --- a/apps/api/internal/platform/httpserver/response/response.go +++ b/apps/api/internal/platform/httpserver/response/response.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" "github.com/rs/zerolog" @@ -108,9 +109,31 @@ func Representation(w http.ResponseWriter, status int, contentType string, body func Error(w http.ResponseWriter, r *http.Request, err error) { status, payload := errorDetails(err) payload.RequestID = chimiddleware.GetReqID(r.Context()) + if status >= http.StatusInternalServerError { + logUnexpectedError(r, status, payload.RequestID, err) + } writeJSON(w, r, status, errorEnvelope{Error: payload}) } +// logUnexpectedError records the cause behind a 5xx response. The response body +// deliberately carries only `internal_error` and a request ID, so without this +// the cause was discarded entirely: an operator following any "the API returned +// 500" runbook saw nothing but an access-log line with http_status 500 and had +// no way to reach the underlying error. The cause is written to the operator log +// only — never to the response — and the request ID ties the two together. +func logUnexpectedError(r *http.Request, status int, requestID string, err error) { + event := zerolog.Ctx(r.Context()).Error(). + Int("http_status", status). + Str("http_method", r.Method) + if route := chi.RouteContext(r.Context()); route != nil && route.RoutePattern() != "" { + event = event.Str("http_route", route.RoutePattern()) + } + if requestID != "" { + event = event.Str("request_id", requestID) + } + event.Err(err).Msg("request failed with an unexpected error") +} + func RequestTimeout(w http.ResponseWriter, r *http.Request) { writeJSON(w, r, http.StatusGatewayTimeout, errorEnvelope{Error: errorPayload{ Code: requestTimeoutCode, @@ -120,8 +143,15 @@ func RequestTimeout(w http.ResponseWriter, r *http.Request) { } func ServiceUnavailable(w http.ResponseWriter, r *http.Request, code, message string) { + ServiceUnavailableWithDetails(w, r, code, message, nil) +} + +// ServiceUnavailableWithDetails adds safe machine-readable diagnostics such as +// per-dependency readiness codes. Details must never contain credentials, +// connection strings, or internal topology. +func ServiceUnavailableWithDetails(w http.ResponseWriter, r *http.Request, code, message string, details map[string]any) { writeJSON(w, r, http.StatusServiceUnavailable, errorEnvelope{Error: errorPayload{ - Code: code, Message: message, RequestID: chimiddleware.GetReqID(r.Context()), + Code: code, Message: message, Details: cloneDetails(details), RequestID: chimiddleware.GetReqID(r.Context()), }}) } @@ -134,9 +164,32 @@ func errorDetails(err error) (int, errorPayload) { if apiError.Status < http.StatusBadRequest || apiError.Status > 599 { return internalError() } - if apiError.Status >= http.StatusInternalServerError { + // A 500 is by definition the unexpected bucket: never trust whatever code + // or message reached it, and never let a cause escape. + // + // Statuses above 500 are different. A handler that answers 503 + // `providerUnavailable` or 502 `providerInvalidResponse` chose a safe, + // documented, machine-readable outcome that an SDK uses to decide whether + // to retry. Collapsing every 5xx into 500 `internal_error` erased all of + // them: every deliberate upstream-failure code in the OpenAPI contract was + // unreachable, and clients could not tell "the provider is down, retry" + // from "Mosaic is broken". A code is still required, so an APIError that + // forgot to set one degrades to internal_error rather than leaking. + if apiError.Status == http.StatusInternalServerError || apiError.Code == "" { return internalError() } + // Above 500 the status and code are preserved but the message is replaced. + // Codes are Mosaic-owned constants and safe by construction; messages are + // free text and are where internal topology leaks (a readiness message + // naming a database host, for example). Clients need the code, not the + // prose. + if apiError.Status > http.StatusInternalServerError { + return apiError.Status, errorPayload{ + Code: apiError.Code, + Message: upstreamFailureMessage, + Details: cloneDetails(apiError.Details), + } + } code := apiError.Code if code == "" { @@ -155,6 +208,10 @@ func errorDetails(err error) (int, errorPayload) { } } +// upstreamFailureMessage is the fixed human text for a deliberate 5xx above +// 500. The machine-readable code carries the meaning. +const upstreamFailureMessage = "The request could not be completed because a dependency failed. Retry may succeed." + func internalError() (int, errorPayload) { return http.StatusInternalServerError, errorPayload{ Code: internalErrorCode, diff --git a/apps/api/internal/platform/httpserver/response/response_test.go b/apps/api/internal/platform/httpserver/response/response_test.go index 4a47275b..e59486be 100644 --- a/apps/api/internal/platform/httpserver/response/response_test.go +++ b/apps/api/internal/platform/httpserver/response/response_test.go @@ -188,7 +188,10 @@ func TestErrorDoesNotExposeUnknownInternalError(t *testing.T) { } } -func TestServerAPIErrorIsAlwaysSanitized(t *testing.T) { +// A deliberate 5xx keeps its machine-readable code, because SDKs and Studio use +// it to tell a failed dependency from a broken Mosaic, but never its message: +// free-text messages are where internal topology leaks. +func TestServerAPIErrorKeepsItsCodeAndDropsItsMessage(t *testing.T) { recorder := serveWithRequestID(t, "req-server", func(w http.ResponseWriter, r *http.Request) { Error(w, r, &APIError{ Status: http.StatusServiceUnavailable, @@ -197,9 +200,12 @@ func TestServerAPIErrorIsAlwaysSanitized(t *testing.T) { }) }) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", recorder.Code) + } payload := decodeError(t, recorder) - if payload.Error.Code != "internal_error" { - t.Fatalf("code = %q, want internal_error", payload.Error.Code) + if payload.Error.Code != "database_unavailable" { + t.Fatalf("code = %q, want the deliberate code to survive", payload.Error.Code) } if strings.Contains(recorder.Body.String(), "db.internal") { t.Fatalf("response exposed internal host: %s", recorder.Body.String()) @@ -250,3 +256,123 @@ func decodeError(t *testing.T, recorder *httptest.ResponseRecorder) errorEnvelop } return payload } + +// TestUnexpectedErrorIsLoggedForOperators protects every "the API returned 500" +// runbook. The response body carries only `internal_error` plus a request ID, so +// if the cause is not written to the operator log it is lost: the sole trace of +// the failure was an access-log line with http_status 500 and no reason. The +// cause must reach the log and must not reach the body. +func TestUnexpectedErrorIsLoggedForOperators(t *testing.T) { + var logged bytes.Buffer + logger := zerolog.New(&logged) + + request := httptest.NewRequest(http.MethodPost, "/v1/projects/project_1/placements", nil) + request.Header.Set(RequestIDHeader, "req-unexpected") + request = request.WithContext(logger.WithContext(request.Context())) + recorder := httptest.NewRecorder() + + chimiddleware.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Error(w, r, stderrors.New(`ERROR: new row violates check constraint "placements_key_format_check"`)) + })).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", recorder.Code) + } + entry := logged.String() + if !strings.Contains(entry, "placements_key_format_check") { + t.Fatalf("operator log did not record the cause: %s", entry) + } + if !strings.Contains(entry, "req-unexpected") { + t.Fatalf("operator log did not record the request ID: %s", entry) + } + if strings.Contains(recorder.Body.String(), "placements_key_format_check") { + t.Fatalf("response leaked the internal cause: %s", recorder.Body.String()) + } +} + +// TestClientErrorIsNotLoggedAsUnexpected keeps ordinary 4xx validation traffic +// out of the error log; otherwise the signal that matters is buried. +func TestClientErrorIsNotLoggedAsUnexpected(t *testing.T) { + var logged bytes.Buffer + logger := zerolog.New(&logged) + + request := httptest.NewRequest(http.MethodPost, "/v1/projects/project_1/placements", nil) + request = request.WithContext(logger.WithContext(request.Context())) + recorder := httptest.NewRecorder() + + chimiddleware.RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Error(w, r, ValidationFailed(map[string][]string{"key": {"must be lowercase"}})) + })).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422", recorder.Code) + } + if logged.Len() != 0 { + t.Fatalf("client error was logged as unexpected: %s", logged.String()) + } +} + +// The API documents deliberate upstream-failure statuses -- 503 +// providerUnavailable, 503 asset_storage_failed, 502 providerInvalidResponse -- +// and SDKs use them to decide whether to retry. Every one of them was rewritten +// to 500 internal_error before reaching the client, so a temporarily +// unavailable commerce provider was indistinguishable from a broken Mosaic and +// the documented contract could never be produced. A 500 must still collapse, +// and an APIError with no code must still degrade safely rather than leak. +func TestDeliberateUpstreamFailureStatusesReachTheClient(t *testing.T) { + for name, testCase := range map[string]struct { + err error + wantStatus int + wantCode string + wantMessage string + }{ + "provider temporarily unavailable": { + &APIError{Status: http.StatusServiceUnavailable, Code: "providerUnavailable", + Message: "The provider is temporarily unavailable.", + Cause: stderrors.New("dial tcp 10.0.0.1:443: connection refused")}, + http.StatusServiceUnavailable, "providerUnavailable", upstreamFailureMessage, + }, + "provider returned an invalid response": { + &APIError{Status: http.StatusBadGateway, Code: "providerInvalidResponse", + Message: "The provider returned an invalid response."}, + http.StatusBadGateway, "providerInvalidResponse", upstreamFailureMessage, + }, + "an unexpected 500 still collapses": { + &APIError{Status: http.StatusInternalServerError, Code: "some_internal_detail", + Message: "pq: relation does not exist", Cause: stderrors.New("SQLSTATE 42P01")}, + http.StatusInternalServerError, "internal_error", "An unexpected error occurred.", + }, + "a 5xx with no code degrades to internal_error": { + &APIError{Status: http.StatusServiceUnavailable}, + http.StatusInternalServerError, "internal_error", "An unexpected error occurred.", + }, + } { + t.Run(name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/x", nil) + Error(recorder, request, testCase.err) + + if recorder.Code != testCase.wantStatus { + t.Fatalf("status = %d, want %d (body %s)", recorder.Code, testCase.wantStatus, recorder.Body.String()) + } + var payload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Error.Code != testCase.wantCode || payload.Error.Message != testCase.wantMessage { + t.Fatalf("body = %s, want code %q message %q", + recorder.Body.String(), testCase.wantCode, testCase.wantMessage) + } + // The cause must never reach the client on any 5xx. + if strings.Contains(recorder.Body.String(), "connection refused") || + strings.Contains(recorder.Body.String(), "SQLSTATE") { + t.Fatalf("the internal cause leaked into the response: %s", recorder.Body.String()) + } + }) + } +} diff --git a/apps/api/internal/platform/httpserver/router.go b/apps/api/internal/platform/httpserver/router.go index 33cc7289..801f3261 100644 --- a/apps/api/internal/platform/httpserver/router.go +++ b/apps/api/internal/platform/httpserver/router.go @@ -9,6 +9,7 @@ import ( chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/riandyrn/otelchi" + otelchimetric "github.com/riandyrn/otelchi/metric" "github.com/rs/zerolog" "github.com/Mujhtech/mosaic/apps/api/internal/analytics" @@ -31,19 +32,21 @@ import ( const ( MiddlewareRequestID = "request_id" - MiddlewareRealIP = "real_ip" + MiddlewareRealIP = "trusted_proxy_real_ip" MiddlewareTelemetry = "otelchi" MiddlewareRequestLogging = "request_scoped_zerolog" MiddlewareRecovery = "recovery" MiddlewareSecurityHeaders = "security_headers" MiddlewareCORS = "cors" MiddlewareTimeout = "timeout" + MiddlewareMetrics = "otelchi_metrics" ) var middlewareOrder = []string{ MiddlewareRequestID, MiddlewareRealIP, MiddlewareTelemetry, + MiddlewareMetrics, MiddlewareRequestLogging, MiddlewareRecovery, MiddlewareSecurityHeaders, @@ -55,13 +58,24 @@ type Config struct { ServiceName string AllowedOrigins []string RequestTimeout time.Duration + // UploadTimeout and IngestTimeout override RequestTimeout for the two + // routes whose legitimate work exceeds the default request budget. + UploadTimeout time.Duration + IngestTimeout time.Duration + // TrustedProxyCIDRs lists peers whose forwarded-client headers are honoured. + TrustedProxyCIDRs []string + // EnableHSTS is set when the deployment is reached over TLS. + EnableHSTS bool } type Dependencies struct { - CloudWorkspace *cloudworkspace.Service - HostedPublishing *hostedpublishing.Service - PlacementDecision *placementdecision.Service - PrincipalResolver authn.Resolver + CloudWorkspace *cloudworkspace.Service + HostedPublishing *hostedpublishing.Service + PlacementDecision *placementdecision.Service + PrincipalResolver authn.Resolver + // Readiness is the full dependency probe used by /health/ready. When it is + // nil the router falls back to ReadinessChecker. + Readiness *health.Readiness ReadinessChecker health.Checker BrowserAuth *browserauth.Service BrowserAuthConfig browserauthhttp.Config @@ -71,6 +85,16 @@ type Dependencies struct { AnalyticsKeyLimiter analyticshttp.Limiter AnalyticsEventLimiter analyticshttp.EventLimiter Experiment *experiment.Service + // APILimiter is the baseline limit for authenticated dashboard APIs. + APILimiter httpmiddleware.Limiter + // DecisionLimiter bounds Placement and Experiment decision reads. + DecisionLimiter httpmiddleware.Limiter + // UploadLimiter bounds asset upload, whose per-request cost (body size, + // extended timeout, object-storage write) is far above the API baseline. + UploadLimiter httpmiddleware.Limiter + // ExportLimiter bounds the analytics, experiment, and privacy export and + // deletion-request routes, each of which enqueues a history-scanning job. + ExportLimiter httpmiddleware.Limiter } func New(cfg Config, logger zerolog.Logger) http.Handler { @@ -81,19 +105,28 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende router := chi.NewRouter() router.Use(chimiddleware.RequestID) - router.Use(chimiddleware.RealIP) - router.Use(otelchi.Middleware(cfg.ServiceName, otelchi.WithChiRoutes(router))) + // Mosaic's own RealIP honours forwarded-client headers only from a trusted + // peer, so limiter buckets and remote_ip log fields cannot be spoofed. + router.Use(httpmiddleware.RealIP(cfg.TrustedProxyCIDRs)) + router.Use(otelchi.Middleware(cfg.ServiceName, + otelchi.WithChiRoutes(router), + otelchi.WithRequestMethodInSpanName(true), + )) + metrics := otelchimetric.NewBaseConfig(cfg.ServiceName) + router.Use(otelchimetric.NewRequestDurationMillis(metrics)) + router.Use(otelchimetric.NewRequestInFlight(metrics)) + router.Use(otelchimetric.NewResponseSizeBytes(metrics)) router.Use(httpmiddleware.RequestLogging(logger)) router.Use(httpmiddleware.Recovery) - router.Use(httpmiddleware.SecurityHeaders) + router.Use(httpmiddleware.SecurityHeaders(cfg.EnableHSTS)) router.Use(corsMiddleware(cfg.AllowedOrigins)) router.Use(httpmiddleware.Timeout(cfg.RequestTimeout)) router.Mount("/health/live", health.LiveRoutes()) - router.Mount("/health/ready", health.ReadyRoutes(dependencies.ReadinessChecker)) + router.Mount("/health/ready", readinessRoutes(dependencies)) // Compatibility aliases retained for existing probes while documented callers migrate. router.Mount("/health", health.LiveRoutes()) - router.Mount("/ready", health.ReadyRoutes(dependencies.ReadinessChecker)) + router.Mount("/ready", readinessRoutes(dependencies)) if dependencies.BrowserAuth != nil || dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.PlacementDecision != nil || dependencies.Analytics != nil { router.Route("/v1", func(versioned chi.Router) { versioned.Use(trustedMutationOrigins(cfg.AllowedOrigins)) @@ -103,6 +136,10 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende if dependencies.CloudWorkspace != nil || dependencies.HostedPublishing != nil || dependencies.Analytics != nil { versioned.Group(func(authenticated chi.Router) { authenticated.Use(authn.Middleware(dependencies.PrincipalResolver)) + // Authenticated dashboard APIs had no limit at all before + // Phase 8; the baseline bucket is per principal so one + // tenant cannot exhaust the API for everyone. + authenticated.Use(httpmiddleware.RateLimit("api", dependencies.APILimiter, principalKey)) if dependencies.CloudWorkspace != nil { cloudworkspacehttp.RegisterWorkspaceRoutes(authenticated, dependencies.CloudWorkspace) } @@ -113,16 +150,25 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende cloudworkspacehttp.RegisterProjectRoutes(project, dependencies.CloudWorkspace) } if dependencies.HostedPublishing != nil { - hostedpublishinghttp.RegisterProjectRoutes(project, dependencies.HostedPublishing) + hostedpublishinghttp.RegisterProjectRoutes(project, dependencies.HostedPublishing, + routeTimeout(cfg.UploadTimeout, cfg.RequestTimeout), + httpmiddleware.RateLimit("upload", dependencies.UploadLimiter, principalKey)) } if dependencies.PlacementDecision != nil { - placementdecisionhttp.RegisterProjectRoutes(project, dependencies.PlacementDecision) + project.Group(func(decision chi.Router) { + decision.Use(httpmiddleware.RateLimit("decision", dependencies.DecisionLimiter, principalKey)) + placementdecisionhttp.RegisterProjectRoutes(decision, dependencies.PlacementDecision) + }) } if dependencies.Analytics != nil { - analyticshttp.RegisterProjectRoutes(project, dependencies.Analytics) + analyticshttp.RegisterProjectRoutes(project, dependencies.Analytics, + httpmiddleware.RateLimit("export", dependencies.ExportLimiter, principalKey)) } if dependencies.Experiment != nil { - experimenthttp.RegisterProjectRoutes(project, dependencies.Experiment) + project.Group(func(decision chi.Router) { + decision.Use(httpmiddleware.RateLimit("decision", dependencies.DecisionLimiter, principalKey)) + experimenthttp.RegisterProjectRoutes(decision, dependencies.Experiment) + }) } }) }) @@ -131,7 +177,8 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende hostedpublishinghttp.RegisterPublicRoutes(versioned, dependencies.HostedPublishing, dependencies.DeliveryLimiter) } if dependencies.Analytics != nil { - analyticshttp.RegisterPublicRoutes(versioned, dependencies.Analytics, dependencies.AnalyticsIPLimiter, dependencies.AnalyticsKeyLimiter, dependencies.AnalyticsEventLimiter) + analyticshttp.RegisterPublicRoutes(versioned, dependencies.Analytics, dependencies.AnalyticsIPLimiter, dependencies.AnalyticsKeyLimiter, dependencies.AnalyticsEventLimiter, + routeTimeout(cfg.IngestTimeout, cfg.RequestTimeout)) } }) } @@ -153,6 +200,32 @@ func NewWithDependencies(cfg Config, logger zerolog.Logger, dependencies Depende return router } +func readinessRoutes(dependencies Dependencies) http.Handler { + if dependencies.Readiness != nil { + return health.ReadinessRoutes(dependencies.Readiness) + } + return health.ReadyRoutes(dependencies.ReadinessChecker) +} + +// routeTimeout returns a middleware that raises the handler timeout for one +// route subtree. It is a no-op when the override is not longer than the global +// timeout, so the default configuration keeps exactly one timeout layer. +func routeTimeout(override, global time.Duration) func(http.Handler) http.Handler { + if override <= global { + return nil + } + return httpmiddleware.RouteTimeout(override) +} + +// principalKey buckets authenticated traffic by actor, falling back to the +// trusted client IP for unauthenticated requests that reach a limited subtree. +func principalKey(r *http.Request) string { + if principal, ok := authn.FromContext(r.Context()); ok && principal.ActorID != "" { + return "actor:" + principal.ActorID + } + return "ip:" + httpmiddleware.ClientIP(r) +} + func trustedMutationOrigins(allowedOrigins []string) func(http.Handler) http.Handler { allowed := make(map[string]struct{}, len(allowedOrigins)) for _, origin := range allowedOrigins { diff --git a/apps/api/internal/platform/httpserver/router_test.go b/apps/api/internal/platform/httpserver/router_test.go index 657b2c9a..ba3127e8 100644 --- a/apps/api/internal/platform/httpserver/router_test.go +++ b/apps/api/internal/platform/httpserver/router_test.go @@ -14,6 +14,7 @@ import ( "github.com/rs/zerolog" + "github.com/Mujhtech/mosaic/apps/api/internal/analytics" "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" @@ -181,8 +182,9 @@ func TestEmptyCORSOriginsDisableCrossOriginAccess(t *testing.T) { func TestMiddlewareOrder(t *testing.T) { want := []string{ "request_id", - "real_ip", + "trusted_proxy_real_ip", "otelchi", + "otelchi_metrics", "request_scoped_zerolog", "recovery", "security_headers", @@ -296,3 +298,85 @@ func newTestHandler() http.Handler { RequestTimeout: time.Second, }, zerolog.Nop()) } + +// exhaustedLimiter rejects everything, so a request that reaches it is proof the +// middleware is mounted on that route. +type exhaustedLimiter struct{ keys []string } + +func (limiter *exhaustedLimiter) Allow(key string) (bool, time.Duration) { + limiter.keys = append(limiter.keys, key) + return false, time.Second +} + +// Asset upload and the four export/privacy submissions each cost far more than a +// dashboard read -- a large body plus an object-storage write, or an enqueued job +// that scans analytics history. Before Phase 8 Stage 6 they shared the baseline +// per-principal API bucket. This pins both halves of that wiring: the expensive +// routes really are behind their own limiter, and the limiter does not leak onto +// the ordinary reads mounted beside them, which would 429 normal dashboard use. +func TestUploadAndExportLimitersCoverOnlyTheExpensiveRoutes(t *testing.T) { + service := cloudworkspace.NewService(cloudworkspacememory.New()) + actor := cloudworkspace.Actor{ID: "actor-owner"} + organization, err := service.CreateOrganization(context.Background(), actor, "Acme") + if err != nil { + t.Fatalf("create organization: %v", err) + } + project, err := service.CreateProject(context.Background(), actor, organization.ID, "ios-app", "iOS App") + if err != nil { + t.Fatalf("create project: %v", err) + } + uploadLimiter := &exhaustedLimiter{} + exportLimiter := &exhaustedLimiter{} + handler := NewWithDependencies(Config{ + ServiceName: "mosaic-api-test", + AllowedOrigins: []string{"http://localhost:3000"}, + RequestTimeout: time.Second, + }, zerolog.Nop(), Dependencies{ + CloudWorkspace: service, + HostedPublishing: hostedpublishing.NewService(nil), + Analytics: analytics.NewService(nil, nil), + PrincipalResolver: authn.ResolverFunc(func(*http.Request) (authn.Principal, error) { + return authn.Principal{ActorID: actor.ID, Method: "test"}, nil + }), + UploadLimiter: uploadLimiter, + ExportLimiter: exportLimiter, + }) + + base := "/v1/projects/" + project.ID + for _, limited := range []struct { + name string + path string + }{ + {name: "asset upload", path: base + "/assets"}, + {name: "analytics export", path: base + "/environments/env-1/analytics/exports"}, + {name: "experiment export", path: base + "/environments/env-1/experiments/experiment-1/exports"}, + {name: "privacy export", path: base + "/analytics/privacy/exports"}, + {name: "privacy deletion request", path: base + "/analytics/privacy/deletions"}, + } { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, limited.path, strings.NewReader("{}")) + request.Header.Set("Content-Type", "application/json") + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusTooManyRequests { + t.Fatalf("%s status = %d, want 429 from its own limiter; body=%s", limited.name, recorder.Code, recorder.Body.String()) + } + } + if len(uploadLimiter.keys) != 1 { + t.Fatalf("upload limiter saw %d requests, want exactly the upload route", len(uploadLimiter.keys)) + } + if len(exportLimiter.keys) != 4 { + t.Fatalf("export limiter saw %d requests, want the four export and privacy submissions", len(exportLimiter.keys)) + } + + // Reads mounted beside the limited routes must be unaffected. + for _, path := range []string{base + "/assets", base + "/environments/env-1/analytics/settings"} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + if recorder.Code == http.StatusTooManyRequests { + t.Fatalf("GET %s was rate limited by an upload/export bucket", path) + } + } + if len(uploadLimiter.keys) != 1 || len(exportLimiter.keys) != 4 { + t.Fatalf("a read consumed an upload/export token: upload=%d export=%d", len(uploadLimiter.keys), len(exportLimiter.keys)) + } +} diff --git a/apps/api/internal/platform/jobtelemetry/jobtelemetry.go b/apps/api/internal/platform/jobtelemetry/jobtelemetry.go new file mode 100644 index 00000000..5ea68bb3 --- /dev/null +++ b/apps/api/internal/platform/jobtelemetry/jobtelemetry.go @@ -0,0 +1,57 @@ +// Package jobtelemetry stamps the identity of a background job onto the log +// context the worker already carries. +// +// Worker log lines used to name only the job family and the worker id, so an +// operator reading "background job finished, failed=true" could not tell which +// job, which tenant, or which trace it belonged to — the three things every +// worker runbook step needs. Each job family calls Annotate once it knows what +// it leased; the worker's own completion line then carries the same fields +// because it reads the logger back out of the job context. +package jobtelemetry + +import ( + "context" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/trace" +) + +// Identity is the safe identity of one background job. Every field is an +// internal identifier: no personal data, no credentials, no payload content. +type Identity struct { + JobID string + JobKind string + ProjectID string + EnvironmentID string + // ResourceID is the domain object the job acts on (an Experiment, a + // provider connection) when the job family has one. + ResourceID string +} + +// Annotate adds the job identity, and the active trace id when a span is +// recording, to the logger carried by ctx. It is a no-op when the context has +// no Mosaic logger, so unit tests calling a service directly are unaffected. +func Annotate(ctx context.Context, identity Identity) { + logger := zerolog.Ctx(ctx) + if logger == nil || logger.GetLevel() == zerolog.Disabled { + return + } + logger.UpdateContext(func(logContext zerolog.Context) zerolog.Context { + logContext = appendField(logContext, "job_id", identity.JobID) + logContext = appendField(logContext, "job_kind", identity.JobKind) + logContext = appendField(logContext, "project_id", identity.ProjectID) + logContext = appendField(logContext, "environment_id", identity.EnvironmentID) + logContext = appendField(logContext, "resource_id", identity.ResourceID) + if span := trace.SpanContextFromContext(ctx); span.IsValid() { + logContext = appendField(logContext, "trace_id", span.TraceID().String()) + } + return logContext + }) +} + +func appendField(logContext zerolog.Context, key, value string) zerolog.Context { + if value == "" { + return logContext + } + return logContext.Str(key, value) +} diff --git a/apps/api/internal/platform/jobtelemetry/jobtelemetry_test.go b/apps/api/internal/platform/jobtelemetry/jobtelemetry_test.go new file mode 100644 index 00000000..f54445e1 --- /dev/null +++ b/apps/api/internal/platform/jobtelemetry/jobtelemetry_test.go @@ -0,0 +1,53 @@ +package jobtelemetry + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/rs/zerolog" +) + +// The worker writes its completion line through the logger it reads back out of +// the job context, because only the job family knows which job it leased. That +// read-back is the whole mechanism: if Annotate wrote to a copy instead of the +// logger stored in the context, worker logs would silently lose the job, +// tenant, and trace identifiers every runbook step depends on, and nothing else +// would fail. This test pins the contract end to end in the worker's own shape. +func TestAnnotateReachesTheLoggerTheWorkerReadsBack(t *testing.T) { + var output bytes.Buffer + logger := zerolog.New(&output).With().Str("job_family", "analytics").Logger() + jobContext := logger.WithContext(context.Background()) + + // Stands in for a job family: it annotates what it leased. + Annotate(jobContext, Identity{ + JobID: "analytics_job_000007", JobKind: "aggregation", + ProjectID: "project_000001", EnvironmentID: "env_000003", + }) + + // Stands in for the worker's completion line. + zerolog.Ctx(jobContext).Info().Msg("background job finished") + + var line map[string]any + if err := json.Unmarshal(output.Bytes(), &line); err != nil { + t.Fatalf("worker log line was not JSON: %v (%s)", err, output.String()) + } + for key, want := range map[string]string{ + "job_family": "analytics", + "job_id": "analytics_job_000007", + "job_kind": "aggregation", + "project_id": "project_000001", + "environment_id": "env_000003", + "message": "background job finished", + } { + if line[key] != want { + t.Fatalf("log field %q = %v, want %q (line: %s)", key, line[key], want, output.String()) + } + } + // A job family with no Environment must not emit an empty field that reads + // as "the environment is blank" in a log query. + if _, present := line["resource_id"]; present { + t.Fatalf("an unset identity field was emitted: %s", output.String()) + } +} diff --git a/apps/api/internal/platform/objectstoreminio/missing_object_test.go b/apps/api/internal/platform/objectstoreminio/missing_object_test.go new file mode 100644 index 00000000..63ba8366 --- /dev/null +++ b/apps/api/internal/platform/objectstoreminio/missing_object_test.go @@ -0,0 +1,43 @@ +package objectstoreminio + +import ( + "errors" + "fmt" + "testing" + + "github.com/minio/minio-go/v7" +) + +// An Asset row whose bytes are absent from the bucket -- what a failed or +// partial object-storage restore leaves behind -- used to reach the SDK as +// `500 internal_error`, indistinguishable from "Mosaic is broken", so a client +// could not fall back to its bundled Asset. Classifying the S3 error is what +// lets the delivery path answer a safe 404 instead, so the classification is +// pinned here rather than left to a string match at the call site. +func TestMissingObjectIsDistinguishedFromStorageFailure(t *testing.T) { + for name, testCase := range map[string]struct { + err error + want bool + }{ + "absent key": {minio.ErrorResponse{Code: "NoSuchKey"}, true}, + "absent bucket": {minio.ErrorResponse{Code: "NoSuchBucket"}, true}, + "wrapped absent key": {fmt.Errorf("stat object: %w", minio.ErrorResponse{Code: "NoSuchKey"}), true}, + "access denied": {minio.ErrorResponse{Code: "AccessDenied"}, false}, + "internal storage fault": {minio.ErrorResponse{Code: "InternalError"}, false}, + "transport failure": {errors.New("dial tcp 10.0.0.2:9000: connect: connection refused"), false}, + } { + t.Run(name, func(t *testing.T) { + if got := missingKey(testCase.err); got != testCase.want { + t.Fatalf("missingKey(%v) = %v, want %v", testCase.err, got, testCase.want) + } + }) + } + + // The wrapper must survive errors.As so callers can classify without + // importing this package. + wrapped := &objectNotFound{err: fmt.Errorf("stat object: %w", minio.ErrorResponse{Code: "NoSuchKey"})} + var missing interface{ ObjectNotFound() bool } + if !errors.As(error(wrapped), &missing) || !missing.ObjectNotFound() { + t.Fatal("a missing-object error is not classifiable through the ObjectNotFound contract") + } +} diff --git a/apps/api/internal/platform/objectstoreminio/store.go b/apps/api/internal/platform/objectstoreminio/store.go index e13e1c8c..adec1f4a 100644 --- a/apps/api/internal/platform/objectstoreminio/store.go +++ b/apps/api/internal/platform/objectstoreminio/store.go @@ -2,28 +2,48 @@ package objectstoreminio import ( "context" + "errors" "fmt" "io" + "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "github.com/Mujhtech/mosaic/apps/api/internal/hostedpublishing" ) +const ( + defaultOperationTimeout = 30 * time.Second + defaultCheckTimeout = 5 * time.Second +) + type Config struct { Endpoint string AccessKey string SecretKey string Bucket string UseTLS bool + // OperationTimeout bounds put and delete so a stalled object store cannot + // hold a request handler or worker job open indefinitely. + OperationTimeout time.Duration + // CheckTimeout bounds the readiness probe. + CheckTimeout time.Duration } type Store struct { - client *minio.Client - bucket string + client *minio.Client + bucket string + operationTimeout time.Duration + checkTimeout time.Duration } +var tracer = otel.Tracer("mosaic/objectstore") + func New(cfg Config) (*Store, error) { client, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), @@ -32,45 +52,124 @@ func New(cfg Config) (*Store, error) { if err != nil { return nil, fmt.Errorf("configure S3-compatible object store: %w", err) } - return &Store{client: client, bucket: cfg.Bucket}, nil + store := &Store{ + client: client, + bucket: cfg.Bucket, + operationTimeout: cfg.OperationTimeout, + checkTimeout: cfg.CheckTimeout, + } + if store.operationTimeout <= 0 { + store.operationTimeout = defaultOperationTimeout + } + if store.checkTimeout <= 0 { + store.checkTimeout = defaultCheckTimeout + } + return store, nil +} + +// span opens an operation span. The bucket name is deployment configuration, +// never a credential, so it is safe as a span attribute. Object keys are +// digest-addressed and are recorded as a length only to avoid unbounded +// cardinality. +func (s *Store) span(ctx context.Context, operation string) (context.Context, trace.Span) { + return tracer.Start(ctx, "objectstore."+operation, trace.WithAttributes( + attribute.String("objectstore.operation", operation), + attribute.String("objectstore.bucket", s.bucket), + )) +} + +func finish(span trace.Span, err error) error { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "object storage operation failed") + } + span.End() + return err } func (s *Store) Check(ctx context.Context) error { + ctx, span := s.span(ctx, "check") + ctx, cancel := context.WithTimeout(ctx, s.checkTimeout) + defer cancel() exists, err := s.client.BucketExists(ctx, s.bucket) if err != nil { - return fmt.Errorf("check object-storage bucket: %w", err) + return finish(span, fmt.Errorf("check object-storage bucket: %w", err)) } if !exists { - return fmt.Errorf("object-storage bucket %q does not exist", s.bucket) + return finish(span, fmt.Errorf("object-storage bucket %q does not exist", s.bucket)) } - return nil + return finish(span, nil) } func (s *Store) Put(ctx context.Context, key string, reader io.Reader, size int64, mediaType string) error { + ctx, span := s.span(ctx, "put") + span.SetAttributes(attribute.Int64("objectstore.size_bytes", size)) + ctx, cancel := context.WithTimeout(ctx, s.operationTimeout) + defer cancel() _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ContentType: mediaType}) if err != nil { - return fmt.Errorf("put object: %w", err) + return finish(span, fmt.Errorf("put object: %w", err)) + } + return finish(span, nil) +} + +// Open returns a reader for an object. The span covers opening and validating +// the handle; the returned stream stays bound to the caller's context because +// the caller, not this package, owns how long the body is streamed for. +// objectNotFound marks an object-store failure that means "this key is not +// there", as distinct from "the object store is failing". Callers classify it +// through the ObjectNotFound() method rather than importing this package, so a +// missing Asset can be answered 404 instead of 500. +type objectNotFound struct{ err error } + +func (e *objectNotFound) Error() string { return e.err.Error() } +func (e *objectNotFound) Unwrap() error { return e.err } +func (e *objectNotFound) ObjectNotFound() bool { return true } + +// missingKey reports whether an S3 error means the key or bucket is absent. +// It unwraps, so a caller may classify an error it has already annotated. +func missingKey(err error) bool { + var response minio.ErrorResponse + if !errors.As(err, &response) { + response = minio.ToErrorResponse(err) + } + switch response.Code { + case "NoSuchKey", "NoSuchBucket": + return true } - return nil + return false } func (s *Store) Open(ctx context.Context, key string) (io.ReadCloser, error) { + _, span := s.span(ctx, "open") object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) if err != nil { - return nil, fmt.Errorf("open object: %w", err) + wrapped := fmt.Errorf("open object: %w", err) + if missingKey(err) { + return nil, finish(span, &objectNotFound{err: wrapped}) + } + return nil, finish(span, wrapped) } + // GetObject is lazy: a missing key only surfaces on Stat. if _, err := object.Stat(); err != nil { _ = object.Close() - return nil, fmt.Errorf("stat object: %w", err) + wrapped := fmt.Errorf("stat object: %w", err) + if missingKey(err) { + return nil, finish(span, &objectNotFound{err: wrapped}) + } + return nil, finish(span, wrapped) } - return object, nil + return object, finish(span, nil) } func (s *Store) Delete(ctx context.Context, key string) error { + ctx, span := s.span(ctx, "delete") + ctx, cancel := context.WithTimeout(ctx, s.operationTimeout) + defer cancel() if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil { - return fmt.Errorf("delete object: %w", err) + return finish(span, fmt.Errorf("delete object: %w", err)) } - return nil + return finish(span, nil) } var _ hostedpublishing.ObjectStore = (*Store)(nil) diff --git a/apps/api/internal/platform/placementdecisionpostgres/repository_integration_test.go b/apps/api/internal/platform/placementdecisionpostgres/repository_integration_test.go index b988ba73..41024703 100644 --- a/apps/api/internal/platform/placementdecisionpostgres/repository_integration_test.go +++ b/apps/api/internal/platform/placementdecisionpostgres/repository_integration_test.go @@ -32,8 +32,12 @@ func TestArchiveRuleSetPreservesVersionsAndClearsActiveUsage(t *testing.T) { if err := goose.SetDialect("postgres"); err != nil { t.Fatal(err) } - if err := goose.DownToContext(ctx, db, ".", 0); err != nil { - t.Fatalf("reset migrations: %v", err) + // Reset by dropping the schema rather than rolling migrations down: since + // Phase 8, irreversible down migrations correctly refuse when affected data + // exists, so a rollback is not a usable test reset. DATABASE_TEST_URL is + // documented as a throwaway database. + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must be a throwaway database): %v", err) } if err := goose.UpContext(ctx, db, "."); err != nil { t.Fatalf("apply migrations: %v", err) @@ -104,8 +108,12 @@ func TestRevokeOverrideIsTenantScoped(t *testing.T) { if err := goose.SetDialect("postgres"); err != nil { t.Fatal(err) } - if err := goose.DownToContext(ctx, db, ".", 0); err != nil { - t.Fatalf("reset migrations: %v", err) + // Reset by dropping the schema rather than rolling migrations down: since + // Phase 8, irreversible down migrations correctly refuse when affected data + // exists, so a rollback is not a usable test reset. DATABASE_TEST_URL is + // documented as a throwaway database. + if _, err := db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil { + t.Fatalf("reset the test schema (DATABASE_TEST_URL must be a throwaway database): %v", err) } if err := goose.UpContext(ctx, db, "."); err != nil { t.Fatalf("apply migrations: %v", err) diff --git a/apps/api/internal/platform/protocolschema/internal/sync/main.go b/apps/api/internal/platform/protocolschema/internal/sync/main.go new file mode 100644 index 00000000..64f3cce5 --- /dev/null +++ b/apps/api/internal/platform/protocolschema/internal/sync/main.go @@ -0,0 +1,50 @@ +// Command sync refreshes the embedded protocol schema copies from the +// canonical files under protocol/schema/**. +// +// Run it from the protocolschema package directory: +// +// go generate ./internal/platform/protocolschema +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// copies maps the embedded file name to its canonical repository-relative path. +var copies = map[string]string{ + "paywall-v0.2.schema.json": "protocol/schema/v0.2/paywall.schema.json", + "commerce-provider-v1.schema.json": "protocol/schema/commerce-provider/v1/contract.schema.json", + "commerce-provider-v2.schema.json": "protocol/schema/commerce-provider/v2/contract.schema.json", + "commerce-configuration-v1.schema.json": "protocol/schema/commerce-configuration/v1/configuration.schema.json", + "commerce-configuration-v2.schema.json": "protocol/schema/commerce-configuration/v2/configuration.schema.json", + "analytics-event-v1.schema.json": "protocol/schema/analytics-event/v1/event.schema.json", + "analytics-event-v2.schema.json": "protocol/schema/analytics-event/v2/event.schema.json", +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "sync protocol schemas: %v\n", err) + os.Exit(1) + } +} + +func run() error { + working, err := os.Getwd() + if err != nil { + return err + } + // working is apps/api/internal/platform/protocolschema during go:generate. + repositoryRoot := filepath.Clean(filepath.Join(working, "../../../../../")) + for name, canonical := range copies { + document, err := os.ReadFile(filepath.Join(repositoryRoot, canonical)) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(working, "schemas", name), document, 0o644); err != nil { + return err + } + } + return nil +} diff --git a/apps/api/internal/platform/protocolschema/protocolschema.go b/apps/api/internal/platform/protocolschema/protocolschema.go new file mode 100644 index 00000000..394d2a3f --- /dev/null +++ b/apps/api/internal/platform/protocolschema/protocolschema.go @@ -0,0 +1,106 @@ +// Package protocolschema serves the canonical Mosaic protocol JSON Schemas the +// API runtime compiles at startup. +// +// The schemas are embedded into the binary so a released image can never be +// packaged without them (the release-artifact drift class fixed in Phase 8). +// Operators may still point an individual schema at a file on disk with the +// documented environment variables; an explicit override always wins over the +// embedded copy. +// +// The embedded copies under schemas/ are byte-for-byte duplicates of the +// canonical files under protocol/schema/**. go:embed cannot reach outside the +// module directory, so the copies are refreshed with: +// +// go generate ./internal/platform/protocolschema +// +// TestEmbeddedSchemasMatchCanonicalProtocolFiles fails loudly when they drift. +package protocolschema + +import ( + "bytes" + "embed" + "fmt" + "io" + "os" + "sort" +) + +//go:generate go run ./internal/sync + +//go:embed schemas/*.schema.json +var files embed.FS + +// Schema identifies one canonical protocol schema the API runtime loads. +type Schema string + +const ( + PaywallV02 Schema = "paywall/v0.2" + CommerceProviderV1 Schema = "commerce-provider/v1" + CommerceProviderV2 Schema = "commerce-provider/v2" + CommerceConfigurationV1 Schema = "commerce-configuration/v1" + CommerceConfigurationV2 Schema = "commerce-configuration/v2" + AnalyticsEventV1 Schema = "analytics-event/v1" + AnalyticsEventV2 Schema = "analytics-event/v2" +) + +type location struct { + embedded string + canonical string +} + +var locations = map[Schema]location{ + PaywallV02: {"schemas/paywall-v0.2.schema.json", "protocol/schema/v0.2/paywall.schema.json"}, + CommerceProviderV1: {"schemas/commerce-provider-v1.schema.json", "protocol/schema/commerce-provider/v1/contract.schema.json"}, + CommerceProviderV2: {"schemas/commerce-provider-v2.schema.json", "protocol/schema/commerce-provider/v2/contract.schema.json"}, + CommerceConfigurationV1: {"schemas/commerce-configuration-v1.schema.json", "protocol/schema/commerce-configuration/v1/configuration.schema.json"}, + CommerceConfigurationV2: {"schemas/commerce-configuration-v2.schema.json", "protocol/schema/commerce-configuration/v2/configuration.schema.json"}, + AnalyticsEventV1: {"schemas/analytics-event-v1.schema.json", "protocol/schema/analytics-event/v1/event.schema.json"}, + AnalyticsEventV2: {"schemas/analytics-event-v2.schema.json", "protocol/schema/analytics-event/v2/event.schema.json"}, +} + +// Schemas lists every embedded schema in a stable order. +func Schemas() []Schema { + names := make([]Schema, 0, len(locations)) + for name := range locations { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { return names[i] < names[j] }) + return names +} + +// CanonicalPath is the repository-relative path of the canonical source file. +func CanonicalPath(schema Schema) (string, bool) { + where, ok := locations[schema] + return where.canonical, ok +} + +// Bytes returns the embedded document for a schema. +func Bytes(schema Schema) ([]byte, error) { + where, ok := locations[schema] + if !ok { + return nil, fmt.Errorf("unknown protocol schema %q", schema) + } + document, err := files.ReadFile(where.embedded) + if err != nil { + return nil, fmt.Errorf("read embedded %s schema: %w", schema, err) + } + return document, nil +} + +// Open returns a reader for a schema. A non-empty overridePath is read from +// disk instead of the embedded copy so operators and tests can pin a schema +// file; an unreadable override is an error rather than a silent fallback. +func Open(schema Schema, overridePath string) (io.ReadCloser, error) { + if overridePath != "" { + file, err := os.Open(overridePath) + if err != nil { + return nil, fmt.Errorf("open %s schema override %s: %w", schema, overridePath, err) + } + return file, nil + } + document, err := Bytes(schema) + if err != nil { + return nil, err + } + return io.NopCloser(bytes.NewReader(document)), nil +} diff --git a/apps/api/internal/platform/protocolschema/protocolschema_test.go b/apps/api/internal/platform/protocolschema/protocolschema_test.go new file mode 100644 index 00000000..e59c5464 --- /dev/null +++ b/apps/api/internal/platform/protocolschema/protocolschema_test.go @@ -0,0 +1,65 @@ +package protocolschema + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" +) + +func repositoryRoot(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "../../../../../")) +} + +// The API validates SDK traffic against these schemas. If an embedded copy +// drifts from the canonical protocol file, the runtime silently enforces a +// different contract than the published one (blocker category 17). This test +// makes that drift a build failure instead. +func TestEmbeddedSchemasMatchCanonicalProtocolFiles(t *testing.T) { + root := repositoryRoot(t) + for _, schema := range Schemas() { + canonical, ok := CanonicalPath(schema) + if !ok { + t.Fatalf("schema %q has no canonical path", schema) + } + t.Run(string(schema), func(t *testing.T) { + want, err := os.ReadFile(filepath.Join(root, canonical)) + if err != nil { + t.Fatal(err) + } + got, err := Bytes(schema) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("embedded %s differs from %s; run go generate ./internal/platform/protocolschema", schema, canonical) + } + }) + } +} + +func TestOpenPrefersExplicitOverride(t *testing.T) { + path := filepath.Join(t.TempDir(), "override.json") + if err := os.WriteFile(path, []byte(`{"$id":"override"}`), 0o600); err != nil { + t.Fatal(err) + } + reader, err := Open(PaywallV02, path) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + document := make([]byte, 64) + n, _ := reader.Read(document) + if string(document[:n]) != `{"$id":"override"}` { + t.Fatalf("override was not used: %q", document[:n]) + } + if _, err := Open(PaywallV02, filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Fatal("a missing override must fail rather than silently fall back to the embedded schema") + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/analytics-event-v1.schema.json b/apps/api/internal/platform/protocolschema/schemas/analytics-event-v1.schema.json new file mode 100644 index 00000000..ed0d56cb --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/analytics-event-v1.schema.json @@ -0,0 +1,2041 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:analytics-event:v1:event", + "title": "Mosaic Analytics Event Contract v1 event", + "type": "object", + "additionalProperties": false, + "required": [ + "eventId", + "eventSchemaVersion", + "eventName", + "occurredAt", + "queuedAt", + "authority", + "correlation", + "attribution", + "payload" + ], + "properties": { + "eventId": { + "$ref": "#/$defs/identifier" + }, + "eventSchemaVersion": { + "const": "1" + }, + "eventName": { + "$ref": "#/$defs/eventName" + }, + "occurredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "queuedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "authority": { + "$ref": "#/$defs/authority" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "sessionId": { + "$ref": "#/$defs/identifier" + }, + "context": { + "$ref": "#/$defs/context" + }, + "correlation": { + "$ref": "#/$defs/correlation" + }, + "attribution": { + "$ref": "#/$defs/attribution" + }, + "payload": { + "type": "object" + } + }, + "oneOf": [ + { + "$ref": "#/$defs/placementRequestedEvent" + }, + { + "$ref": "#/$defs/placementPaywallSelectedEvent" + }, + { + "$ref": "#/$defs/placementNoPaywallEvent" + }, + { + "$ref": "#/$defs/placementFallbackUsedEvent" + }, + { + "$ref": "#/$defs/placementUnavailableEvent" + }, + { + "$ref": "#/$defs/placementEvaluationFailedEvent" + }, + { + "$ref": "#/$defs/paywallPresentedEvent" + }, + { + "$ref": "#/$defs/paywallDismissedEvent" + }, + { + "$ref": "#/$defs/paywallActionSelectedEvent" + }, + { + "$ref": "#/$defs/paywallRenderFailedEvent" + }, + { + "$ref": "#/$defs/productLoadStartedEvent" + }, + { + "$ref": "#/$defs/productLoadCompletedEvent" + }, + { + "$ref": "#/$defs/productLoadFailedEvent" + }, + { + "$ref": "#/$defs/productUnavailableEvent" + }, + { + "$ref": "#/$defs/productSelectedEvent" + }, + { + "$ref": "#/$defs/purchaseStartedEvent" + }, + { + "$ref": "#/$defs/purchaseCompletedClientEvent" + }, + { + "$ref": "#/$defs/purchaseCompletedProviderEvent" + }, + { + "$ref": "#/$defs/purchasePendingEvent" + }, + { + "$ref": "#/$defs/purchaseDeferredEvent" + }, + { + "$ref": "#/$defs/purchaseCancelledEvent" + }, + { + "$ref": "#/$defs/purchaseFailedEvent" + }, + { + "$ref": "#/$defs/restoreStartedEvent" + }, + { + "$ref": "#/$defs/restoreCompletedEvent" + }, + { + "$ref": "#/$defs/restoreNothingFoundEvent" + }, + { + "$ref": "#/$defs/restoreCancelledEvent" + }, + { + "$ref": "#/$defs/restoreFailedEvent" + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "utcTimestamp": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}Z$" + }, + "safeCode": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "durationMs": { + "type": "integer", + "minimum": 0, + "maximum": 86400000 + }, + "boundedIdentifiers": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "eventName": { + "enum": [ + "placement_requested", + "placement_paywall_selected", + "placement_no_paywall", + "placement_fallback_used", + "placement_unavailable", + "placement_evaluation_failed", + "paywall_presented", + "paywall_dismissed", + "paywall_action_selected", + "paywall_render_failed", + "product_load_started", + "product_load_completed", + "product_load_failed", + "product_unavailable", + "product_selected", + "purchase_started", + "purchase_completed_client", + "purchase_completed_provider", + "purchase_pending", + "purchase_deferred", + "purchase_cancelled", + "purchase_failed", + "restore_started", + "restore_completed", + "restore_nothing_found", + "restore_cancelled", + "restore_failed" + ] + }, + "authority": { + "enum": [ + "client_observed", + "trusted_server", + "provider_confirmed" + ] + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "installationId", + "generation" + ], + "properties": { + "installationId": { + "$ref": "#/$defs/identifier" + }, + "applicationUserId": { + "description": "Host-defined opaque identity: 1–256 non-control characters, not Mosaic resource-ID syntax. Sensitive-shaped values are rejected by ingestion policy.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]+$" + }, + "generation": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "sdkFamily", + "sdkVersion" + ], + "properties": { + "platform": { + "enum": [ + "ios", + "android" + ] + }, + "sdkFamily": { + "enum": [ + "flutter", + "ios", + "android" + ] + }, + "sdkVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_-]*$" + }, + "operatingSystemVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_ -]*$" + }, + "applicationVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_-]*$" + }, + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 35, + "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" + }, + "configurationDeliveryVersion": { + "enum": [ + "1", + "2" + ] + }, + "commerceProviderContractVersion": { + "enum": [ + "1", + "2" + ] + } + } + }, + "correlation": { + "type": "object", + "additionalProperties": false, + "properties": { + "placementRequestId": { + "$ref": "#/$defs/identifier" + }, + "paywallPresentationId": { + "$ref": "#/$defs/identifier" + }, + "productLoadAttemptId": { + "$ref": "#/$defs/identifier" + }, + "purchaseAttemptId": { + "$ref": "#/$defs/identifier" + }, + "restoreAttemptId": { + "$ref": "#/$defs/identifier" + }, + "providerOperationId": { + "$ref": "#/$defs/identifier" + }, + "providerUpdateId": { + "$ref": "#/$defs/identifier" + } + } + }, + "attribution": { + "type": "object", + "additionalProperties": false, + "properties": { + "configurationReleaseId": { + "$ref": "#/$defs/identifier" + }, + "placementId": { + "$ref": "#/$defs/identifier" + }, + "placementRuleSetId": { + "$ref": "#/$defs/identifier" + }, + "placementRuleSetVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "winningRuleId": { + "$ref": "#/$defs/identifier" + }, + "paywallId": { + "$ref": "#/$defs/identifier" + }, + "paywallVersionId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "planId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "providerProductMappingId": { + "$ref": "#/$defs/identifier" + } + }, + "dependentRequired": { + "placementRuleSetId": [ + "placementRuleSetVersion" + ], + "placementRuleSetVersion": [ + "placementRuleSetId" + ], + "winningRuleId": [ + "placementRuleSetId", + "placementRuleSetVersion" + ] + } + }, + "clientEventBase": { + "type": "object", + "required": [ + "identity", + "sessionId", + "context" + ], + "properties": { + "authority": { + "const": "client_observed" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "sessionId": { + "$ref": "#/$defs/identifier" + }, + "context": { + "$ref": "#/$defs/context" + } + } + }, + "placementCorrelation": { + "type": "object", + "required": [ + "placementRequestId" + ], + "properties": { + "placementRequestId": { + "$ref": "#/$defs/identifier" + } + } + }, + "placementAttribution": { + "type": "object", + "required": [ + "placementId" + ], + "properties": { + "placementId": { + "$ref": "#/$defs/identifier" + } + } + }, + "paywallAttribution": { + "type": "object", + "required": [ + "paywallId", + "paywallVersionId" + ], + "properties": { + "paywallId": { + "$ref": "#/$defs/identifier" + }, + "paywallVersionId": { + "$ref": "#/$defs/identifier" + } + } + }, + "productAttribution": { + "type": "object", + "required": [ + "mosaicProductId" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + } + } + }, + "purchaseAttribution": { + "type": "object", + "required": [ + "mosaicProductId", + "providerId" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + } + } + }, + "diagnosticFailurePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "diagnosticCode", + "retryable" + ], + "properties": { + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "placementRequestedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "decisionContractVersion" + ], + "properties": { + "decisionContractVersion": { + "const": "1" + } + } + }, + "placementSelectionPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "finalOutcome", + "decisionContractVersion" + ], + "properties": { + "finalOutcome": { + "enum": [ + "paywall", + "no_paywall" + ] + }, + "decisionContractVersion": { + "const": "1" + }, + "assignmentKeyType": { + "enum": [ + "installation", + "identified_user" + ] + }, + "bucketingAlgorithm": { + "const": "sha256_length_prefixed_v1" + }, + "rolloutBucket": { + "type": "integer", + "minimum": 0, + "maximum": 9999 + } + }, + "dependentRequired": { + "assignmentKeyType": [ + "bucketingAlgorithm", + "rolloutBucket" + ], + "bucketingAlgorithm": [ + "assignmentKeyType", + "rolloutBucket" + ], + "rolloutBucket": [ + "assignmentKeyType", + "bucketingAlgorithm" + ] + } + }, + "placementFallbackPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "trigger", + "fallbackKey", + "finalOutcome" + ], + "properties": { + "trigger": { + "enum": [ + "configuration_incompatible", + "content_unavailable", + "commerce_unavailable", + "product_unavailable", + "product_unknown", + "provider_unavailable", + "entitlement_unknown", + "unsafe_rendering" + ] + }, + "fallbackKey": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "finalOutcome": { + "enum": [ + "paywall", + "no_paywall", + "unavailable" + ] + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "placementUnavailablePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "no_safe_decision", + "configuration_incompatible", + "content_unavailable", + "commerce_unavailable" + ] + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "emptyPayload": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "dismissalPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "user", + "system", + "purchase_completed", + "host_application", + "unknown" + ] + } + } + }, + "paywallActionPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "action" + ], + "properties": { + "action": { + "enum": [ + "purchase", + "restore", + "close", + "navigate_to", + "navigate_back", + "open_external_url" + ] + }, + "componentId": { + "$ref": "#/$defs/identifier" + } + } + }, + "productLoadStartedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestedProductCount" + ], + "properties": { + "requestedProductCount": { + "type": "integer", + "minimum": 1, + "maximum": 64 + } + } + }, + "productLoadCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "availableProductCount", + "unavailableProductCount", + "durationMs" + ], + "properties": { + "availableProductCount": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "unavailableProductCount": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + } + } + }, + "productLoadFailedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestedProductCount", + "durationMs", + "diagnosticCode", + "retryable" + ], + "properties": { + "requestedProductCount": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "productUnavailablePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "mapping_missing", + "mapping_invalid", + "product_not_found", + "temporarily_unavailable", + "provider_unavailable", + "unsupported_product_type", + "metadata_unavailable" + ] + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "productSelectedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "source" + ], + "properties": { + "source": { + "enum": [ + "default", + "user" + ] + } + } + }, + "purchaseClientCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "outcome", + "durationMs", + "observedEntitlementKeys" + ], + "properties": { + "outcome": { + "enum": [ + "purchased", + "already_entitled" + ] + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "observedEntitlementKeys": { + "$ref": "#/$defs/boundedIdentifiers" + }, + "providerResultCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "purchaseProviderCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "confirmationSource", + "activeEntitlementKeys" + ], + "properties": { + "confirmationSource": { + "enum": [ + "trusted_provider_integration", + "trusted_server_endpoint", + "accepted_adapter_source" + ] + }, + "activeEntitlementKeys": { + "$ref": "#/$defs/boundedIdentifiers" + }, + "linkedClientEventId": { + "$ref": "#/$defs/identifier" + } + } + }, + "purchaseLifecyclePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "durationMs" + ], + "properties": { + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "providerResultCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "purchaseFailedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "durationMs", + "diagnosticCode", + "retryable" + ], + "properties": { + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "restoreStartedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + } + } + }, + "restoreCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "durationMs", + "restoredProductIds", + "observedEntitlementKeys" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "restoredProductIds": { + "$ref": "#/$defs/boundedIdentifiers" + }, + "observedEntitlementKeys": { + "$ref": "#/$defs/boundedIdentifiers" + } + } + }, + "restoreLifecyclePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "durationMs" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "providerResultCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "restoreFailedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "durationMs", + "diagnosticCode", + "retryable" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "placementRequestedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_requested" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopePlacementRequest" + } + ] + }, + "payload": { + "$ref": "#/$defs/placementRequestedPayload" + } + } + } + ] + }, + "placementPaywallSelectedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_paywall_selected" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywall" + } + ] + }, + "payload": { + "allOf": [ + { + "$ref": "#/$defs/placementSelectionPayload" + }, + { + "properties": { + "finalOutcome": { + "const": "paywall" + } + } + } + ] + } + } + } + ] + }, + "placementNoPaywallEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_no_paywall" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacement" + } + ] + }, + "payload": { + "allOf": [ + { + "$ref": "#/$defs/placementSelectionPayload" + }, + { + "properties": { + "finalOutcome": { + "const": "no_paywall" + } + } + } + ] + } + } + } + ] + }, + "placementFallbackUsedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_fallback_used" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywall" + } + ] + }, + "payload": { + "$ref": "#/$defs/placementFallbackPayload" + } + } + } + ] + }, + "placementUnavailableEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_unavailable" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacement" + } + ] + }, + "payload": { + "$ref": "#/$defs/placementUnavailablePayload" + } + } + } + ] + }, + "placementEvaluationFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_evaluation_failed" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopePlacementRequest" + } + ] + }, + "payload": { + "$ref": "#/$defs/diagnosticFailurePayload" + } + } + } + ] + }, + "paywallPresentedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_presented" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywall" + } + ] + }, + "payload": { + "$ref": "#/$defs/emptyPayload" + } + } + } + ] + }, + "paywallDismissedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_dismissed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "payload": { + "$ref": "#/$defs/dismissalPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "paywallActionSelectedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_action_selected" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "payload": { + "$ref": "#/$defs/paywallActionPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "paywallRenderFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_render_failed" + }, + "payload": { + "$ref": "#/$defs/diagnosticFailurePayload" + }, + "correlation": { + "$ref": "#/$defs/correlationScopePaywallPresentation" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productLoadStartedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_load_started" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId", + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "payload": { + "$ref": "#/$defs/productLoadStartedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productLoadCompletedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_load_completed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "payload": { + "$ref": "#/$defs/productLoadCompletedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productLoadFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_load_failed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "payload": { + "$ref": "#/$defs/productLoadFailedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productUnavailableEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_unavailable" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/productAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/productUnavailablePayload" + } + } + } + ] + }, + "productSelectedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_selected" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/productAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/productSelectedPayload" + } + } + } + ] + }, + "purchaseStartedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_started" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/emptyPayload" + } + } + } + ] + }, + "purchaseCompletedClientEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_completed_client" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseClientCompletedPayload" + } + } + } + ] + }, + "purchaseCompletedProviderEvent": { + "type": "object", + "properties": { + "eventName": { + "const": "purchase_completed_provider" + }, + "authority": { + "enum": [ + "trusted_server", + "provider_confirmed" + ] + }, + "correlation": { + "allOf": [ + { + "type": "object", + "anyOf": [ + { + "required": [ + "purchaseAttemptId" + ] + }, + { + "required": [ + "providerOperationId" + ] + }, + { + "required": [ + "providerUpdateId" + ] + } + ] + }, + { + "$ref": "#/$defs/correlationScopeProviderConfirmation" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseProviderCompletedPayload" + } + } + }, + "purchasePendingEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_pending" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseLifecyclePayload" + } + } + } + ] + }, + "purchaseDeferredEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_deferred" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseLifecyclePayload" + } + } + } + ] + }, + "purchaseCancelledEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_cancelled" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseLifecyclePayload" + } + } + } + ] + }, + "purchaseFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_failed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseFailedPayload" + } + } + } + ] + }, + "restoreStartedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_started" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreStartedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreCompletedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_completed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreCompletedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreNothingFoundEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_nothing_found" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreLifecyclePayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreCancelledEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_cancelled" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreLifecyclePayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_failed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreFailedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "attributionScopeConfigurationRelease": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true + }, + "unevaluatedProperties": false + }, + "attributionScopeDecidedPlacement": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopePaywall": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "paywallId": true, + "paywallVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopePlacementRequest": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true + }, + "unevaluatedProperties": false + }, + "attributionScopeProduct": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "mosaicProductId": true, + "paywallId": true, + "paywallVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "planId": true, + "providerId": true, + "providerProductMappingId": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "correlationScopePaywallPresentation": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "paywallPresentationId": true, + "placementRequestId": true + }, + "unevaluatedProperties": false + }, + "correlationScopePlacementRequest": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "placementRequestId": true + }, + "unevaluatedProperties": false + }, + "correlationScopeProductLoad": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "paywallPresentationId": true, + "placementRequestId": true, + "productLoadAttemptId": true + }, + "unevaluatedProperties": false + }, + "correlationScopeProviderConfirmation": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "providerOperationId": true, + "providerUpdateId": true, + "purchaseAttemptId": true + }, + "unevaluatedProperties": false + }, + "correlationScopePurchaseAttempt": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "paywallPresentationId": true, + "placementRequestId": true, + "productLoadAttemptId": true, + "providerOperationId": true, + "purchaseAttemptId": true + }, + "unevaluatedProperties": false + }, + "correlationScopeRestoreAttempt": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "providerOperationId": true, + "restoreAttemptId": true + }, + "unevaluatedProperties": false + } + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/analytics-event-v2.schema.json b/apps/api/internal/platform/protocolschema/schemas/analytics-event-v2.schema.json new file mode 100644 index 00000000..2206ce35 --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/analytics-event-v2.schema.json @@ -0,0 +1,2431 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:analytics-event:v2:event", + "title": "Mosaic Analytics Event Contract v2 event", + "type": "object", + "additionalProperties": false, + "required": [ + "eventId", + "eventSchemaVersion", + "eventName", + "occurredAt", + "queuedAt", + "authority", + "correlation", + "attribution", + "payload" + ], + "properties": { + "eventId": { + "$ref": "#/$defs/identifier" + }, + "eventSchemaVersion": { + "const": "2" + }, + "eventName": { + "$ref": "#/$defs/eventName" + }, + "occurredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "queuedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "authority": { + "$ref": "#/$defs/authority" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "sessionId": { + "$ref": "#/$defs/identifier" + }, + "context": { + "$ref": "#/$defs/context" + }, + "correlation": { + "$ref": "#/$defs/correlation" + }, + "attribution": { + "$ref": "#/$defs/attribution" + }, + "payload": { + "type": "object" + } + }, + "oneOf": [ + { + "$ref": "#/$defs/placementRequestedEvent" + }, + { + "$ref": "#/$defs/placementPaywallSelectedEvent" + }, + { + "$ref": "#/$defs/placementNoPaywallEvent" + }, + { + "$ref": "#/$defs/placementFallbackUsedEvent" + }, + { + "$ref": "#/$defs/placementUnavailableEvent" + }, + { + "$ref": "#/$defs/placementEvaluationFailedEvent" + }, + { + "$ref": "#/$defs/paywallPresentedEvent" + }, + { + "$ref": "#/$defs/paywallDismissedEvent" + }, + { + "$ref": "#/$defs/paywallActionSelectedEvent" + }, + { + "$ref": "#/$defs/paywallRenderFailedEvent" + }, + { + "$ref": "#/$defs/productLoadStartedEvent" + }, + { + "$ref": "#/$defs/productLoadCompletedEvent" + }, + { + "$ref": "#/$defs/productLoadFailedEvent" + }, + { + "$ref": "#/$defs/productUnavailableEvent" + }, + { + "$ref": "#/$defs/productSelectedEvent" + }, + { + "$ref": "#/$defs/purchaseStartedEvent" + }, + { + "$ref": "#/$defs/purchaseCompletedClientEvent" + }, + { + "$ref": "#/$defs/purchaseCompletedProviderEvent" + }, + { + "$ref": "#/$defs/purchasePendingEvent" + }, + { + "$ref": "#/$defs/purchaseDeferredEvent" + }, + { + "$ref": "#/$defs/purchaseCancelledEvent" + }, + { + "$ref": "#/$defs/purchaseFailedEvent" + }, + { + "$ref": "#/$defs/restoreStartedEvent" + }, + { + "$ref": "#/$defs/restoreCompletedEvent" + }, + { + "$ref": "#/$defs/restoreNothingFoundEvent" + }, + { + "$ref": "#/$defs/restoreCancelledEvent" + }, + { + "$ref": "#/$defs/restoreFailedEvent" + }, + { + "$ref": "#/$defs/experimentAssignedEvent" + }, + { + "$ref": "#/$defs/experimentExposedEvent" + }, + { + "$ref": "#/$defs/experimentFallbackPresentedEvent" + }, + { + "$ref": "#/$defs/experimentAssignmentFailedEvent" + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "utcTimestamp": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\\.[0-9]{3}Z$" + }, + "safeCode": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "durationMs": { + "type": "integer", + "minimum": 0, + "maximum": 86400000 + }, + "boundedIdentifiers": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "eventName": { + "enum": [ + "placement_requested", + "placement_paywall_selected", + "placement_no_paywall", + "placement_fallback_used", + "placement_unavailable", + "placement_evaluation_failed", + "paywall_presented", + "paywall_dismissed", + "paywall_action_selected", + "paywall_render_failed", + "product_load_started", + "product_load_completed", + "product_load_failed", + "product_unavailable", + "product_selected", + "purchase_started", + "purchase_completed_client", + "purchase_completed_provider", + "purchase_pending", + "purchase_deferred", + "purchase_cancelled", + "purchase_failed", + "restore_started", + "restore_completed", + "restore_nothing_found", + "restore_cancelled", + "restore_failed", + "experiment_assigned", + "experiment_exposed", + "experiment_fallback_presented", + "experiment_assignment_failed" + ] + }, + "authority": { + "enum": [ + "client_observed", + "trusted_server", + "provider_confirmed" + ] + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "installationId", + "generation" + ], + "properties": { + "installationId": { + "$ref": "#/$defs/identifier" + }, + "applicationUserId": { + "description": "Host-defined opaque identity: 1–256 non-control characters, not Mosaic resource-ID syntax. Sensitive-shaped values are rejected by ingestion policy.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]+$" + }, + "generation": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "sdkFamily", + "sdkVersion" + ], + "properties": { + "platform": { + "enum": [ + "ios", + "android" + ] + }, + "sdkFamily": { + "enum": [ + "flutter", + "ios", + "android" + ] + }, + "sdkVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_-]*$" + }, + "operatingSystemVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_ -]*$" + }, + "applicationVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_-]*$" + }, + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 35, + "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" + }, + "configurationDeliveryVersion": { + "enum": [ + "1", + "2", + "3" + ] + }, + "commerceProviderContractVersion": { + "enum": [ + "1", + "2" + ] + } + } + }, + "correlation": { + "type": "object", + "additionalProperties": false, + "properties": { + "placementRequestId": { + "$ref": "#/$defs/identifier" + }, + "paywallPresentationId": { + "$ref": "#/$defs/identifier" + }, + "productLoadAttemptId": { + "$ref": "#/$defs/identifier" + }, + "purchaseAttemptId": { + "$ref": "#/$defs/identifier" + }, + "restoreAttemptId": { + "$ref": "#/$defs/identifier" + }, + "providerOperationId": { + "$ref": "#/$defs/identifier" + }, + "providerUpdateId": { + "$ref": "#/$defs/identifier" + } + } + }, + "attribution": { + "type": "object", + "additionalProperties": false, + "properties": { + "configurationReleaseId": { + "$ref": "#/$defs/identifier" + }, + "placementId": { + "$ref": "#/$defs/identifier" + }, + "placementRuleSetId": { + "$ref": "#/$defs/identifier" + }, + "placementRuleSetVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "winningRuleId": { + "$ref": "#/$defs/identifier" + }, + "paywallId": { + "$ref": "#/$defs/identifier" + }, + "paywallVersionId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "planId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "providerProductMappingId": { + "$ref": "#/$defs/identifier" + }, + "experimentId": { + "$ref": "#/$defs/identifier" + }, + "experimentVersionId": { + "$ref": "#/$defs/identifier" + }, + "experimentVariantId": { + "$ref": "#/$defs/identifier" + }, + "experimentAllocationVersion": { + "$ref": "#/$defs/identifier" + } + }, + "dependentRequired": { + "placementRuleSetId": [ + "placementRuleSetVersion" + ], + "placementRuleSetVersion": [ + "placementRuleSetId" + ], + "winningRuleId": [ + "placementRuleSetId", + "placementRuleSetVersion" + ], + "experimentAllocationVersion": [ + "experimentId", + "experimentVariantId", + "experimentVersionId" + ], + "experimentId": [ + "experimentAllocationVersion", + "experimentVariantId", + "experimentVersionId" + ], + "experimentVariantId": [ + "experimentAllocationVersion", + "experimentId", + "experimentVersionId" + ], + "experimentVersionId": [ + "experimentAllocationVersion", + "experimentId", + "experimentVariantId" + ] + } + }, + "clientEventBase": { + "type": "object", + "required": [ + "identity", + "sessionId", + "context" + ], + "properties": { + "authority": { + "const": "client_observed" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "sessionId": { + "$ref": "#/$defs/identifier" + }, + "context": { + "$ref": "#/$defs/context" + } + } + }, + "placementCorrelation": { + "type": "object", + "required": [ + "placementRequestId" + ], + "properties": { + "placementRequestId": { + "$ref": "#/$defs/identifier" + } + } + }, + "placementAttribution": { + "type": "object", + "required": [ + "placementId" + ], + "properties": { + "placementId": { + "$ref": "#/$defs/identifier" + } + } + }, + "paywallAttribution": { + "type": "object", + "required": [ + "paywallId", + "paywallVersionId" + ], + "properties": { + "paywallId": { + "$ref": "#/$defs/identifier" + }, + "paywallVersionId": { + "$ref": "#/$defs/identifier" + } + } + }, + "productAttribution": { + "type": "object", + "required": [ + "mosaicProductId" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + } + } + }, + "purchaseAttribution": { + "type": "object", + "required": [ + "mosaicProductId", + "providerId" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + } + } + }, + "diagnosticFailurePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "diagnosticCode", + "retryable" + ], + "properties": { + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "placementRequestedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "decisionContractVersion" + ], + "properties": { + "decisionContractVersion": { + "const": "1" + } + } + }, + "placementSelectionPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "finalOutcome", + "decisionContractVersion" + ], + "properties": { + "finalOutcome": { + "enum": [ + "paywall", + "no_paywall" + ] + }, + "decisionContractVersion": { + "const": "1" + }, + "assignmentKeyType": { + "enum": [ + "installation", + "identified_user" + ] + }, + "bucketingAlgorithm": { + "const": "sha256_length_prefixed_v1" + }, + "rolloutBucket": { + "type": "integer", + "minimum": 0, + "maximum": 9999 + } + }, + "dependentRequired": { + "assignmentKeyType": [ + "bucketingAlgorithm", + "rolloutBucket" + ], + "bucketingAlgorithm": [ + "assignmentKeyType", + "rolloutBucket" + ], + "rolloutBucket": [ + "assignmentKeyType", + "bucketingAlgorithm" + ] + } + }, + "placementFallbackPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "trigger", + "fallbackKey", + "finalOutcome" + ], + "properties": { + "trigger": { + "enum": [ + "configuration_incompatible", + "content_unavailable", + "commerce_unavailable", + "product_unavailable", + "product_unknown", + "provider_unavailable", + "entitlement_unknown", + "unsafe_rendering" + ] + }, + "fallbackKey": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "finalOutcome": { + "enum": [ + "paywall", + "no_paywall", + "unavailable" + ] + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "placementUnavailablePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "no_safe_decision", + "configuration_incompatible", + "content_unavailable", + "commerce_unavailable" + ] + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "emptyPayload": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "dismissalPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "user", + "system", + "purchase_completed", + "host_application", + "unknown" + ] + } + } + }, + "paywallActionPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "action" + ], + "properties": { + "action": { + "enum": [ + "purchase", + "restore", + "close", + "navigate_to", + "navigate_back", + "open_external_url" + ] + }, + "componentId": { + "$ref": "#/$defs/identifier" + } + } + }, + "productLoadStartedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestedProductCount" + ], + "properties": { + "requestedProductCount": { + "type": "integer", + "minimum": 1, + "maximum": 64 + } + } + }, + "productLoadCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "availableProductCount", + "unavailableProductCount", + "durationMs" + ], + "properties": { + "availableProductCount": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "unavailableProductCount": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + } + } + }, + "productLoadFailedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestedProductCount", + "durationMs", + "diagnosticCode", + "retryable" + ], + "properties": { + "requestedProductCount": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "productUnavailablePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "enum": [ + "mapping_missing", + "mapping_invalid", + "product_not_found", + "temporarily_unavailable", + "provider_unavailable", + "unsupported_product_type", + "metadata_unavailable" + ] + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "productSelectedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "source" + ], + "properties": { + "source": { + "enum": [ + "default", + "user" + ] + } + } + }, + "purchaseClientCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "outcome", + "durationMs", + "observedEntitlementKeys" + ], + "properties": { + "outcome": { + "enum": [ + "purchased", + "already_entitled" + ] + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "observedEntitlementKeys": { + "$ref": "#/$defs/boundedIdentifiers" + }, + "providerResultCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "purchaseProviderCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "confirmationSource", + "activeEntitlementKeys" + ], + "properties": { + "confirmationSource": { + "enum": [ + "trusted_provider_integration", + "trusted_server_endpoint", + "accepted_adapter_source" + ] + }, + "activeEntitlementKeys": { + "$ref": "#/$defs/boundedIdentifiers" + }, + "linkedClientEventId": { + "$ref": "#/$defs/identifier" + } + } + }, + "purchaseLifecyclePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "durationMs" + ], + "properties": { + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "providerResultCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "purchaseFailedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "durationMs", + "diagnosticCode", + "retryable" + ], + "properties": { + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "restoreStartedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + } + } + }, + "restoreCompletedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "durationMs", + "restoredProductIds", + "observedEntitlementKeys" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "restoredProductIds": { + "$ref": "#/$defs/boundedIdentifiers" + }, + "observedEntitlementKeys": { + "$ref": "#/$defs/boundedIdentifiers" + } + } + }, + "restoreLifecyclePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "durationMs" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "providerResultCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "restoreFailedPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "durationMs", + "diagnosticCode", + "retryable" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "durationMs": { + "$ref": "#/$defs/durationMs" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + }, + "retryable": { + "type": "boolean" + } + } + }, + "placementRequestedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_requested" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopePlacementRequest" + } + ] + }, + "payload": { + "$ref": "#/$defs/placementRequestedPayload" + } + } + } + ] + }, + "placementPaywallSelectedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_paywall_selected" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywall" + } + ] + }, + "payload": { + "allOf": [ + { + "$ref": "#/$defs/placementSelectionPayload" + }, + { + "properties": { + "finalOutcome": { + "const": "paywall" + } + } + } + ] + } + } + } + ] + }, + "placementNoPaywallEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_no_paywall" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacement" + } + ] + }, + "payload": { + "allOf": [ + { + "$ref": "#/$defs/placementSelectionPayload" + }, + { + "properties": { + "finalOutcome": { + "const": "no_paywall" + } + } + } + ] + } + } + } + ] + }, + "placementFallbackUsedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_fallback_used" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywall" + } + ] + }, + "payload": { + "$ref": "#/$defs/placementFallbackPayload" + } + } + } + ] + }, + "placementUnavailableEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_unavailable" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacement" + } + ] + }, + "payload": { + "$ref": "#/$defs/placementUnavailablePayload" + } + } + } + ] + }, + "placementEvaluationFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "placement_evaluation_failed" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/attributionScopePlacementRequest" + } + ] + }, + "payload": { + "$ref": "#/$defs/diagnosticFailurePayload" + } + } + } + ] + }, + "paywallPresentedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_presented" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywall" + } + ] + }, + "payload": { + "$ref": "#/$defs/emptyPayload" + } + } + } + ] + }, + "paywallDismissedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_dismissed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "payload": { + "$ref": "#/$defs/dismissalPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "paywallActionSelectedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_action_selected" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "payload": { + "$ref": "#/$defs/paywallActionPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "paywallRenderFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "paywall_render_failed" + }, + "payload": { + "$ref": "#/$defs/diagnosticFailurePayload" + }, + "correlation": { + "$ref": "#/$defs/correlationScopePaywallPresentation" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productLoadStartedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_load_started" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId", + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "payload": { + "$ref": "#/$defs/productLoadStartedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productLoadCompletedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_load_completed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "payload": { + "$ref": "#/$defs/productLoadCompletedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productLoadFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_load_failed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "payload": { + "$ref": "#/$defs/productLoadFailedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopePaywall" + } + } + } + ] + }, + "productUnavailableEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_unavailable" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "productLoadAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/productAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProduct" + } + ] + }, + "payload": { + "$ref": "#/$defs/productUnavailablePayload" + } + } + } + ] + }, + "productSelectedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "product_selected" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopeProductLoad" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/productAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/productSelectedPayload" + } + } + } + ] + }, + "purchaseStartedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_started" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/emptyPayload" + } + } + } + ] + }, + "purchaseCompletedClientEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_completed_client" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseClientCompletedPayload" + } + } + } + ] + }, + "purchaseCompletedProviderEvent": { + "type": "object", + "properties": { + "eventName": { + "const": "purchase_completed_provider" + }, + "authority": { + "enum": [ + "trusted_server", + "provider_confirmed" + ] + }, + "correlation": { + "allOf": [ + { + "type": "object", + "anyOf": [ + { + "required": [ + "purchaseAttemptId" + ] + }, + { + "required": [ + "providerOperationId" + ] + }, + { + "required": [ + "providerUpdateId" + ] + } + ] + }, + { + "$ref": "#/$defs/correlationScopeProviderConfirmation" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseProviderCompletedPayload" + } + } + }, + "purchasePendingEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_pending" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseLifecyclePayload" + } + } + } + ] + }, + "purchaseDeferredEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_deferred" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseLifecyclePayload" + } + } + } + ] + }, + "purchaseCancelledEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_cancelled" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseLifecyclePayload" + } + } + } + ] + }, + "purchaseFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "purchase_failed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "purchaseAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopePurchaseAttempt" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/purchaseAttribution" + }, + { + "$ref": "#/$defs/attributionScopeProductExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/purchaseFailedPayload" + } + } + } + ] + }, + "restoreStartedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_started" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreStartedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreCompletedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_completed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreCompletedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreNothingFoundEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_nothing_found" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreLifecyclePayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreCancelledEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_cancelled" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreLifecyclePayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "restoreFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "restore_failed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "restoreAttemptId" + ] + }, + { + "$ref": "#/$defs/correlationScopeRestoreAttempt" + } + ] + }, + "payload": { + "$ref": "#/$defs/restoreFailedPayload" + }, + "attribution": { + "$ref": "#/$defs/attributionScopeConfigurationRelease" + } + } + } + ] + }, + "experimentAttribution": { + "type": "object", + "required": [ + "experimentId", + "experimentVersionId", + "experimentVariantId", + "experimentAllocationVersion" + ], + "properties": { + "experimentId": { + "$ref": "#/$defs/identifier" + }, + "experimentVersionId": { + "$ref": "#/$defs/identifier" + }, + "experimentVariantId": { + "$ref": "#/$defs/identifier" + }, + "experimentAllocationVersion": { + "$ref": "#/$defs/identifier" + } + } + }, + "experimentAssignmentPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentKeyType", + "bucketingAlgorithm", + "bucket", + "source" + ], + "properties": { + "assignmentKeyType": { + "enum": [ + "installation", + "identified_user" + ] + }, + "bucketingAlgorithm": { + "const": "experiment_sha256_length_prefixed_v1" + }, + "bucket": { + "type": "integer", + "minimum": 0, + "maximum": 9999 + }, + "source": { + "enum": [ + "deterministic", + "qa_override" + ] + } + } + }, + "experimentExposurePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "assignmentKeyType", + "bucketingAlgorithm", + "productReadiness", + "providerCapability" + ], + "properties": { + "assignmentKeyType": { + "enum": [ + "installation", + "identified_user" + ] + }, + "bucketingAlgorithm": { + "const": "experiment_sha256_length_prefixed_v1" + }, + "productReadiness": { + "const": "ready" + }, + "providerCapability": { + "const": "accepted" + }, + "qaOverride": { + "type": "boolean" + } + } + }, + "experimentFallbackPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "presentedPaywallId", + "presentedPaywallVersionId" + ], + "properties": { + "reason": { + "enum": [ + "configuration_incompatible", + "product_unavailable", + "provider_unavailable", + "rendering_failed", + "time_unreliable" + ] + }, + "presentedPaywallId": { + "$ref": "#/$defs/identifier" + }, + "presentedPaywallVersionId": { + "$ref": "#/$defs/identifier" + }, + "diagnosticCode": { + "$ref": "#/$defs/safeCode" + } + } + }, + "experimentAssignedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "experiment_assigned" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/experimentAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacementExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/experimentAssignmentPayload" + } + } + } + ] + }, + "experimentExposedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "experiment_exposed" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "placementRequestId", + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/paywallAttribution" + }, + { + "$ref": "#/$defs/experimentAttribution" + }, + { + "$ref": "#/$defs/attributionScopePaywallExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/experimentExposurePayload" + } + } + } + ] + }, + "experimentFallbackPresentedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "experiment_fallback_presented" + }, + "correlation": { + "allOf": [ + { + "type": "object", + "required": [ + "placementRequestId", + "paywallPresentationId" + ] + }, + { + "$ref": "#/$defs/correlationScopePaywallPresentation" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/experimentAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacementExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/experimentFallbackPayload" + } + } + } + ] + }, + "experimentAssignmentFailedEvent": { + "allOf": [ + { + "$ref": "#/$defs/clientEventBase" + }, + { + "properties": { + "eventName": { + "const": "experiment_assignment_failed" + }, + "correlation": { + "allOf": [ + { + "$ref": "#/$defs/placementCorrelation" + }, + { + "$ref": "#/$defs/correlationScopePlacementRequest" + } + ] + }, + "attribution": { + "allOf": [ + { + "$ref": "#/$defs/placementAttribution" + }, + { + "$ref": "#/$defs/experimentAttribution" + }, + { + "$ref": "#/$defs/attributionScopeDecidedPlacementExperiment" + } + ] + }, + "payload": { + "$ref": "#/$defs/diagnosticFailurePayload" + } + } + } + ] + }, + "attributionScopeConfigurationRelease": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true + }, + "unevaluatedProperties": false + }, + "attributionScopeDecidedPlacement": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopeDecidedPlacementExperiment": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "experimentAllocationVersion": true, + "experimentId": true, + "experimentVariantId": true, + "experimentVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopePaywall": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "paywallId": true, + "paywallVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopePaywallExperiment": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "experimentAllocationVersion": true, + "experimentId": true, + "experimentVariantId": true, + "experimentVersionId": true, + "paywallId": true, + "paywallVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopePlacementRequest": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true + }, + "unevaluatedProperties": false + }, + "attributionScopeProduct": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "mosaicProductId": true, + "paywallId": true, + "paywallVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "planId": true, + "providerId": true, + "providerProductMappingId": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "attributionScopeProductExperiment": { + "type": "object", + "description": "Fields this event may carry in `attribution`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "configurationReleaseId": true, + "experimentAllocationVersion": true, + "experimentId": true, + "experimentVariantId": true, + "experimentVersionId": true, + "mosaicProductId": true, + "paywallId": true, + "paywallVersionId": true, + "placementId": true, + "placementRuleSetId": true, + "placementRuleSetVersion": true, + "planId": true, + "providerId": true, + "providerProductMappingId": true, + "winningRuleId": true + }, + "unevaluatedProperties": false + }, + "correlationScopePaywallPresentation": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "paywallPresentationId": true, + "placementRequestId": true + }, + "unevaluatedProperties": false + }, + "correlationScopePlacementRequest": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "placementRequestId": true + }, + "unevaluatedProperties": false + }, + "correlationScopeProductLoad": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "paywallPresentationId": true, + "placementRequestId": true, + "productLoadAttemptId": true + }, + "unevaluatedProperties": false + }, + "correlationScopeProviderConfirmation": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "providerOperationId": true, + "providerUpdateId": true, + "purchaseAttemptId": true + }, + "unevaluatedProperties": false + }, + "correlationScopePurchaseAttempt": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "paywallPresentationId": true, + "placementRequestId": true, + "productLoadAttemptId": true, + "providerOperationId": true, + "purchaseAttemptId": true + }, + "unevaluatedProperties": false + }, + "correlationScopeRestoreAttempt": { + "type": "object", + "description": "Fields this event may carry in `correlation`. Any other field is rejected: analytics minimization forbids collecting identifiers the event's own semantics cannot justify.", + "properties": { + "providerOperationId": true, + "restoreAttemptId": true + }, + "unevaluatedProperties": false + } + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/commerce-configuration-v1.schema.json b/apps/api/internal/platform/protocolschema/schemas/commerce-configuration-v1.schema.json new file mode 100644 index 00000000..aa1e85b8 --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/commerce-configuration-v1.schema.json @@ -0,0 +1,314 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:commerce-configuration:v1:configuration", + "title": "Mosaic Commerce Configuration v1", + "description": "An immutable, release-associated commerce-provider mapping snapshot consumed by Mosaic SDKs.", + "type": "object", + "additionalProperties": false, + "required": [ + "commerceConfigurationVersion", + "configuration" + ], + "properties": { + "commerceConfigurationVersion": { + "const": "1" + }, + "configuration": { + "$ref": "#/$defs/configuration" + } + }, + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "entitlementKey": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "opaqueProviderIdentifier": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]+$" + }, + "sha256Digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "utcTimestamp": { + "type": "string", + "maxLength": 32, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$" + }, + "storePlatform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "configurationRelease": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "contentDigest" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "contentDigest": { + "$ref": "#/$defs/sha256Digest" + } + } + }, + "providerActivation": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "providerConnectionId" + ], + "properties": { + "source": { + "const": "providerConnection" + }, + "providerConnectionId": { + "$ref": "#/$defs/identifier" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "localSnapshotId" + ], + "properties": { + "source": { + "const": "sdkLocal" + }, + "localSnapshotId": { + "$ref": "#/$defs/identifier" + } + } + } + ] + }, + "activeProvider": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "activation", + "capabilities" + ], + "properties": { + "identity": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v1:contract#/$defs/providerIdentity" + }, + "activation": { + "$ref": "#/$defs/providerActivation" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 13, + "items": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v1:contract#/$defs/providerCapability" + } + } + } + }, + "adapterMapping": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "directProduct" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "offeringIdentifier", + "packageIdentifier" + ], + "properties": { + "kind": { + "const": "revenueCatPackage" + }, + "offeringIdentifier": { + "$ref": "#/$defs/opaqueProviderIdentifier" + }, + "packageIdentifier": { + "$ref": "#/$defs/opaqueProviderIdentifier" + } + } + } + ] + }, + "productMapping": { + "type": "object", + "additionalProperties": false, + "required": [ + "mosaicProductId", + "mappingId", + "providerProductReference", + "adapterMapping" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "mappingId": { + "$ref": "#/$defs/identifier" + }, + "providerProductReference": { + "$ref": "#/$defs/opaqueProviderIdentifier" + }, + "adapterMapping": { + "$ref": "#/$defs/adapterMapping" + } + } + }, + "entitlementMapping": { + "type": "object", + "additionalProperties": false, + "required": [ + "mosaicEntitlementKey", + "providerEntitlementIdentifier" + ], + "properties": { + "mosaicEntitlementKey": { + "$ref": "#/$defs/entitlementKey" + }, + "providerEntitlementIdentifier": { + "$ref": "#/$defs/opaqueProviderIdentifier" + } + } + }, + "freshness": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "status", + "providerObservedAt", + "synchronizedAt", + "staleAt" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "providerSynchronization", + "sdkLocalSnapshot" + ] + }, + "status": { + "type": "string", + "enum": [ + "fresh", + "stale" + ] + }, + "providerObservedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "synchronizedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "staleAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "expiresAt": { + "$ref": "#/$defs/utcTimestamp" + } + } + }, + "configuration": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "environmentId", + "applicationId", + "storePlatform", + "configurationRelease", + "contentDigest", + "activeProvider", + "productMappings", + "entitlementMappings", + "freshness", + "diagnostics" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "environmentId": { + "$ref": "#/$defs/identifier" + }, + "applicationId": { + "$ref": "#/$defs/identifier" + }, + "storePlatform": { + "$ref": "#/$defs/storePlatform" + }, + "configurationRelease": { + "$ref": "#/$defs/configurationRelease" + }, + "contentDigest": { + "$ref": "#/$defs/sha256Digest" + }, + "activeProvider": { + "$ref": "#/$defs/activeProvider" + }, + "productMappings": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "$ref": "#/$defs/productMapping" + } + }, + "entitlementMappings": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "$ref": "#/$defs/entitlementMapping" + } + }, + "freshness": { + "$ref": "#/$defs/freshness" + }, + "diagnostics": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v1:contract#/$defs/diagnostic" + } + } + } + } + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/commerce-configuration-v2.schema.json b/apps/api/internal/platform/protocolschema/schemas/commerce-configuration-v2.schema.json new file mode 100644 index 00000000..020308fc --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/commerce-configuration-v2.schema.json @@ -0,0 +1,423 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:commerce-configuration:v2:configuration", + "title": "Mosaic Commerce Configuration v2", + "description": "An immutable, release-associated commerce-provider mapping snapshot consumed by Mosaic SDKs.", + "type": "object", + "additionalProperties": false, + "required": [ + "commerceConfigurationVersion", + "configuration" + ], + "properties": { + "commerceConfigurationVersion": { + "const": "2" + }, + "configuration": { + "$ref": "#/$defs/configuration" + } + }, + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "entitlementKey": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "opaqueProviderIdentifier": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]+$" + }, + "sha256Digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "utcTimestamp": { + "type": "string", + "maxLength": 32, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$" + }, + "storePlatform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "configurationRelease": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "contentDigest" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "contentDigest": { + "$ref": "#/$defs/sha256Digest" + } + } + }, + "providerActivation": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "providerConnectionId" + ], + "properties": { + "source": { + "const": "providerConnection" + }, + "providerConnectionId": { + "$ref": "#/$defs/identifier" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "localSnapshotId" + ], + "properties": { + "source": { + "const": "sdkLocal" + }, + "localSnapshotId": { + "$ref": "#/$defs/identifier" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source" + ], + "properties": { + "source": { + "const": "nativeStore" + } + } + } + ] + }, + "activeProvider": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "activation", + "capabilities", + "recoveryMode" + ], + "properties": { + "identity": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v2:contract#/$defs/providerIdentity" + }, + "activation": { + "$ref": "#/$defs/providerActivation" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 19, + "items": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v2:contract#/$defs/providerCapability" + } + }, + "recoveryMode": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v2:contract#/$defs/recoveryMode" + } + } + }, + "adapterMapping": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "directProduct" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "offeringIdentifier", + "packageIdentifier" + ], + "properties": { + "kind": { + "const": "revenueCatPackage" + }, + "offeringIdentifier": { + "$ref": "#/$defs/opaqueProviderIdentifier" + }, + "packageIdentifier": { + "$ref": "#/$defs/opaqueProviderIdentifier" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "storeKitProduct" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "googlePlayProduct" + }, + "basePlanId": { + "$ref": "#/$defs/opaqueProviderIdentifier" + }, + "offerId": { + "$ref": "#/$defs/opaqueProviderIdentifier" + } + } + } + ] + }, + "productMapping": { + "type": "object", + "additionalProperties": false, + "required": [ + "mosaicProductId", + "mappingId", + "productType", + "entitlementKeys", + "providerProductReference", + "adapterMapping" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "mappingId": { + "$ref": "#/$defs/identifier" + }, + "productType": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v2:contract#/$defs/productType" + }, + "entitlementKeys": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/entitlementKey" + } + }, + "providerProductReference": { + "$ref": "#/$defs/opaqueProviderIdentifier" + }, + "adapterMapping": { + "$ref": "#/$defs/adapterMapping" + } + } + }, + "entitlementMapping": { + "type": "object", + "additionalProperties": false, + "required": [ + "mosaicEntitlementKey", + "providerEntitlementIdentifier" + ], + "properties": { + "mosaicEntitlementKey": { + "$ref": "#/$defs/entitlementKey" + }, + "providerEntitlementIdentifier": { + "$ref": "#/$defs/opaqueProviderIdentifier" + } + } + }, + "freshness": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "status", + "providerObservedAt", + "synchronizedAt", + "staleAt" + ], + "properties": { + "source": { + "enum": [ + "providerSynchronization", + "sdkLocalSnapshot" + ] + }, + "status": { + "enum": [ + "fresh", + "stale" + ] + }, + "providerObservedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "synchronizedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "staleAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "expiresAt": { + "$ref": "#/$defs/utcTimestamp" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "status", + "configuredAt" + ], + "properties": { + "source": { + "const": "nativeStoreConfiguration" + }, + "status": { + "enum": [ + "configured", + "fresh", + "stale" + ] + }, + "configuredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "observation": { + "type": "object", + "additionalProperties": false, + "required": [ + "environment", + "observedAt" + ], + "properties": { + "environment": { + "enum": [ + "test", + "production", + "unknown" + ] + }, + "observedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "expiresAt": { + "$ref": "#/$defs/utcTimestamp" + } + } + } + } + } + ] + }, + "configuration": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "environmentId", + "applicationId", + "storePlatform", + "configurationRelease", + "contentDigest", + "activeProvider", + "productMappings", + "entitlementMappings", + "freshness", + "diagnostics" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "environmentId": { + "$ref": "#/$defs/identifier" + }, + "applicationId": { + "$ref": "#/$defs/identifier" + }, + "storePlatform": { + "$ref": "#/$defs/storePlatform" + }, + "configurationRelease": { + "$ref": "#/$defs/configurationRelease" + }, + "contentDigest": { + "$ref": "#/$defs/sha256Digest" + }, + "activeProvider": { + "$ref": "#/$defs/activeProvider" + }, + "productMappings": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "$ref": "#/$defs/productMapping" + } + }, + "entitlementMappings": { + "type": "array", + "minItems": 0, + "maxItems": 128, + "items": { + "$ref": "#/$defs/entitlementMapping" + } + }, + "freshness": { + "$ref": "#/$defs/freshness" + }, + "diagnostics": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "urn:mosaic:protocol:schema:commerce-provider:v2:contract#/$defs/diagnostic" + } + } + } + } + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/commerce-provider-v1.schema.json b/apps/api/internal/platform/protocolschema/schemas/commerce-provider-v1.schema.json new file mode 100644 index 00000000..15e5c0f5 --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/commerce-provider-v1.schema.json @@ -0,0 +1,901 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:commerce-provider:v1:contract", + "title": "Mosaic Commerce Provider Contract v1", + "description": "The platform-neutral contract between Mosaic Core and commerce-provider adapters.", + "type": "object", + "additionalProperties": false, + "required": [ + "commerceProviderContractVersion", + "recordType", + "payload" + ], + "properties": { + "commerceProviderContractVersion": { + "const": "1" + }, + "recordType": { + "$ref": "#/$defs/recordType" + }, + "payload": {} + }, + "allOf": [ + { + "if": { + "properties": { + "recordType": { + "const": "providerProfile" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/providerProfile" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "productLoadRequest" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/productLoadRequest" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "productLoadResult" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/productLoadResult" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "purchaseRequest" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/purchaseRequest" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "purchaseOutcome" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/purchaseOutcome" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "restoreOutcome" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/restoreOutcome" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "activeEntitlementOutcome" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/activeEntitlementOutcome" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "providerDiagnostics" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/providerDiagnostics" + } + } + } + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "safeText": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "safeProviderCode": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "utcTimestamp": { + "type": "string", + "maxLength": 32, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$" + }, + "recordType": { + "type": "string", + "enum": [ + "providerProfile", + "productLoadRequest", + "productLoadResult", + "purchaseRequest", + "purchaseOutcome", + "restoreOutcome", + "activeEntitlementOutcome", + "providerDiagnostics" + ] + }, + "providerIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "displayName", + "adapterVersion" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "displayName": { + "$ref": "#/$defs/safeText" + }, + "adapterVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_-]*$" + } + } + }, + "capabilityName": { + "type": "string", + "enum": [ + "productLoading", + "subscriptions", + "oneTimeNonConsumables", + "trials", + "introductoryOffers", + "promotionalOffers", + "restore", + "activeEntitlementLookup", + "pendingPurchases", + "deferredPurchases", + "serverConfirmedTransactions", + "productSynchronization", + "providerDiagnostics" + ] + }, + "providerCapability": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "support" + ], + "properties": { + "name": { + "$ref": "#/$defs/capabilityName" + }, + "support": { + "type": "string", + "enum": [ + "supported", + "unsupported", + "conditional" + ] + }, + "reasonCode": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$" + } + } + }, + "providerProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "capabilities" + ], + "properties": { + "provider": { + "$ref": "#/$defs/providerIdentity" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 13, + "items": { + "$ref": "#/$defs/providerCapability" + } + } + } + }, + "productType": { + "type": "string", + "enum": [ + "subscription", + "one_time_non_consumable" + ] + }, + "mosaicProduct": { + "type": "object", + "additionalProperties": false, + "required": [ + "mosaicProductId", + "key", + "type", + "entitlementKeys" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "key": { + "$ref": "#/$defs/identifier" + }, + "type": { + "$ref": "#/$defs/productType" + }, + "entitlementKeys": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + } + } + }, + "providerProductBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "mappingId", + "providerProductReference" + ], + "properties": { + "mappingId": { + "$ref": "#/$defs/identifier" + }, + "providerProductReference": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + } + } + }, + "productLoadRequestItem": { + "type": "object", + "additionalProperties": false, + "required": [ + "product", + "binding" + ], + "properties": { + "product": { + "$ref": "#/$defs/mosaicProduct" + }, + "binding": { + "$ref": "#/$defs/providerProductBinding" + } + } + }, + "productLoadRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "providerId", + "products" + ], + "properties": { + "requestId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "products": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/productLoadRequestItem" + } + } + } + }, + "period": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "value" + ], + "properties": { + "unit": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 120 + } + } + }, + "offerEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible", + "unknown" + ] + }, + "trial": { + "type": "object", + "additionalProperties": false, + "required": [ + "period" + ], + "properties": { + "period": { + "$ref": "#/$defs/period" + }, + "eligibility": { + "$ref": "#/$defs/offerEligibility" + } + } + }, + "introductoryOffer": { + "type": "object", + "additionalProperties": false, + "required": [ + "localizedPrice", + "period", + "cycles", + "paymentMode" + ], + "properties": { + "localizedPrice": { + "$ref": "#/$defs/safeText" + }, + "period": { + "$ref": "#/$defs/period" + }, + "cycles": { + "type": "integer", + "minimum": 1, + "maximum": 120 + }, + "paymentMode": { + "type": "string", + "enum": [ + "payAsYouGo", + "payUpFront" + ] + }, + "eligibility": { + "$ref": "#/$defs/offerEligibility" + } + } + }, + "resolvedProductMetadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "localizedDisplayName", + "localizedPrice" + ], + "properties": { + "localizedDisplayName": { + "$ref": "#/$defs/safeText" + }, + "localizedPrice": { + "$ref": "#/$defs/safeText" + }, + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 35, + "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$" + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "billingPeriod": { + "$ref": "#/$defs/period" + }, + "trial": { + "$ref": "#/$defs/trial" + }, + "introductoryOffer": { + "$ref": "#/$defs/introductoryOffer" + } + } + }, + "metadataFreshness": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "status", + "observedAt" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "liveProvider", + "providerCache", + "mosaicSynchronization", + "simulated" + ] + }, + "status": { + "type": "string", + "enum": [ + "fresh", + "stale", + "unknown" + ] + }, + "observedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "expiresAt": { + "$ref": "#/$defs/utcTimestamp" + } + } + }, + "productAvailability": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "available", + "unavailable", + "unknown" + ] + }, + "reason": { + "type": "string", + "enum": [ + "mappingMissing", + "mappingInvalid", + "productNotFound", + "temporarilyUnavailable", + "providerUnavailable", + "unsupportedProductType", + "metadataUnavailable" + ] + } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "safeMessage", + "severity", + "retryable", + "correlationId" + ], + "properties": { + "code": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$" + }, + "safeMessage": { + "$ref": "#/$defs/safeText" + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "error" + ] + }, + "retryable": { + "type": "boolean" + }, + "retryAfterSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "correlationId": { + "$ref": "#/$defs/identifier" + }, + "providerCode": { + "$ref": "#/$defs/safeProviderCode" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "recoveryAction": { + "type": "string", + "enum": [ + "retry", + "reconnectProvider", + "fixProductMapping", + "updateProviderConfiguration", + "contactProvider", + "none" + ] + } + } + }, + "diagnostics": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/$defs/diagnostic" + } + }, + "resolvedProduct": { + "type": "object", + "additionalProperties": false, + "required": [ + "product", + "availability", + "freshness", + "diagnostics" + ], + "properties": { + "product": { + "$ref": "#/$defs/mosaicProduct" + }, + "availability": { + "$ref": "#/$defs/productAvailability" + }, + "metadata": { + "$ref": "#/$defs/resolvedProductMetadata" + }, + "freshness": { + "$ref": "#/$defs/metadataFreshness" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "productLoadResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "providerId", + "products", + "diagnostics" + ], + "properties": { + "requestId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "products": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/resolvedProduct" + } + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "purchaseRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "providerId", + "mosaicProductId" + ], + "properties": { + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + } + } + }, + "purchaseOutcomeName": { + "type": "string", + "enum": [ + "purchased", + "pending", + "deferred", + "cancelled", + "alreadyEntitled", + "productUnavailable", + "providerUnavailable", + "failed" + ] + }, + "purchaseOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "providerId", + "mosaicProductId", + "outcome", + "occurredAt", + "diagnostics" + ], + "properties": { + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "outcome": { + "$ref": "#/$defs/purchaseOutcomeName" + }, + "transactionReference": { + "$ref": "#/$defs/safeProviderCode" + }, + "activeEntitlementKeys": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "occurredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "restoreOutcomeName": { + "type": "string", + "enum": [ + "restored", + "nothingToRestore", + "cancelled", + "providerUnavailable", + "failed" + ] + }, + "restoreOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "providerId", + "outcome", + "occurredAt", + "diagnostics" + ], + "properties": { + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "outcome": { + "$ref": "#/$defs/restoreOutcomeName" + }, + "activeEntitlementKeys": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "occurredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "activeEntitlementOutcomeName": { + "type": "string", + "enum": [ + "available", + "unknown", + "providerUnavailable", + "failed" + ] + }, + "activeEntitlementOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "lookupId", + "providerId", + "outcome", + "checkedAt", + "diagnostics" + ], + "properties": { + "lookupId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "outcome": { + "$ref": "#/$defs/activeEntitlementOutcomeName" + }, + "activeEntitlementKeys": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "freshness": { + "$ref": "#/$defs/metadataFreshness" + }, + "checkedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "providerHealth": { + "type": "string", + "enum": [ + "healthy", + "degraded", + "unavailable", + "unknown" + ] + }, + "providerDiagnostics": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "health", + "freshness", + "diagnostics" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "health": { + "$ref": "#/$defs/providerHealth" + }, + "freshness": { + "$ref": "#/$defs/metadataFreshness" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + } + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/commerce-provider-v2.schema.json b/apps/api/internal/platform/protocolschema/schemas/commerce-provider-v2.schema.json new file mode 100644 index 00000000..73c3c670 --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/commerce-provider-v2.schema.json @@ -0,0 +1,1088 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:commerce-provider:v2:contract", + "title": "Mosaic Commerce Provider Contract v2", + "description": "The platform-neutral contract between Mosaic Core and commerce-provider adapters.", + "type": "object", + "additionalProperties": false, + "required": [ + "commerceProviderContractVersion", + "recordType", + "payload" + ], + "properties": { + "commerceProviderContractVersion": { + "const": "2" + }, + "recordType": { + "$ref": "#/$defs/recordType" + }, + "payload": {} + }, + "allOf": [ + { + "if": { + "properties": { + "recordType": { + "const": "providerProfile" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/providerProfile" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "productLoadRequest" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/productLoadRequest" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "productLoadResult" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/productLoadResult" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "purchaseRequest" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/purchaseRequest" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "purchaseOutcome" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/purchaseOutcome" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "restoreOutcome" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/restoreOutcome" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "activeEntitlementOutcome" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/activeEntitlementOutcome" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "providerDiagnostics" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/providerDiagnostics" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "commerceUpdate" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/commerceUpdate" + } + } + } + }, + { + "if": { + "properties": { + "recordType": { + "const": "commerceUpdateAcceptance" + } + }, + "required": [ + "recordType" + ] + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/commerceUpdateAcceptance" + } + } + } + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "safeText": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "safeProviderCode": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + }, + "utcTimestamp": { + "type": "string", + "maxLength": 32, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,6})?Z$" + }, + "recordType": { + "type": "string", + "enum": [ + "providerProfile", + "productLoadRequest", + "productLoadResult", + "purchaseRequest", + "purchaseOutcome", + "restoreOutcome", + "activeEntitlementOutcome", + "providerDiagnostics", + "commerceUpdate", + "commerceUpdateAcceptance" + ] + }, + "providerIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "displayName", + "adapterVersion" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "displayName": { + "$ref": "#/$defs/safeText" + }, + "adapterVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9.+_-]*$" + } + } + }, + "capabilityName": { + "type": "string", + "enum": [ + "productLoading", + "subscriptions", + "oneTimeNonConsumables", + "trials", + "introductoryOffers", + "promotionalOffers", + "restore", + "activeEntitlementLookup", + "pendingPurchases", + "deferredPurchases", + "serverConfirmedTransactions", + "productSynchronization", + "providerDiagnostics", + "basePlans", + "explicitOffers", + "storeSynchronization", + "activePurchaseRecovery", + "asynchronousCommerceUpdates", + "localDeliveryAcceptance" + ] + }, + "providerCapability": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "support" + ], + "properties": { + "name": { + "$ref": "#/$defs/capabilityName" + }, + "support": { + "type": "string", + "enum": [ + "supported", + "unsupported", + "conditional" + ] + }, + "reasonCode": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$" + } + } + }, + "providerProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "capabilities", + "recoveryMode" + ], + "properties": { + "provider": { + "$ref": "#/$defs/providerIdentity" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 19, + "items": { + "$ref": "#/$defs/providerCapability" + } + }, + "recoveryMode": { + "$ref": "#/$defs/recoveryMode" + } + } + }, + "recoveryMode": { + "type": "string", + "enum": [ + "providerDefined", + "storeSynchronization", + "activePurchaseRecovery" + ] + }, + "configurationReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "configurationId", + "configurationRevision" + ], + "properties": { + "configurationId": { + "$ref": "#/$defs/identifier" + }, + "configurationRevision": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + }, + "productType": { + "type": "string", + "enum": [ + "subscription", + "one_time_non_consumable" + ] + }, + "mosaicProduct": { + "type": "object", + "additionalProperties": false, + "required": [ + "mosaicProductId", + "key", + "type", + "entitlementKeys" + ], + "properties": { + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "key": { + "$ref": "#/$defs/identifier" + }, + "type": { + "$ref": "#/$defs/productType" + }, + "entitlementKeys": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + } + } + }, + "providerProductBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "mappingId", + "providerProductReference" + ], + "properties": { + "mappingId": { + "$ref": "#/$defs/identifier" + }, + "providerProductReference": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^\\r\\n\\u0000-\\u001F\\u007F]*$" + } + } + }, + "productLoadRequestItem": { + "type": "object", + "additionalProperties": false, + "required": [ + "product", + "binding" + ], + "properties": { + "product": { + "$ref": "#/$defs/mosaicProduct" + }, + "binding": { + "$ref": "#/$defs/providerProductBinding" + } + } + }, + "productLoadRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "providerId", + "products" + ], + "properties": { + "requestId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "products": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/productLoadRequestItem" + } + } + } + }, + "period": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "value" + ], + "properties": { + "unit": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 120 + } + } + }, + "offerEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible", + "unknown" + ] + }, + "trial": { + "type": "object", + "additionalProperties": false, + "required": [ + "period" + ], + "properties": { + "period": { + "$ref": "#/$defs/period" + }, + "eligibility": { + "$ref": "#/$defs/offerEligibility" + } + } + }, + "introductoryOffer": { + "type": "object", + "additionalProperties": false, + "required": [ + "localizedPrice", + "period", + "cycles", + "paymentMode" + ], + "properties": { + "localizedPrice": { + "$ref": "#/$defs/safeText" + }, + "period": { + "$ref": "#/$defs/period" + }, + "cycles": { + "type": "integer", + "minimum": 1, + "maximum": 120 + }, + "paymentMode": { + "type": "string", + "enum": [ + "payAsYouGo", + "payUpFront" + ] + }, + "eligibility": { + "$ref": "#/$defs/offerEligibility" + } + } + }, + "resolvedProductMetadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "localizedDisplayName", + "localizedPrice" + ], + "properties": { + "localizedDisplayName": { + "$ref": "#/$defs/safeText" + }, + "localizedPrice": { + "$ref": "#/$defs/safeText" + }, + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 35, + "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$" + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "billingPeriod": { + "$ref": "#/$defs/period" + }, + "trial": { + "$ref": "#/$defs/trial" + }, + "introductoryOffer": { + "$ref": "#/$defs/introductoryOffer" + } + } + }, + "metadataFreshness": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "status", + "observedAt" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "liveProvider", + "providerCache", + "mosaicSynchronization", + "simulated" + ] + }, + "status": { + "type": "string", + "enum": [ + "fresh", + "stale", + "unknown" + ] + }, + "observedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "expiresAt": { + "$ref": "#/$defs/utcTimestamp" + } + } + }, + "productAvailability": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "available", + "unavailable", + "unknown" + ] + }, + "reason": { + "type": "string", + "enum": [ + "mappingMissing", + "mappingInvalid", + "productNotFound", + "temporarilyUnavailable", + "providerUnavailable", + "unsupportedProductType", + "metadataUnavailable" + ] + } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "safeMessage", + "severity", + "retryable", + "correlationId" + ], + "properties": { + "code": { + "type": "string", + "minLength": 3, + "maxLength": 96, + "pattern": "^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$" + }, + "safeMessage": { + "$ref": "#/$defs/safeText" + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "error" + ] + }, + "retryable": { + "type": "boolean" + }, + "retryAfterSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "correlationId": { + "$ref": "#/$defs/identifier" + }, + "providerCode": { + "$ref": "#/$defs/safeProviderCode" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "recoveryAction": { + "type": "string", + "enum": [ + "retry", + "reconnectProvider", + "fixProductMapping", + "updateProviderConfiguration", + "contactProvider", + "none" + ] + } + } + }, + "diagnostics": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/$defs/diagnostic" + } + }, + "resolvedProduct": { + "type": "object", + "additionalProperties": false, + "required": [ + "product", + "availability", + "freshness", + "diagnostics" + ], + "properties": { + "product": { + "$ref": "#/$defs/mosaicProduct" + }, + "availability": { + "$ref": "#/$defs/productAvailability" + }, + "metadata": { + "$ref": "#/$defs/resolvedProductMetadata" + }, + "freshness": { + "$ref": "#/$defs/metadataFreshness" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "productLoadResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "providerId", + "products", + "diagnostics" + ], + "properties": { + "requestId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "products": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/resolvedProduct" + } + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "purchaseRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "providerId", + "mosaicProductId", + "configuration" + ], + "properties": { + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "configuration": { + "$ref": "#/$defs/configurationReference" + } + } + }, + "purchaseOutcomeName": { + "type": "string", + "enum": [ + "purchased", + "pending", + "deferred", + "cancelled", + "alreadyEntitled", + "productUnavailable", + "providerUnavailable", + "failed" + ] + }, + "purchaseOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "providerId", + "mosaicProductId", + "outcome", + "occurredAt", + "diagnostics" + ], + "properties": { + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "outcome": { + "$ref": "#/$defs/purchaseOutcomeName" + }, + "transactionReference": { + "$ref": "#/$defs/safeProviderCode" + }, + "activeEntitlementKeys": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "occurredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "restoreOutcomeName": { + "type": "string", + "enum": [ + "restored", + "nothingToRestore", + "cancelled", + "providerUnavailable", + "failed" + ] + }, + "restoreOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationId", + "providerId", + "outcome", + "recoveryMode", + "completedAt", + "diagnostics" + ], + "properties": { + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "outcome": { + "$ref": "#/$defs/restoreOutcomeName" + }, + "recoveryMode": { + "$ref": "#/$defs/recoveryMode" + }, + "activeEntitlementKeys": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "completedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "activeEntitlementOutcomeName": { + "type": "string", + "enum": [ + "available", + "unknown", + "providerUnavailable", + "failed" + ] + }, + "activeEntitlementOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "lookupId", + "providerId", + "outcome", + "observedAt", + "diagnostics" + ], + "properties": { + "lookupId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "outcome": { + "$ref": "#/$defs/activeEntitlementOutcomeName" + }, + "activeEntitlementKeys": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "freshness": { + "$ref": "#/$defs/metadataFreshness" + }, + "observedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "providerHealth": { + "type": "string", + "enum": [ + "healthy", + "degraded", + "unavailable", + "unknown" + ] + }, + "commerceUpdateOutcomeName": { + "type": "string", + "enum": [ + "purchased", + "pending", + "cancelled", + "providerUnavailable", + "failed", + "entitlementsChanged" + ] + }, + "commerceUpdate": { + "type": "object", + "additionalProperties": false, + "required": [ + "updateId", + "providerId", + "mosaicProductId", + "configuration", + "outcome", + "occurredAt", + "diagnostics" + ], + "properties": { + "updateId": { + "$ref": "#/$defs/identifier" + }, + "operationId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "mosaicProductId": { + "$ref": "#/$defs/identifier" + }, + "configuration": { + "$ref": "#/$defs/configurationReference" + }, + "outcome": { + "$ref": "#/$defs/commerceUpdateOutcomeName" + }, + "transactionReference": { + "$ref": "#/$defs/safeProviderCode" + }, + "activeEntitlementKeys": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "occurredAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "commerceUpdateAcceptanceDisposition": { + "type": "string", + "enum": [ + "accepted", + "alreadyAccepted", + "rejectedStaleConfiguration", + "deliveryFailed" + ] + }, + "commerceUpdateAcceptance": { + "type": "object", + "additionalProperties": false, + "required": [ + "updateId", + "providerId", + "configuration", + "disposition", + "decidedAt", + "diagnostics" + ], + "properties": { + "updateId": { + "$ref": "#/$defs/identifier" + }, + "providerId": { + "$ref": "#/$defs/identifier" + }, + "configuration": { + "$ref": "#/$defs/configurationReference" + }, + "disposition": { + "$ref": "#/$defs/commerceUpdateAcceptanceDisposition" + }, + "decidedAt": { + "$ref": "#/$defs/utcTimestamp" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + }, + "providerDiagnostics": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerId", + "health", + "freshness", + "diagnostics" + ], + "properties": { + "providerId": { + "$ref": "#/$defs/identifier" + }, + "health": { + "$ref": "#/$defs/providerHealth" + }, + "freshness": { + "$ref": "#/$defs/metadataFreshness" + }, + "diagnostics": { + "$ref": "#/$defs/diagnostics" + } + } + } + } +} diff --git a/apps/api/internal/platform/protocolschema/schemas/paywall-v0.2.schema.json b/apps/api/internal/platform/protocolschema/schemas/paywall-v0.2.schema.json new file mode 100644 index 00000000..37073c8a --- /dev/null +++ b/apps/api/internal/platform/protocolschema/schemas/paywall-v0.2.schema.json @@ -0,0 +1,2526 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mosaic:protocol:schema:v0.2:paywall", + "title": "Mosaic Paywall Protocol 0.2 RC4", + "description": "The platform-neutral Mosaic native-paywall document contract for design tokens, visual effects, media backgrounds, two-axis sizing, screen and sheet presentation, generalized content, actions, and authored product-card states.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "id", + "revision", + "compatibility", + "localization", + "designSystem", + "assets", + "products", + "initialScreenId", + "screens" + ], + "properties": { + "schemaVersion": { + "$ref": "#/$defs/version" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "compatibility": { + "$ref": "#/$defs/documentCompatibility" + }, + "localization": { + "$ref": "#/$defs/localization" + }, + "designSystem": { + "$ref": "#/$defs/designSystem" + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/$defs/asset" + } + }, + "products": { + "type": "array", + "items": { + "$ref": "#/$defs/productReference" + } + }, + "initialScreenId": { + "$ref": "#/$defs/identifier" + }, + "screens": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "$ref": "#/$defs/screen" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "screens": { + "type": "array", + "minItems": 2 + } + }, + "required": [ + "screens" + ] + }, + "then": { + "properties": { + "screens": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/$defs/screen" + }, + { + "type": "object", + "properties": { + "accessibilityLabel": true + }, + "required": [ + "accessibilityLabel" + ] + } + ] + } + } + } + } + } + ], + "$defs": { + "version": { + "type": "string", + "const": "0.2" + }, + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$" + }, + "localizationKey": { + "type": "string", + "maxLength": 256, + "pattern": "^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)+$" + }, + "localeTag": { + "type": "string", + "pattern": "^[a-z]{2,3}(?:-(?:[A-Z]{2}|[0-9]{3}))?$" + }, + "localizedText": { + "type": "object", + "additionalProperties": false, + "required": [ + "default", + "localizationKey" + ], + "properties": { + "default": { + "type": "string", + "minLength": 1, + "maxLength": 5000 + }, + "localizationKey": { + "$ref": "#/$defs/localizationKey" + } + } + }, + "localeCatalog": { + "type": "object", + "additionalProperties": false, + "required": [ + "direction", + "strings" + ], + "properties": { + "direction": { + "type": "string", + "enum": [ + "ltr", + "rtl" + ] + }, + "strings": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/localizationKey" + }, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 5000 + } + } + } + }, + "localization": { + "type": "object", + "additionalProperties": false, + "required": [ + "defaultLocale", + "fallbackLocale", + "locales" + ], + "properties": { + "defaultLocale": { + "$ref": "#/$defs/localeTag" + }, + "fallbackLocale": { + "$ref": "#/$defs/localeTag" + }, + "locales": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/localeTag" + }, + "additionalProperties": { + "$ref": "#/$defs/localeCatalog" + } + } + } + }, + "capabilityName": { + "type": "string", + "enum": [ + "layout.scrollContainer", + "layout.stack", + "layout.sizing", + "layout.heightSizing", + "layout.outerInsets", + "navigation.screens", + "navigation.sheets", + "component.text", + "component.image", + "component.icon", + "component.featureList", + "component.productSelector", + "component.productCard", + "component.productBadge", + "component.button", + "component.carousel", + "component.switch", + "component.countdown", + "localization.catalogs", + "localization.rtl", + "localization.productTemplate", + "product.references", + "asset.bundledImage", + "asset.remoteImage", + "asset.bundledVideo", + "asset.remoteVideo", + "action.purchase", + "action.restore", + "action.close", + "action.navigateTo", + "action.navigateBack", + "action.openExternalUrl", + "accessibility.metadata", + "fallback.asset", + "fallback.product", + "outcome.normalized", + "style.colors", + "style.designTokens", + "style.gradientBackground", + "style.mediaBackground", + "style.shadow", + "style.box", + "style.clipping", + "style.typography", + "style.productCardStates", + "visibility.static", + "condition.switchVisibility" + ] + }, + "requiredCapability": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "$ref": "#/$defs/capabilityName" + }, + "version": { + "$ref": "#/$defs/version" + } + } + }, + "documentCompatibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "requiredCapabilities" + ], + "properties": { + "requiredCapabilities": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/requiredCapability" + } + } + } + }, + "logicalSize": { + "type": "number", + "minimum": 0, + "maximum": 4096 + }, + "positiveLogicalSize": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 4096 + }, + "edgeInsets": { + "type": "object", + "additionalProperties": false, + "required": [ + "top", + "start", + "bottom", + "end" + ], + "properties": { + "top": { + "$ref": "#/$defs/logicalSize" + }, + "start": { + "$ref": "#/$defs/logicalSize" + }, + "bottom": { + "$ref": "#/$defs/logicalSize" + }, + "end": { + "$ref": "#/$defs/logicalSize" + } + } + }, + "textAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end" + ] + }, + "semanticColor": { + "type": "string", + "enum": [ + "text.primary", + "text.secondary", + "surface.default", + "surface.elevated", + "action.primary", + "action.onPrimary", + "border.default", + "transparent" + ] + }, + "literalColor": { + "type": "string", + "pattern": "^#[0-9A-F]{8}$" + }, + "colorTokenReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "const": "colorToken" + }, + "id": { + "$ref": "#/$defs/identifier" + } + } + }, + "color": { + "oneOf": [ + { + "$ref": "#/$defs/semanticColor" + }, + { + "$ref": "#/$defs/literalColor" + }, + { + "$ref": "#/$defs/colorTokenReference" + } + ] + }, + "axisSizingValue": { + "oneOf": [ + { + "type": "string", + "enum": [ + "fit", + "fill" + ] + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "value" + ], + "properties": { + "mode": { + "const": "fixed" + }, + "value": { + "$ref": "#/$defs/positiveLogicalSize" + } + } + } + ] + }, + "boxSizing": { + "type": "object", + "additionalProperties": false, + "required": [ + "width", + "height" + ], + "properties": { + "width": { + "$ref": "#/$defs/axisSizingValue" + }, + "height": { + "$ref": "#/$defs/axisSizingValue" + } + } + }, + "gradientStop": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "color" + ], + "properties": { + "position": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "color": { + "$ref": "#/$defs/color" + } + } + }, + "gradientStops": { + "type": "array", + "minItems": 2, + "maxItems": 8, + "items": { + "$ref": "#/$defs/gradientStop" + } + }, + "normalizedPoint": { + "type": "object", + "additionalProperties": false, + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "colorBackground": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "color" + }, + "value": { + "$ref": "#/$defs/color" + } + } + }, + "linearGradientBackground": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "angle", + "stops" + ], + "properties": { + "type": { + "const": "linearGradient" + }, + "angle": { + "description": "Physical canvas direction from the first stop to the last: 0 degrees is left-to-right, 90 degrees is top-to-bottom, angles increase clockwise, and RTL never mirrors the angle.", + "type": "number", + "minimum": 0, + "maximum": 360 + }, + "stops": { + "$ref": "#/$defs/gradientStops" + } + } + }, + "radialGradientBackground": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "center", + "radius", + "stops" + ], + "properties": { + "type": { + "const": "radialGradient" + }, + "center": { + "$ref": "#/$defs/normalizedPoint" + }, + "radius": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 2 + }, + "stops": { + "$ref": "#/$defs/gradientStops" + } + } + }, + "mediaContentMode": { + "type": "string", + "enum": [ + "fit", + "fill" + ] + }, + "imageBackground": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "assetId", + "contentMode", + "fallbackColor" + ], + "properties": { + "type": { + "const": "image" + }, + "assetId": { + "$ref": "#/$defs/identifier" + }, + "contentMode": { + "$ref": "#/$defs/mediaContentMode" + }, + "fallbackColor": { + "$ref": "#/$defs/color" + } + } + }, + "videoBackground": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "assetId", + "contentMode", + "fallbackColor" + ], + "properties": { + "type": { + "const": "video" + }, + "assetId": { + "$ref": "#/$defs/identifier" + }, + "contentMode": { + "$ref": "#/$defs/mediaContentMode" + }, + "posterAssetId": { + "$ref": "#/$defs/identifier" + }, + "fallbackColor": { + "$ref": "#/$defs/color" + } + } + }, + "backgroundTokenReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "const": "backgroundToken" + }, + "id": { + "$ref": "#/$defs/identifier" + } + } + }, + "background": { + "oneOf": [ + { + "$ref": "#/$defs/colorBackground" + }, + { + "$ref": "#/$defs/linearGradientBackground" + }, + { + "$ref": "#/$defs/radialGradientBackground" + }, + { + "$ref": "#/$defs/imageBackground" + }, + { + "$ref": "#/$defs/videoBackground" + }, + { + "$ref": "#/$defs/backgroundTokenReference" + } + ] + }, + "inlineShadow": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "color", + "offsetX", + "offsetY", + "blurRadius" + ], + "properties": { + "type": { + "const": "shadow" + }, + "color": { + "$ref": "#/$defs/color" + }, + "offsetX": { + "type": "number", + "minimum": -4096, + "maximum": 4096 + }, + "offsetY": { + "type": "number", + "minimum": -4096, + "maximum": 4096 + }, + "blurRadius": { + "$ref": "#/$defs/logicalSize" + } + } + }, + "shadowTokenReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "const": "shadowToken" + }, + "id": { + "$ref": "#/$defs/identifier" + } + } + }, + "shadow": { + "oneOf": [ + { + "$ref": "#/$defs/inlineShadow" + }, + { + "$ref": "#/$defs/shadowTokenReference" + } + ] + }, + "designTokenName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "colorToken": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "value" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/designTokenName" + }, + "value": { + "$ref": "#/$defs/color" + } + } + }, + "backgroundToken": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "value" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/designTokenName" + }, + "value": { + "$ref": "#/$defs/background" + } + } + }, + "shadowToken": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "value" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/designTokenName" + }, + "value": { + "$ref": "#/$defs/shadow" + } + } + }, + "designSystem": { + "type": "object", + "additionalProperties": false, + "required": [ + "colors", + "backgrounds", + "shadows" + ], + "properties": { + "colors": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/colorToken" + } + }, + "backgrounds": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/backgroundToken" + } + }, + "shadows": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/shadowToken" + } + } + } + }, + "border": { + "type": "object", + "additionalProperties": false, + "required": [ + "color", + "width" + ], + "properties": { + "color": { + "$ref": "#/$defs/color" + }, + "width": { + "$ref": "#/$defs/logicalSize" + } + } + }, + "borderOverride": { + "type": "object", + "additionalProperties": false, + "properties": { + "color": { + "$ref": "#/$defs/color" + }, + "width": { + "$ref": "#/$defs/logicalSize" + } + } + }, + "boxAppearance": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "background": { + "$ref": "#/$defs/background" + }, + "border": { + "$ref": "#/$defs/border" + }, + "cornerRadius": { + "$ref": "#/$defs/logicalSize" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "padding": { + "$ref": "#/$defs/edgeInsets" + }, + "shadow": { + "$ref": "#/$defs/shadow" + } + } + }, + "containerAppearance": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "background": { + "$ref": "#/$defs/background" + }, + "border": { + "$ref": "#/$defs/border" + }, + "cornerRadius": { + "$ref": "#/$defs/logicalSize" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "clipContent": { + "type": "boolean" + }, + "shadow": { + "$ref": "#/$defs/shadow" + } + } + }, + "typographyStyle": { + "type": "string", + "enum": [ + "display", + "title", + "heading", + "body", + "label", + "caption" + ] + }, + "fontWeight": { + "type": "string", + "enum": [ + "regular", + "medium", + "semibold", + "bold" + ] + }, + "baseTypography": { + "type": "object", + "additionalProperties": false, + "required": [ + "style", + "fontSize", + "lineHeightMultiplier", + "weight", + "color", + "alignment" + ], + "properties": { + "style": { + "$ref": "#/$defs/typographyStyle" + }, + "fontSize": { + "type": "number", + "minimum": 8, + "maximum": 96 + }, + "lineHeightMultiplier": { + "type": "number", + "minimum": 0.8, + "maximum": 3 + }, + "weight": { + "$ref": "#/$defs/fontWeight" + }, + "color": { + "$ref": "#/$defs/color" + }, + "alignment": { + "$ref": "#/$defs/textAlignment" + } + } + }, + "typography": { + "type": "object", + "additionalProperties": false, + "required": [ + "style", + "fontSize", + "lineHeightMultiplier", + "weight", + "color", + "alignment" + ], + "dependentRequired": { + "maxLines": [ + "overflow" + ], + "overflow": [ + "maxLines" + ] + }, + "properties": { + "style": { + "$ref": "#/$defs/typographyStyle" + }, + "fontSize": { + "type": "number", + "minimum": 8, + "maximum": 96 + }, + "lineHeightMultiplier": { + "type": "number", + "minimum": 0.8, + "maximum": 3 + }, + "weight": { + "$ref": "#/$defs/fontWeight" + }, + "color": { + "$ref": "#/$defs/color" + }, + "alignment": { + "$ref": "#/$defs/textAlignment" + }, + "maxLines": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "overflow": { + "type": "string", + "enum": [ + "clip", + "ellipsis" + ] + } + } + }, + "visibility": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "always" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "hidden" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "switchId", + "equals" + ], + "properties": { + "mode": { + "const": "switch" + }, + "switchId": { + "$ref": "#/$defs/identifier" + }, + "equals": { + "type": "boolean" + } + } + } + ] + }, + "controlAccessibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "label" + ], + "properties": { + "label": { + "$ref": "#/$defs/localizedText" + }, + "hint": { + "$ref": "#/$defs/localizedText" + } + } + }, + "textAccessibility": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "role" + ], + "properties": { + "role": { + "const": "text" + }, + "label": { + "$ref": "#/$defs/localizedText" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "role", + "level" + ], + "properties": { + "role": { + "const": "heading" + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "label": { + "$ref": "#/$defs/localizedText" + } + } + } + ] + }, + "imageAccessibility": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "hidden" + ], + "properties": { + "hidden": { + "const": true + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "hidden", + "label" + ], + "properties": { + "hidden": { + "const": false + }, + "label": { + "$ref": "#/$defs/localizedText" + } + } + } + ] + }, + "bundledAssetSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "key" + ], + "properties": { + "type": { + "const": "bundled" + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + } + } + }, + "remoteAssetSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "const": "remote" + }, + "url": { + "$ref": "#/$defs/externalUrl" + } + } + }, + "assetSource": { + "oneOf": [ + { + "$ref": "#/$defs/bundledAssetSource" + }, + { + "$ref": "#/$defs/remoteAssetSource" + } + ] + }, + "imageAssetFallback": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "placeholder" + }, + "value": { + "$ref": "#/$defs/localizedText" + } + } + }, + "imageAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "source", + "fallback" + ], + "properties": { + "type": { + "const": "image" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "source": { + "$ref": "#/$defs/assetSource" + }, + "fallback": { + "$ref": "#/$defs/imageAssetFallback" + } + } + }, + "videoAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "source" + ], + "properties": { + "type": { + "const": "video" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "source": { + "$ref": "#/$defs/assetSource" + } + } + }, + "asset": { + "oneOf": [ + { + "$ref": "#/$defs/imageAsset" + }, + { + "$ref": "#/$defs/videoAsset" + } + ] + }, + "productReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "productId", + "label" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "productId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "label": { + "$ref": "#/$defs/localizedText" + } + } + }, + "screenPresentation": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "screen" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "sheet" + } + } + } + ] + }, + "screen": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "presentation", + "layout" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "accessibilityLabel": { + "$ref": "#/$defs/localizedText" + }, + "presentation": { + "$ref": "#/$defs/screenPresentation" + }, + "layout": { + "$ref": "#/$defs/scrollContainer" + } + } + }, + "scrollContainer": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "axis", + "safeArea", + "showsIndicators", + "content" + ], + "properties": { + "type": { + "const": "scrollContainer" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "axis": { + "const": "vertical" + }, + "safeArea": { + "const": "respect" + }, + "showsIndicators": { + "type": "boolean" + }, + "background": { + "$ref": "#/$defs/background" + }, + "content": { + "$ref": "#/$defs/stack" + } + } + }, + "stack": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "direction", + "gap", + "padding", + "mainAxisDistribution", + "crossAxisAlignment", + "children" + ], + "properties": { + "type": { + "const": "stack" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "direction": { + "type": "string", + "enum": [ + "vertical", + "horizontal" + ] + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "padding": { + "$ref": "#/$defs/edgeInsets" + }, + "mainAxisDistribution": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "spaceBetween" + ] + }, + "crossAxisAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "appearance": { + "$ref": "#/$defs/containerAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "children": { + "type": "array", + "minItems": 0, + "items": { + "$ref": "#/$defs/node" + } + } + } + }, + "node": { + "oneOf": [ + { + "$ref": "#/$defs/stack" + }, + { + "$ref": "#/$defs/textComponent" + }, + { + "$ref": "#/$defs/imageComponent" + }, + { + "$ref": "#/$defs/iconComponent" + }, + { + "$ref": "#/$defs/featureListComponent" + }, + { + "$ref": "#/$defs/productSelectorComponent" + }, + { + "$ref": "#/$defs/buttonComponent" + }, + { + "$ref": "#/$defs/carouselComponent" + }, + { + "$ref": "#/$defs/switchComponent" + }, + { + "$ref": "#/$defs/countdownComponent" + } + ] + }, + "textComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "value", + "typography", + "accessibility" + ], + "properties": { + "type": { + "const": "text" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "value": { + "$ref": "#/$defs/localizedText" + }, + "typography": { + "$ref": "#/$defs/typography" + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/textAccessibility" + } + } + }, + "imageComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "assetId", + "contentMode", + "accessibility" + ], + "properties": { + "type": { + "const": "image" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "assetId": { + "$ref": "#/$defs/identifier" + }, + "aspectRatio": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 10 + }, + "contentMode": { + "type": "string", + "enum": [ + "fit", + "fill" + ] + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/imageAccessibility" + } + } + }, + "iconName": { + "type": "string", + "enum": [ + "checkmark", + "close", + "lock", + "restore", + "externalLink", + "arrowBackward", + "arrowForward", + "chevronBackward", + "chevronForward" + ] + }, + "iconComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "name", + "size", + "color", + "accessibility" + ], + "properties": { + "type": { + "const": "icon" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/iconName" + }, + "size": { + "$ref": "#/$defs/positiveLogicalSize" + }, + "color": { + "$ref": "#/$defs/color" + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/imageAccessibility" + } + } + }, + "featureListItem": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "text" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "text": { + "$ref": "#/$defs/localizedText" + } + } + }, + "featureListComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "marker", + "gap", + "markerColor", + "items", + "typography", + "accessibility" + ], + "properties": { + "type": { + "const": "featureList" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "marker": { + "const": "checkmark" + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "markerColor": { + "$ref": "#/$defs/color" + }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/featureListItem" + } + }, + "typography": { + "$ref": "#/$defs/baseTypography" + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/controlAccessibility" + } + } + }, + "unavailableProductFallback": { + "type": "object", + "additionalProperties": false, + "required": [ + "selection", + "whenNoneAvailable", + "message" + ], + "properties": { + "selection": { + "const": "firstAvailable" + }, + "whenNoneAvailable": { + "const": "showMessageAndDisablePurchase" + }, + "message": { + "$ref": "#/$defs/localizedText" + } + } + }, + "productCardDefaultStyle": { + "type": "object", + "additionalProperties": false, + "required": [ + "background", + "border", + "cornerRadius", + "padding", + "opacity" + ], + "properties": { + "background": { + "$ref": "#/$defs/background" + }, + "border": { + "$ref": "#/$defs/border" + }, + "cornerRadius": { + "$ref": "#/$defs/logicalSize" + }, + "padding": { + "$ref": "#/$defs/edgeInsets" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "shadow": { + "$ref": "#/$defs/shadow" + } + } + }, + "edgeInsetsOverride": { + "type": "object", + "additionalProperties": false, + "properties": { + "top": { + "$ref": "#/$defs/logicalSize" + }, + "start": { + "$ref": "#/$defs/logicalSize" + }, + "bottom": { + "$ref": "#/$defs/logicalSize" + }, + "end": { + "$ref": "#/$defs/logicalSize" + } + } + }, + "productCardSelectedStyle": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { + "$ref": "#/$defs/background" + }, + "border": { + "$ref": "#/$defs/borderOverride" + }, + "cornerRadius": { + "$ref": "#/$defs/logicalSize" + }, + "padding": { + "$ref": "#/$defs/edgeInsetsOverride" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "shadow": { + "$ref": "#/$defs/shadow" + } + } + }, + "productCardStyles": { + "type": "object", + "additionalProperties": false, + "required": [ + "default", + "selected" + ], + "properties": { + "default": { + "$ref": "#/$defs/productCardDefaultStyle" + }, + "selected": { + "$ref": "#/$defs/productCardSelectedStyle" + } + } + }, + "productCardAccessibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "label" + ], + "properties": { + "label": { + "$ref": "#/$defs/localizedText" + } + } + }, + "productBadgePlacement": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "nested" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "anchor", + "inset" + ], + "properties": { + "mode": { + "const": "overlay" + }, + "anchor": { + "type": "string", + "enum": [ + "topStart", + "topEnd", + "bottomStart", + "bottomEnd" + ] + }, + "inset": { + "type": "number", + "minimum": 0, + "maximum": 64 + } + } + } + ] + }, + "productCardPassiveStack": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "direction", + "gap", + "padding", + "mainAxisDistribution", + "crossAxisAlignment", + "children" + ], + "properties": { + "type": { + "const": "stack" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "direction": { + "type": "string", + "enum": [ + "vertical", + "horizontal" + ] + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "padding": { + "$ref": "#/$defs/edgeInsets" + }, + "mainAxisDistribution": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "spaceBetween" + ] + }, + "crossAxisAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "appearance": { + "$ref": "#/$defs/containerAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "children": { + "type": "array", + "minItems": 0, + "items": { + "$ref": "#/$defs/productCardPassiveNode" + } + } + } + }, + "productCardPassiveNode": { + "oneOf": [ + { + "$ref": "#/$defs/productCardPassiveStack" + }, + { + "$ref": "#/$defs/textComponent" + }, + { + "$ref": "#/$defs/imageComponent" + }, + { + "$ref": "#/$defs/iconComponent" + }, + { + "$ref": "#/$defs/featureListComponent" + }, + { + "$ref": "#/$defs/countdownComponent" + } + ] + }, + "productBadgeComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "placement", + "direction", + "gap", + "mainAxisDistribution", + "crossAxisAlignment", + "children", + "styles" + ], + "properties": { + "type": { + "const": "productBadge" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "placement": { + "$ref": "#/$defs/productBadgePlacement" + }, + "direction": { + "type": "string", + "enum": [ + "vertical", + "horizontal" + ] + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "mainAxisDistribution": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "spaceBetween" + ] + }, + "crossAxisAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "children": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "$ref": "#/$defs/productCardPassiveNode" + } + }, + "styles": { + "$ref": "#/$defs/productCardStyles" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + } + } + }, + "productCardChild": { + "oneOf": [ + { + "$ref": "#/$defs/productCardPassiveNode" + }, + { + "$ref": "#/$defs/productBadgeComponent" + } + ] + }, + "productCardComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "productReferenceId", + "direction", + "gap", + "mainAxisDistribution", + "crossAxisAlignment", + "children", + "styles" + ], + "properties": { + "type": { + "const": "productCard" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "productReferenceId": { + "$ref": "#/$defs/identifier" + }, + "direction": { + "type": "string", + "enum": [ + "vertical", + "horizontal" + ] + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "mainAxisDistribution": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "spaceBetween" + ] + }, + "crossAxisAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "children": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/productCardChild" + } + }, + "styles": { + "$ref": "#/$defs/productCardStyles" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "clipContent": { + "const": false + }, + "accessibility": { + "$ref": "#/$defs/productCardAccessibility" + } + } + }, + "productSelectorComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "direction", + "gap", + "crossAxisAlignment", + "initialProductCardId", + "cards", + "unavailableFallback", + "accessibility" + ], + "properties": { + "type": { + "const": "productSelector" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "direction": { + "type": "string", + "enum": [ + "vertical", + "horizontal" + ] + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "crossAxisAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "initialProductCardId": { + "$ref": "#/$defs/identifier" + }, + "cards": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { + "$ref": "#/$defs/productCardComponent" + } + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "unavailableFallback": { + "$ref": "#/$defs/unavailableProductFallback" + }, + "accessibility": { + "$ref": "#/$defs/controlAccessibility" + } + } + }, + "purchaseAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "productSelectorId" + ], + "properties": { + "type": { + "const": "purchase" + }, + "productSelectorId": { + "$ref": "#/$defs/identifier" + } + } + }, + "restoreAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "restore" + } + } + }, + "closeAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "close" + } + } + }, + "navigateToAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "screenId" + ], + "properties": { + "type": { + "const": "navigateTo" + }, + "screenId": { + "$ref": "#/$defs/identifier" + } + } + }, + "navigateBackAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "navigateBack" + } + } + }, + "externalUrl": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,5})?(?:[/?#][^\\s\\\\\\u0000-\\u001F\\u007F]*)?$" + }, + "openExternalUrlAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "const": "openExternalUrl" + }, + "url": { + "$ref": "#/$defs/externalUrl" + } + } + }, + "buttonAction": { + "oneOf": [ + { + "$ref": "#/$defs/purchaseAction" + }, + { + "$ref": "#/$defs/restoreAction" + }, + { + "$ref": "#/$defs/closeAction" + }, + { + "$ref": "#/$defs/navigateToAction" + }, + { + "$ref": "#/$defs/navigateBackAction" + }, + { + "$ref": "#/$defs/openExternalUrlAction" + } + ] + }, + "buttonComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "direction", + "gap", + "mainAxisDistribution", + "crossAxisAlignment", + "children", + "action", + "accessibility" + ], + "properties": { + "type": { + "const": "button" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "direction": { + "type": "string", + "enum": [ + "vertical", + "horizontal" + ] + }, + "gap": { + "$ref": "#/$defs/logicalSize" + }, + "mainAxisDistribution": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "spaceBetween" + ] + }, + "crossAxisAlignment": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "children": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/node" + } + }, + "inProgressChildren": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/node" + } + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "action": { + "$ref": "#/$defs/buttonAction" + }, + "accessibility": { + "$ref": "#/$defs/controlAccessibility" + } + } + }, + "carouselPage": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "accessibilityLabel", + "content" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "accessibilityLabel": { + "$ref": "#/$defs/localizedText" + }, + "content": { + "$ref": "#/$defs/stack" + } + } + }, + "carouselComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "initialPageIndex", + "showsIndicators", + "pages", + "accessibility" + ], + "properties": { + "type": { + "const": "carousel" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "initialPageIndex": { + "type": "integer", + "minimum": 0, + "maximum": 19 + }, + "showsIndicators": { + "type": "boolean" + }, + "pages": { + "type": "array", + "minItems": 2, + "maxItems": 20, + "items": { + "$ref": "#/$defs/carouselPage" + } + }, + "appearance": { + "$ref": "#/$defs/containerAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/controlAccessibility" + } + } + }, + "switchComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "label", + "initialValue", + "typography", + "offTrackColor", + "onTrackColor", + "thumbColor", + "accessibility" + ], + "properties": { + "type": { + "const": "switch" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "label": { + "$ref": "#/$defs/localizedText" + }, + "initialValue": { + "type": "boolean" + }, + "typography": { + "$ref": "#/$defs/baseTypography" + }, + "offTrackColor": { + "$ref": "#/$defs/color" + }, + "onTrackColor": { + "$ref": "#/$defs/color" + }, + "thumbColor": { + "$ref": "#/$defs/color" + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/controlAccessibility" + } + } + }, + "countdownComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id", + "endsAt", + "largestUnit", + "smallestUnit", + "completedText", + "typography", + "accessibility" + ], + "properties": { + "type": { + "const": "countdown" + }, + "id": { + "$ref": "#/$defs/identifier" + }, + "endsAt": { + "type": "string", + "pattern": "^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$" + }, + "largestUnit": { + "type": "string", + "enum": [ + "day", + "hour", + "minute", + "second" + ] + }, + "smallestUnit": { + "type": "string", + "enum": [ + "day", + "hour", + "minute", + "second" + ] + }, + "completedText": { + "$ref": "#/$defs/localizedText" + }, + "typography": { + "$ref": "#/$defs/baseTypography" + }, + "appearance": { + "$ref": "#/$defs/boxAppearance" + }, + "sizing": { + "$ref": "#/$defs/boxSizing" + }, + "outerInsets": { + "$ref": "#/$defs/edgeInsets" + }, + "visibility": { + "$ref": "#/$defs/visibility" + }, + "accessibility": { + "$ref": "#/$defs/textAccessibility" + } + } + } + } +} diff --git a/apps/api/internal/platform/requestvalidation/requestvalidation.go b/apps/api/internal/platform/requestvalidation/requestvalidation.go index f9817a0b..51334e9b 100644 --- a/apps/api/internal/platform/requestvalidation/requestvalidation.go +++ b/apps/api/internal/platform/requestvalidation/requestvalidation.go @@ -2,6 +2,7 @@ package requestvalidation import ( "errors" + "regexp" "strings" "unicode" @@ -10,6 +11,24 @@ import ( const requestField = "_request" +// placementKeyPattern mirrors the PostgreSQL CHECK constraints on +// placements.key, placement_aliases.key, and +// placement_attribute_definitions.key exactly. Those three columns are the only +// keys in the schema that forbid hyphens, and the transport layer previously +// validated length alone — so a hyphenated key (the form the Product and +// Project key patterns accept, and the form an operator naturally types) passed +// validation, reached PostgreSQL, and came back as an unexplained 500 instead of +// a field error naming the offending key. +var placementKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// PlacementKey validates a Placement, Placement alias, or Placement attribute +// key against the database's own format rule, so an invalid key is rejected at +// the boundary with a 422 that says what is wrong. +func PlacementKey() validation.Rule { + return validation.Match(placementKeyPattern). + Error("must start with a lowercase letter and contain only lowercase letters, digits, and underscores") +} + // FieldErrors converts Ozzo validation errors to Mosaic's field-error shape. // The boolean is false for non-validation and Ozzo internal errors so callers // can map those failures to a safe internal response instead. diff --git a/apps/api/internal/platform/requestvalidation/requestvalidation_test.go b/apps/api/internal/platform/requestvalidation/requestvalidation_test.go index 62b41b66..c86bf7e8 100644 --- a/apps/api/internal/platform/requestvalidation/requestvalidation_test.go +++ b/apps/api/internal/platform/requestvalidation/requestvalidation_test.go @@ -3,6 +3,7 @@ package requestvalidation import ( "errors" "reflect" + "strings" "testing" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -62,3 +63,25 @@ func TestFieldErrorsRejectsInternalValidationErrors(t *testing.T) { t.Fatalf("FieldErrors returned %#v, want internal failure", fields) } } + +// TestPlacementKeyMatchesDatabaseConstraint protects against the transport +// layer accepting a key PostgreSQL will reject. Placement, alias, and attribute +// keys are the only keys in the schema that forbid hyphens; when the boundary +// checked length alone, a hyphenated key became a 500 with no field error and no +// logged cause. The pattern here must stay identical to the CHECK constraints in +// migrations 00009 and 00011. +func TestPlacementKeyMatchesDatabaseConstraint(t *testing.T) { + rule := PlacementKey() + accepted := []string{"onboarding", "drill_onboarding", "a", "p1", strings.Repeat("a", 64)} + for _, key := range accepted { + if err := rule.Validate(key); err != nil { + t.Errorf("key %q = %v, want accepted", key, err) + } + } + rejected := []string{"drill-onboarding", "Onboarding", "1onboarding", "_onboarding", "onboarding key", "onboarding.key", strings.Repeat("a", 65)} + for _, key := range rejected { + if err := rule.Validate(key); err == nil { + t.Errorf("key %q was accepted; PostgreSQL rejects it", key) + } + } +} diff --git a/apps/api/internal/platform/telemetry/telemetry.go b/apps/api/internal/platform/telemetry/telemetry.go index 5e61cb59..96384c4f 100644 --- a/apps/api/internal/platform/telemetry/telemetry.go +++ b/apps/api/internal/platform/telemetry/telemetry.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" + "github.com/rs/zerolog" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/propagation" @@ -13,24 +15,39 @@ import ( "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.41.0" + + "github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo" ) type Config struct { ServiceName string Environment string OTLPEndpoint string + // Logger receives OpenTelemetry's own internal errors (export failures, + // dropped batches). Without it the SDK writes them to the standard library + // logger, which bypasses Mosaic's JSON log stream and makes exporter + // breakage invisible to log-based alerting. + Logger zerolog.Logger } type Shutdown func(context.Context) error func New(ctx context.Context, cfg Config) (Shutdown, error) { + otel.SetErrorHandler(errorHandler{logger: cfg.Logger}) + build := buildinfo.Current() + attributes := []attribute.KeyValue{ + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(build.Version), + semconv.DeploymentEnvironmentNameKey.String(cfg.Environment), + } + // The commit is release identity, never a secret, and is what an operator + // correlates a trace back to a source tree with. + if build.Commit != "" { + attributes = append(attributes, attribute.String("service.commit", build.Commit)) + } serviceResource, err := resource.Merge( resource.Default(), - resource.NewWithAttributes( - semconv.SchemaURL, - semconv.ServiceName(cfg.ServiceName), - semconv.DeploymentEnvironmentNameKey.String(cfg.Environment), - ), + resource.NewWithAttributes(semconv.SchemaURL, attributes...), ) if err != nil { return nil, fmt.Errorf("create telemetry resource: %w", err) @@ -70,3 +87,15 @@ func New(ctx context.Context, cfg Config) (Shutdown, error) { return errors.Join(metricProvider.Shutdown(ctx), provider.Shutdown(ctx)) }, nil } + +// errorHandler routes OpenTelemetry SDK errors into Mosaic's structured log +// stream so an operator sees exporter failures in the same JSON pipeline as +// every other backend error. +type errorHandler struct{ logger zerolog.Logger } + +func (h errorHandler) Handle(err error) { + if err == nil { + return + } + h.logger.Error().Err(err).Str("component", "opentelemetry").Msg("opentelemetry sdk error") +} diff --git a/apps/api/internal/providercredential/cipher.go b/apps/api/internal/providercredential/cipher.go index 6f75f6b0..db1f3412 100644 --- a/apps/api/internal/providercredential/cipher.go +++ b/apps/api/internal/providercredential/cipher.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "io" + "sort" "strings" ) @@ -260,3 +261,23 @@ func fingerprint(key, plaintext []byte) []byte { } var _ CredentialCipher = (*AESGCMCipher)(nil) + +// ValidateKeyring reports whether an encoded keyring is structurally usable +// without retaining any key material. It never echoes the supplied value. +func ValidateKeyring(encoded string) error { + _, _, err := parseKeyring(encoded) + return err +} + +// ActiveKeyID is the key identifier new envelopes are sealed under. +func (c *AESGCMCipher) ActiveKeyID() string { return c.activeKeyID } + +// KeyIDs lists every key identifier the keyring can decrypt with, sorted. +func (c *AESGCMCipher) KeyIDs() []string { + ids := make([]string, 0, len(c.keys)) + for id := range c.keys { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} diff --git a/apps/api/internal/providercredential/cipher_test.go b/apps/api/internal/providercredential/cipher_test.go index ad50db03..d66c5cdd 100644 --- a/apps/api/internal/providercredential/cipher_test.go +++ b/apps/api/internal/providercredential/cipher_test.go @@ -92,7 +92,7 @@ func TestKeyRotationKeepsOldEnvelopesReadableAndUsesOnlyActiveKeyForWrites(t *te } rotated, err := NewAESGCMCipher( keyring("new", fmt.Sprintf(`"old":%q,"new":%q`, oldKey, newKey)), - bytes.NewReader(bytes.Repeat([]byte{2}, 12)), + bytes.NewReader(bytes.Repeat([]byte{2}, 12*4)), ) if err != nil { t.Fatalf("new rotated cipher: %v", err) @@ -107,4 +107,20 @@ func TestKeyRotationKeepsOldEnvelopesReadableAndUsesOnlyActiveKeyForWrites(t *te if newEnvelope.KeyID != "new" { t.Fatalf("replacement key ID = %q, want active key", newEnvelope.KeyID) } + + // `keyring rotate` re-seals every envelope under the active key so the old + // key can eventually be removed. If a re-sealed envelope did not decrypt + // under a keyring holding only the new key, retiring the old key would make + // every provider credential permanently unreadable. + resealed, err := rotated.Encrypt([]byte("credential-value"), scope) + if err != nil { + t.Fatalf("re-seal envelope under the active key: %v", err) + } + onlyNew, err := NewAESGCMCipher(keyring("new", fmt.Sprintf(`"new":%q`, newKey)), bytes.NewReader(bytes.Repeat([]byte{3}, 12))) + if err != nil { + t.Fatalf("new post-rotation cipher: %v", err) + } + if plaintext, err := onlyNew.Decrypt(resealed, scope); err != nil || string(plaintext) != "credential-value" { + t.Fatalf("re-sealed envelope after removing the retired key = %q, %v", plaintext, err) + } } diff --git a/apps/api/internal/transport/analytics/handler.go b/apps/api/internal/transport/analytics/handler.go index cd12e4ed..8fcd9b23 100644 --- a/apps/api/internal/transport/analytics/handler.go +++ b/apps/api/internal/transport/analytics/handler.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "io" - "net" "net/http" "strconv" "strings" @@ -18,6 +17,7 @@ import ( "github.com/Mujhtech/mosaic/apps/api/internal/analytics" "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/httpmiddleware" "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" ) @@ -33,12 +33,32 @@ type Handler struct { eventLimiter EventLimiter } -func RegisterPublicRoutes(router chi.Router, service *analytics.Service, ip, key Limiter, events EventLimiter) { +// RegisterPublicRoutes mounts the SDK ingestion endpoint. ingestMiddleware +// carries the per-route timeout override, because a 100-event batch legitimately +// takes longer than the global request budget. +func RegisterPublicRoutes(router chi.Router, service *analytics.Service, ip, key Limiter, events EventLimiter, ingestMiddleware ...func(http.Handler) http.Handler) { h := &Handler{service: service, ipLimiter: ip, keyLimiter: key, eventLimiter: events} - router.Post("/sdk/events/batch", h.ingest) + router.With(nonNil(ingestMiddleware)...).Post("/sdk/events/batch", h.ingest) } -func RegisterProjectRoutes(router chi.Router, service *analytics.Service) { + +// nonNil drops unset optional middleware so callers can pass a nil override. +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 +} + +// RegisterProjectRoutes mounts the authenticated analytics routes. +// exportMiddleware carries the export-specific rate limit. Every route it wraps +// enqueues a background job that scans analytics history, so those routes are +// bounded separately from the baseline API limit shared by dashboard reads. +func RegisterProjectRoutes(router chi.Router, service *analytics.Service, exportMiddleware ...func(http.Handler) http.Handler) { h := &Handler{service: service} + export := nonNil(exportMiddleware) router.Route("/environments/{environmentId}/analytics", func(a chi.Router) { a.Get("/settings", h.settings) a.Put("/settings", h.updateSettings) @@ -49,13 +69,13 @@ func RegisterProjectRoutes(router chi.Router, service *analytics.Service) { a.Get("/product-availability-failures", h.productFailures) a.Get("/breakdowns/{dimension}", h.breakdown) a.Get("/freshness", h.freshness) - a.Post("/exports", h.eventExport) + a.With(export...).Post("/exports", h.eventExport) }) - router.Post("/environments/{environmentId}/experiments/{experimentId}/exports", h.experimentExport) + router.With(export...).Post("/environments/{environmentId}/experiments/{experimentId}/exports", h.experimentExport) router.Route("/analytics/privacy", func(p chi.Router) { p.Post("/preview", h.preview) - p.Post("/exports", h.userExport) - p.Post("/deletions", h.deletion) + p.With(export...).Post("/exports", h.userExport) + p.With(export...).Post("/deletions", h.deletion) }) router.Get("/analytics/jobs/{jobId}", h.job) router.Get("/analytics/jobs/{jobId}/download", h.download) @@ -81,10 +101,7 @@ func (h *Handler) ingest(w http.ResponseWriter, r *http.Request) { writeError(w, r, analytics.ErrInvalidBatch) return } - host, _, _ := net.SplitHostPort(r.RemoteAddr) - if host == "" { - host = r.RemoteAddr - } + host := httpmiddleware.ClientIP(r) if ok, retry := h.ipLimiter.Allow("ip:" + host); !ok { w.Header().Set("Retry-After", retryHeader(retry)) writeError(w, r, analytics.ErrRateLimited) diff --git a/apps/api/internal/transport/browserauth/handler.go b/apps/api/internal/transport/browserauth/handler.go index 79d889e3..509070e9 100644 --- a/apps/api/internal/transport/browserauth/handler.go +++ b/apps/api/internal/transport/browserauth/handler.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "io" - "net" "net/http" "strconv" "strings" @@ -16,6 +15,7 @@ import ( "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/Mujhtech/mosaic/apps/api/internal/browserauth" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/httpmiddleware" "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" "github.com/Mujhtech/mosaic/apps/api/internal/platform/requestvalidation" ) @@ -48,6 +48,19 @@ func RegisterRoutes(router chi.Router, service *browserauth.Service, cfg Config) }) } +// emailAddress validates the shape of an email address without resolving it. +// +// ozzo's `is.Email` is `govalidator.IsExistingEmail`, which performs a live +// net.LookupMX (then net.LookupIP) on the domain of every submitted address. +// That made administrator bootstrap depend on outbound DNS from the API +// container and rejected every internal-only domain (`.internal`, `.local`, +// an intranet zone, an RFC 2606 `.test`/`.example` name), so a self-hosted +// installation on an isolated network could not create its first user. It also +// put an unbounded, uncancellable network call inside two unauthenticated +// handlers. Mosaic validates the format only; deliverability is not something +// an authentication boundary can or should assert. +var emailAddress = is.EmailFormat + type signupRequest struct { Email string `json:"email"` Name string `json:"name"` @@ -56,7 +69,7 @@ type signupRequest struct { func (request *signupRequest) Validate() error { return validation.ValidateStruct(request, - validation.Field(&request.Email, validation.Required, is.Email, validation.Length(3, 320)), + validation.Field(&request.Email, validation.Required, emailAddress, validation.Length(3, 320)), validation.Field(&request.Name, validation.Required, validation.Length(1, 120)), validation.Field(&request.Password, validation.Required, validation.Length(12, 72)), ) @@ -69,7 +82,7 @@ type loginRequest struct { func (request *loginRequest) Validate() error { return validation.ValidateStruct(request, - validation.Field(&request.Email, validation.Required, is.Email, validation.Length(3, 320)), + validation.Field(&request.Email, validation.Required, emailAddress, validation.Length(3, 320)), validation.Field(&request.Password, validation.Required, validation.Length(1, 72)), ) } @@ -106,7 +119,7 @@ func (h *Handler) signup(w http.ResponseWriter, r *http.Request) { if !h.requireTrustedOrigin(w, r) { return } - if !h.allowAuthentication(w, r, "ip:"+requestIP(r)) { + if !h.allowAuthentication(w, r, "ip:"+httpmiddleware.ClientIP(r)) { return } request := new(signupRequest) @@ -129,7 +142,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { if !h.requireTrustedOrigin(w, r) { return } - if !h.allowAuthentication(w, r, "ip:"+requestIP(r)) { + if !h.allowAuthentication(w, r, "ip:"+httpmiddleware.ClientIP(r)) { return } request := new(loginRequest) @@ -205,14 +218,6 @@ func fmtDigest(value []byte) string { return string(result) } -func requestIP(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil { - return host - } - return r.RemoteAddr -} - func (h *Handler) session(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(browserauth.SessionCookieName) if err != nil { diff --git a/apps/api/internal/transport/browserauth/handler_test.go b/apps/api/internal/transport/browserauth/handler_test.go index fcc3cf9b..ef330450 100644 --- a/apps/api/internal/transport/browserauth/handler_test.go +++ b/apps/api/internal/transport/browserauth/handler_test.go @@ -73,3 +73,34 @@ func TestSignupConflictDoesNotConfirmAccountExistence(t *testing.T) { t.Fatalf("enumerating signup response: status=%d body=%s", recorder.Code, recorder.Body.String()) } } + +// TestEmailValidationDoesNotDependOnDomainResolution protects administrator +// bootstrap on an isolated self-hosted installation. ozzo's `is.Email` resolves +// MX records for the submitted domain, which made signup and login fail closed +// whenever the API container had no outbound DNS and rejected every +// internal-only or RFC 2606 reserved domain outright. The risk is an +// installation that cannot create its first user; the assertion is that +// validation is a pure format check. +func TestEmailValidationDoesNotDependOnDomainResolution(t *testing.T) { + unresolvable := []string{ + "admin@mosaic.internal", + "admin@drill.test", + "admin@studio.example", + "admin@host.invalid", + "admin@deep.subdomain.example.com", + } + for _, address := range unresolvable { + if err := (&signupRequest{Email: address, Name: "Operator", Password: "correct-horse-battery"}).Validate(); err != nil { + t.Errorf("signup with %q = %v, want accepted without resolving the domain", address, err) + } + if err := (&loginRequest{Email: address, Password: "correct-horse-battery"}).Validate(); err != nil { + t.Errorf("login with %q = %v, want accepted without resolving the domain", address, err) + } + } + malformed := []string{"admin", "admin@", "@example.com", "admin example.com", "admin@@example.com"} + for _, address := range malformed { + if err := (&signupRequest{Email: address, Name: "Operator", Password: "correct-horse-battery"}).Validate(); err == nil { + t.Errorf("signup with malformed %q was accepted", address) + } + } +} diff --git a/apps/api/internal/transport/experiment/handler.go b/apps/api/internal/transport/experiment/handler.go index 15e075aa..9a907c60 100644 --- a/apps/api/internal/transport/experiment/handler.go +++ b/apps/api/internal/transport/experiment/handler.go @@ -388,8 +388,19 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) { response.Error(w, r, response.NewAPIError(409, "idempotency_conflict", "The idempotency key was already used for different input.")) case errors.Is(err, experiment.ErrConflict): response.Error(w, r, response.NewAPIError(409, "experiment_transition_blocked", "The Experiment action is not valid in its current state.")) + case errors.Is(err, experiment.ErrPlacementDecisionRequired): + response.Error(w, r, response.NewAPIError(409, "experiment_placement_decision_required", + "Publish a Placement rule set in this Environment before publishing an Experiment: "+ + "the current Configuration Release carries no Placement Decision representation for an "+ + "Experiment release to build on.")) case errors.Is(err, experiment.ErrInvalid): - response.Error(w, r, response.NewAPIError(422, "experiment_invalid", "The Experiment request is not valid.")) + x := response.NewAPIError(422, "experiment_invalid", "The Experiment request is not valid.") + // Name the precondition that refused. Without it every one of the + // publish preconditions answers with the same opaque code. + if reason, ok := experiment.InvalidReason(err); ok { + x.Details = map[string]any{"reason": reason} + } + response.Error(w, r, x) default: response.Error(w, r, err) } diff --git a/apps/api/internal/transport/experiment/handler_error_test.go b/apps/api/internal/transport/experiment/handler_error_test.go new file mode 100644 index 00000000..84415895 --- /dev/null +++ b/apps/api/internal/transport/experiment/handler_error_test.go @@ -0,0 +1,35 @@ +package experimenthttp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Mujhtech/mosaic/apps/api/internal/experiment" +) + +// Publishing an Experiment requires the Environment's current Configuration +// Release to carry a Placement Decision representation, i.e. a published +// Placement rule set. That prerequisite used to surface as a generic +// `422 experiment_invalid` with no detail, which sent operators to inspect the +// Experiment instead of the Environment. It must be its own code and must state +// what to do. +func TestPlacementDecisionPrerequisiteIsNamedAndActionable(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/publish", nil) + + writeError(recorder, request, experiment.ErrPlacementDecisionRequired) + + if recorder.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409 (body %s)", recorder.Code, recorder.Body.String()) + } + body := recorder.Body.String() + if !strings.Contains(body, `"code":"experiment_placement_decision_required"`) { + t.Fatalf("body = %s, want the dedicated prerequisite code", body) + } + // The message must name the action, not merely restate the failure. + if !strings.Contains(body, "Placement rule set") { + t.Fatalf("body = %s, want a message naming the prerequisite", body) + } +} diff --git a/apps/api/internal/transport/health/handler.go b/apps/api/internal/transport/health/handler.go index 4b8e774c..c48d9457 100644 --- a/apps/api/internal/transport/health/handler.go +++ b/apps/api/internal/transport/health/handler.go @@ -1,18 +1,112 @@ +// Package health exposes Mosaic's liveness and readiness probes. +// +// Liveness answers "is this process up" and reports the build identity so an +// operator can tell which artifact is serving. Readiness answers "should this +// instance receive traffic" by checking the dependencies Mosaic cannot serve +// without, and reports 503 while the process is draining. package health import ( "context" "net/http" + "sync/atomic" + "time" "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/buildinfo" "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" ) +// checkTimeout bounds every individual readiness probe so a hung dependency +// cannot hold the probe open past a load balancer's own timeout. +const checkTimeout = 3 * time.Second + +// Checker is the single-dependency readiness contract. type Checker interface{ Ping(context.Context) error } -type payload struct { - Status string `json:"status"` +// Check is one named readiness dependency. Code is a stable, safe diagnostic +// identifier reported to operators; it must never embed configuration values. +// +// DependsOn names another Check this one cannot be evaluated without. When that +// prerequisite fails, this check is skipped rather than reported: the migration +// probe needs PostgreSQL, so reporting `migration_incompatible` while the +// database is simply down sends an operator to diagnose a schema problem that +// does not exist. Readiness still fails — on the dependency that actually broke. +type Check struct { + Name string + Code string + DependsOn string + Probe func(context.Context) error +} + +// Readiness aggregates readiness dependencies and the draining flag. +type Readiness struct { + checks []Check + draining atomic.Bool +} + +func NewReadiness(checks ...Check) *Readiness { + return &Readiness{checks: checks} +} + +// StartDraining flips readiness to 503 before the HTTP server begins its +// graceful shutdown so load balancers stop sending new work. +func (r *Readiness) StartDraining() { + if r != nil { + r.draining.Store(true) + } +} + +func (r *Readiness) Draining() bool { return r != nil && r.draining.Load() } + +// Evaluate runs every check and returns the failing dependency codes. +func (r *Readiness) Evaluate(ctx context.Context) []string { + if r == nil { + return nil + } + var failures []string + failed := make(map[string]struct{}, len(r.checks)) + for _, check := range r.checks { + if check.Probe == nil { + continue + } + if check.DependsOn != "" { + if _, broken := failed[check.DependsOn]; broken { + zerolog.Ctx(ctx).Warn(). + Str("readiness_check", check.Name). + Str("readiness_skipped_because", check.DependsOn). + Msg("readiness dependency check skipped: a prerequisite is unavailable") + continue + } + } + probeContext, cancel := context.WithTimeout(ctx, checkTimeout) + err := check.Probe(probeContext) + cancel() + if err != nil { + zerolog.Ctx(ctx).Warn(). + Str("readiness_check", check.Name). + Str("readiness_code", check.Code). + Err(err). + Msg("readiness dependency check failed") + failed[check.Name] = struct{}{} + failures = append(failures, check.Code) + } + } + return failures +} + +type livePayload struct { + Status string `json:"status"` + Version string `json:"version"` + Commit string `json:"commit,omitempty"` + Built string `json:"built,omitempty"` +} + +type readyPayload struct { + Status string `json:"status"` + Version string `json:"version"` } func LiveRoutes() http.Handler { @@ -21,18 +115,41 @@ func LiveRoutes() http.Handler { return router } +// ReadyRoutes keeps the single-dependency probe used by callers that only need +// PostgreSQL readiness. func ReadyRoutes(checker Checker) http.Handler { + if checker == nil { + return ReadinessRoutes(nil) + } + return ReadinessRoutes(NewReadiness(Check{ + Name: "postgresql", Code: "database_unavailable", Probe: checker.Ping, + })) +} + +func ReadinessRoutes(readiness *Readiness) http.Handler { router := chi.NewRouter() router.Get("/", func(w http.ResponseWriter, r *http.Request) { - if checker == nil || checker.Ping(r.Context()) != nil { + if readiness == nil { response.ServiceUnavailable(w, r, "not_ready", "A required dependency is unavailable.") return } - response.OK(w, r, payload{Status: "ready"}) + if readiness.Draining() { + response.ServiceUnavailableWithDetails(w, r, "draining", + "The instance is shutting down and is no longer accepting traffic.", + map[string]any{"checks": []string{"draining"}}) + return + } + if failures := readiness.Evaluate(r.Context()); len(failures) > 0 { + response.ServiceUnavailableWithDetails(w, r, "not_ready", + "A required dependency is unavailable.", map[string]any{"checks": failures}) + return + } + response.OK(w, r, readyPayload{Status: "ready", Version: buildinfo.Version()}) }) return router } func live(w http.ResponseWriter, r *http.Request) { - response.OK(w, r, payload{Status: "ok"}) + build := buildinfo.Current() + response.OK(w, r, livePayload{Status: "ok", Version: build.Version, Commit: build.Commit, Built: build.Date}) } diff --git a/apps/api/internal/transport/health/handler_test.go b/apps/api/internal/transport/health/handler_test.go new file mode 100644 index 00000000..06de8396 --- /dev/null +++ b/apps/api/internal/transport/health/handler_test.go @@ -0,0 +1,117 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func get(t *testing.T, handler http.Handler) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + return recorder +} + +// Readiness exists so a load balancer stops sending traffic to an instance that +// cannot serve. A probe that only checked PostgreSQL would report ready while +// every Asset upload and delivery read failed, which is the failure this test +// prevents. It also asserts the response carries a safe per-check code and no +// configuration detail. +func TestReadinessFailsWhenObjectStorageIsDownWhilePostgreSQLIsHealthy(t *testing.T) { + readiness := NewReadiness( + Check{Name: "postgresql", Code: "database_unavailable", Probe: func(context.Context) error { return nil }}, + Check{Name: "object_storage", Code: "object_storage_unavailable", Probe: func(context.Context) error { + return errors.New("dial tcp minio:9000: connect: connection refused") + }}, + ) + + recorder := get(t, ReadinessRoutes(readiness)) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", recorder.Code) + } + var payload struct { + Error struct { + Code string `json:"code"` + Details struct { + Checks []string `json:"checks"` + } `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Error.Code != "not_ready" { + t.Fatalf("code = %q, want not_ready", payload.Error.Code) + } + if len(payload.Error.Details.Checks) != 1 || payload.Error.Details.Checks[0] != "object_storage_unavailable" { + t.Fatalf("checks = %#v, want only object_storage_unavailable", payload.Error.Details.Checks) + } + if strings.Contains(recorder.Body.String(), "minio:9000") { + t.Fatalf("readiness leaked internal topology: %s", recorder.Body.String()) + } +} + +// Draining must be observable before the HTTP server starts refusing +// connections; otherwise a rolling restart drops in-flight client traffic. +func TestReadinessReportsDrainingBeforeShutdown(t *testing.T) { + readiness := NewReadiness(Check{Name: "postgresql", Code: "database_unavailable", Probe: func(context.Context) error { return nil }}) + if recorder := get(t, ReadinessRoutes(readiness)); recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 before draining", recorder.Code) + } + readiness.StartDraining() + recorder := get(t, ReadinessRoutes(readiness)) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 while draining", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "draining") { + t.Fatalf("body = %s, want a draining diagnostic", recorder.Body.String()) + } +} + +// A readiness failure is the first thing an operator reads during an incident, +// so it must name the dependency that actually broke. The migration check runs +// its own query, so a PostgreSQL outage used to fail it too and readiness +// reported `migration_incompatible` alongside `database_unavailable` — sending +// the operator to diagnose a schema problem that does not exist. This asserts a +// check declaring a prerequisite is skipped, not reported, when that +// prerequisite fails, and that it still runs when the prerequisite is healthy. +func TestReadinessDoesNotReportDependentChecksWhenTheirPrerequisiteFails(t *testing.T) { + migrationProbed := false + readiness := NewReadiness( + Check{Name: "postgresql", Code: "database_unavailable", Probe: func(context.Context) error { + return errors.New("failed to connect to `user=mosaic database=mosaic`") + }}, + Check{Name: "migrations", Code: "migration_incompatible", DependsOn: "postgresql", Probe: func(context.Context) error { + migrationProbed = true + return errors.New("query goose_db_version: connection refused") + }}, + ) + + failures := readiness.Evaluate(context.Background()) + + if len(failures) != 1 || failures[0] != "database_unavailable" { + t.Fatalf("checks = %#v, want only database_unavailable", failures) + } + if migrationProbed { + t.Fatal("the migration probe ran even though its prerequisite was down") + } + + // The dependent check must still be able to fail on its own merits: a + // genuinely incompatible schema on a healthy database has to be reported, + // or a pending-migration deployment would look ready. + healthy := NewReadiness( + Check{Name: "postgresql", Code: "database_unavailable", Probe: func(context.Context) error { return nil }}, + Check{Name: "migrations", Code: "migration_incompatible", DependsOn: "postgresql", Probe: func(context.Context) error { + return errors.New("3 pending migrations") + }}, + ) + if failures := healthy.Evaluate(context.Background()); len(failures) != 1 || failures[0] != "migration_incompatible" { + t.Fatalf("checks = %#v, want only migration_incompatible", failures) + } +} diff --git a/apps/api/internal/transport/hostedpublishing/handler.go b/apps/api/internal/transport/hostedpublishing/handler.go index 4a45d5c9..4e4115c0 100644 --- a/apps/api/internal/transport/hostedpublishing/handler.go +++ b/apps/api/internal/transport/hostedpublishing/handler.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "io" - "net" "net/http" "strconv" "strings" @@ -14,16 +13,22 @@ import ( "github.com/go-chi/chi/v5" validation "github.com/go-ozzo/ozzo-validation/v4" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "github.com/Mujhtech/mosaic/apps/api/internal/hostedpublishing" "github.com/Mujhtech/mosaic/apps/api/internal/platform/authn" + "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/httpmiddleware" "github.com/Mujhtech/mosaic/apps/api/internal/platform/httpserver/response" "github.com/Mujhtech/mosaic/apps/api/internal/platform/requestvalidation" ) const ( - maxDocumentRequestBytes = 4 << 20 - maxAssetRequestBytes = 11 << 20 + maxDocumentRequestBytes = 4 << 20 + // multipartOverheadBytes covers the MIME part headers and boundary markers + // wrapping the Asset bytes themselves. + multipartOverheadBytes = 1 << 20 idempotencyHeader = "Idempotency-Key" ifMatchHeader = "If-Match" deliveryContentType = "application/vnd.mosaic.configuration+json;version=1" @@ -56,10 +61,19 @@ func RegisterRoutes(router chi.Router, service *hostedpublishing.Service, resolv RegisterPublicRoutes(router, service, limiters...) } -func RegisterProjectRoutes(router chi.Router, service *hostedpublishing.Service) { +// RegisterProjectRoutes mounts the authenticated publishing routes. +// uploadMiddleware carries the per-route timeout override for asset upload, +// which must not be bounded by the global request budget. +func RegisterProjectRoutes(router chi.Router, service *hostedpublishing.Service, uploadMiddleware ...func(http.Handler) http.Handler) { handler := &Handler{service: service} + upload := make([]func(http.Handler) http.Handler, 0, len(uploadMiddleware)) + for _, item := range uploadMiddleware { + if item != nil { + upload = append(upload, item) + } + } router.Get("/assets", handler.listAssets) - router.Post("/assets", handler.uploadAsset) + router.With(upload...).Post("/assets", handler.uploadAsset) router.Get("/assets/{assetId}", handler.getAsset) router.Delete("/assets/{assetId}", handler.archiveAsset) router.Get("/assets/{assetId}/usage", handler.assetUsage) @@ -147,7 +161,7 @@ type placementRequest struct { func (request *placementRequest) Validate() error { return validation.ValidateStruct(request, - validation.Field(&request.Key, validation.Required, validation.Length(2, 63)), + validation.Field(&request.Key, validation.Required, validation.Length(2, 63), requestvalidation.PlacementKey()), validation.Field(&request.Name, validation.Required, validation.Length(1, 120)), validation.Field(&request.Description, validation.Length(0, 1000))) } @@ -243,6 +257,8 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) { status, code, message = http.StatusConflict, "asset_not_ready", "The Asset is not ready for this operation." case errors.Is(err, hostedpublishing.ErrAssetReferenced): status, code, message = http.StatusConflict, "asset_referenced", "The Asset is referenced and its bytes must be retained." + case errors.Is(err, hostedpublishing.ErrAssetObjectMissing): + status, code, message = http.StatusNotFound, "asset_object_missing", "The Asset's stored bytes are not available." case errors.Is(err, hostedpublishing.ErrAssetStorage): status, code, message = http.StatusServiceUnavailable, "asset_storage_failed", "Asset storage is temporarily unavailable." case errors.Is(err, hostedpublishing.ErrPlacementUnpublished): @@ -263,13 +279,29 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) { } case errors.Is(err, hostedpublishing.ErrUnsupportedCapability): status, code, message = http.StatusNotAcceptable, "unsupported_capability", "The SDK does not support this Configuration Release." + // Name the term that failed. Without it an integrator has no path from + // the 406 to the header they must send or the SDK they must upgrade. + if capabilityError, ok := hostedpublishing.CapabilityFailure(err); ok { + details := map[string]any{ + "requirement": capabilityError.Requirement, + "reason": string(capabilityError.Reason), + "detail": capabilityError.Detail(), + } + if capabilityError.Name != "" { + details["capability"] = capabilityError.Name + } + if capabilityError.Version != "" { + details["version"] = capabilityError.Version + } + apiError.Details = details + } } apiError.Status, apiError.Code, apiError.Message = status, code, message response.Error(w, r, apiError) } func (h *Handler) uploadAsset(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxAssetRequestBytes) + r.Body = http.MaxBytesReader(w, r.Body, h.service.AssetUploadLimit()+multipartOverheadBytes) reader, err := r.MultipartReader() if err != nil { response.Error(w, r, response.ValidationFailed(map[string][]string{"file": {"A multipart file upload is required."}})) @@ -588,13 +620,13 @@ func (h *Handler) rollback(w http.ResponseWriter, r *http.Request) { func capabilityRequestFromHeaders(r *http.Request) (hostedpublishing.SDKCapabilityRequest, error) { capabilityHeader := r.Header.Get(capabilitiesHeader) if len(capabilityHeader) == 0 || len(capabilityHeader) > maxCapabilityHeaderSize { - return hostedpublishing.SDKCapabilityRequest{}, hostedpublishing.ErrUnsupportedCapability + return hostedpublishing.SDKCapabilityRequest{}, hostedpublishing.NewCapabilityError("paywallCapability", capabilitiesHeader, "", hostedpublishing.CapabilityMalformed) } capabilities := make([]hostedpublishing.SDKCapability, 0) for _, item := range strings.Split(capabilityHeader, ",") { name, version, ok := strings.Cut(strings.TrimSpace(item), "@") if !ok || name == "" || version == "" || strings.Contains(version, "@") { - return hostedpublishing.SDKCapabilityRequest{}, hostedpublishing.ErrUnsupportedCapability + return hostedpublishing.SDKCapabilityRequest{}, hostedpublishing.NewCapabilityError("paywallCapability", name, version, hostedpublishing.CapabilityMalformed) } capabilities = append(capabilities, hostedpublishing.SDKCapability{Name: name, Version: version}) } @@ -616,7 +648,7 @@ func capabilityRequestFromHeaders(r *http.Request) (hostedpublishing.SDKCapabili } for _, name := range []string{experimentAssignmentVersionsHeader, experimentFeaturesHeader, experimentBucketingAlgorithmsHeader, experimentSchedulePoliciesHeader} { if len(r.Header.Get(name)) > maxCapabilityHeaderSize { - return hostedpublishing.SDKCapabilityRequest{}, hostedpublishing.ErrUnsupportedCapability + return hostedpublishing.SDKCapabilityRequest{}, hostedpublishing.NewCapabilityError("experimentCapabilityHeader", name, "", hostedpublishing.CapabilityMalformed) } } return request, nil @@ -673,8 +705,26 @@ func digestBytes(payload []byte) string { return hostedpublishing.ContentHash(payload) } +// deliveryResponses counts Configuration Release deliveries split by whether +// the SDK's cached representation was still current. The 304 ratio is the +// documented signal for delivery efficiency and cache correctness. +var deliveryResponses = func() metric.Int64Counter { + counter, _ := otel.Meter("mosaic/hostedpublishing").Int64Counter( + "mosaic.delivery.responses", + metric.WithDescription("Configuration delivery responses, split by cache outcome."), + ) + return counter +}() + +func recordDelivery(r *http.Request, surface string, notModified bool) { + deliveryResponses.Add(r.Context(), 1, metric.WithAttributes( + attribute.String("surface", surface), + attribute.Bool("not_modified", notModified), + )) +} + func (h *Handler) sdkConfiguration(w http.ResponseWriter, r *http.Request) { - if !h.allowDelivery(w, r, "ip:"+requestIP(r)) { + if !h.allowDelivery(w, r, "ip:"+httpmiddleware.ClientIP(r)) { return } capabilities, err := capabilityRequestFromHeaders(r) @@ -683,7 +733,7 @@ func (h *Handler) sdkConfiguration(w http.ResponseWriter, r *http.Request) { return } if hostedpublishing.PreferredDeliveryVersion(capabilities.SupportedConfigurationDeliveryVersions) == "" { - writeError(w, r, hostedpublishing.ErrUnsupportedCapability) + writeError(w, r, hostedpublishing.NewCapabilityError("configurationDeliveryVersion", "Mosaic-Configuration-Versions", "", hostedpublishing.CapabilityMalformed)) return } configuration, err := h.service.AuthenticateSDKKeyVersions(r.Context(), bearer(r), capabilities.SupportedConfigurationDeliveryVersions) @@ -717,19 +767,21 @@ func (h *Handler) sdkConfiguration(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Encoding", encoding) } if r.Header.Get("If-None-Match") == etag { + recordDelivery(r, "configuration", true) response.Representation(w, http.StatusNotModified, "application/vnd.mosaic.configuration+json;version="+deliveryVersion, nil) return } + recordDelivery(r, "configuration", false) response.Representation(w, http.StatusOK, "application/vnd.mosaic.configuration+json;version="+deliveryVersion, payload) } func (h *Handler) sdkCommerceConfiguration(w http.ResponseWriter, r *http.Request) { - if !h.allowDelivery(w, r, "ip:"+requestIP(r)) { + if !h.allowDelivery(w, r, "ip:"+httpmiddleware.ClientIP(r)) { return } if !headerContains(r.Header.Get("Accept"), commerceContentType) && !headerContains(r.Header.Get("Accept"), commerceContentTypeV2) { - writeError(w, r, hostedpublishing.ErrUnsupportedCapability) + writeError(w, r, hostedpublishing.NewCapabilityError("acceptMediaType", commerceContentType, "", hostedpublishing.CapabilityMissing)) return } sdkPlatform := strings.TrimSpace(r.Header.Get("Mosaic-SDK-Platform")) @@ -763,7 +815,7 @@ func (h *Handler) sdkCommerceConfiguration(w http.ResponseWriter, r *http.Reques Version string `json:"commerceConfigurationVersion"` } if err := json.Unmarshal(configuration.Snapshot.Payload, &envelope); err != nil { - writeError(w, r, hostedpublishing.ErrUnsupportedCapability) + writeError(w, r, hostedpublishing.NewCapabilityError("commerceConfigurationVersion", "", "", hostedpublishing.CapabilityUnavailable)) return } if err := hostedpublishing.ValidateSDKCommerceSnapshotCapability( @@ -781,9 +833,11 @@ func (h *Handler) sdkCommerceConfiguration(w http.ResponseWriter, r *http.Reques w.Header().Set("Vary", "Authorization, Accept, Mosaic-SDK-Platform, Mosaic-SDK-Version, Mosaic-Commerce-Configuration-Versions, Mosaic-Commerce-Provider-Contract-Versions") contentType := "application/vnd.mosaic.commerce-configuration+json;version=" + envelope.Version if r.Header.Get("If-None-Match") == etag { + recordDelivery(r, "commerce-configuration", true) response.Representation(w, http.StatusNotModified, contentType, nil) return } + recordDelivery(r, "commerce-configuration", false) response.Representation(w, http.StatusOK, contentType, configuration.Snapshot.Payload) } @@ -800,11 +854,3 @@ func (h *Handler) allowDelivery(w http.ResponseWriter, r *http.Request, key stri response.Error(w, r, response.NewAPIError(http.StatusTooManyRequests, "rate_limited", "Too many configuration requests. Retry later.")) return false } - -func requestIP(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil { - return host - } - return r.RemoteAddr -} diff --git a/apps/api/internal/transport/hostedpublishing/handler_test.go b/apps/api/internal/transport/hostedpublishing/handler_test.go index 59e14645..81ff2077 100644 --- a/apps/api/internal/transport/hostedpublishing/handler_test.go +++ b/apps/api/internal/transport/hostedpublishing/handler_test.go @@ -414,3 +414,35 @@ func TestAssetContentRetainsArchivedImmutableBytes(t *testing.T) { } var _ hostedpublishing.ObjectStore = deliveryObjectStore{} + +// A referenced Asset whose bytes are missing from object storage is a "not +// found", not a "Mosaic is broken": an SDK must be able to tell them apart to +// fall back to its bundled Asset, and a 500 tells it the wrong thing. Storage +// that is genuinely failing must still be a retryable 503. +func TestMissingAssetObjectIsNotFoundAndFailingStorageIsRetryable(t *testing.T) { + for name, testCase := range map[string]struct { + err error + wantStatus int + wantCode string + }{ + "asset bytes are absent from the bucket": { + hostedpublishing.ErrAssetObjectMissing, http.StatusNotFound, "asset_object_missing", + }, + "object storage is failing": { + hostedpublishing.ErrAssetStorage, http.StatusServiceUnavailable, "asset_storage_failed", + }, + } { + t.Run(name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/sdk/assets/asset_1/sha256:abc", nil) + writeError(recorder, request, testCase.err) + + if recorder.Code != testCase.wantStatus { + t.Fatalf("status = %d, want %d (body %s)", recorder.Code, testCase.wantStatus, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), `"code":"`+testCase.wantCode+`"`) { + t.Fatalf("body = %s, want code %q", recorder.Body.String(), testCase.wantCode) + } + }) + } +} diff --git a/apps/api/internal/transport/placementdecision/handler.go b/apps/api/internal/transport/placementdecision/handler.go index 4fda41f6..368b3d60 100644 --- a/apps/api/internal/transport/placementdecision/handler.go +++ b/apps/api/internal/transport/placementdecision/handler.go @@ -77,7 +77,7 @@ type attributeRequest struct { } func (r *attributeRequest) Validate() error { - return validation.ValidateStruct(r, validation.Field(&r.Key, validation.Required, validation.Length(1, 64)), validation.Field(&r.Type, validation.Required, validation.In("string", "boolean", "number", "timestamp", "semantic_version", "string_list")), validation.Field(&r.Description, validation.Length(0, 500)), validation.Field(&r.AllowedOperators, validation.Required, validation.Length(1, 13)), validation.Field(&r.Sensitivity, validation.Required, validation.In("standard", "sensitive"))) + return validation.ValidateStruct(r, validation.Field(&r.Key, validation.Required, validation.Length(1, 64), requestvalidation.PlacementKey()), validation.Field(&r.Type, validation.Required, validation.In("string", "boolean", "number", "timestamp", "semantic_version", "string_list")), validation.Field(&r.Description, validation.Length(0, 500)), validation.Field(&r.AllowedOperators, validation.Required, validation.Length(1, 13)), validation.Field(&r.Sensitivity, validation.Required, validation.In("standard", "sensitive"))) } type aliasRequest struct { @@ -85,7 +85,7 @@ type aliasRequest struct { } func (r *aliasRequest) Validate() error { - return validation.ValidateStruct(r, validation.Field(&r.Key, validation.Required, validation.Length(1, 64))) + return validation.ValidateStruct(r, validation.Field(&r.Key, validation.Required, validation.Length(1, 64), requestvalidation.PlacementKey())) } type overrideRequest struct { diff --git a/apps/api/migrations/00006_provider_foundation.sql b/apps/api/migrations/00006_provider_foundation.sql index 5cacd26c..11ee84d9 100644 --- a/apps/api/migrations/00006_provider_foundation.sql +++ b/apps/api/migrations/00006_provider_foundation.sql @@ -227,9 +227,23 @@ DROP INDEX provider_product_mappings_active_scope_key; DROP INDEX provider_product_mappings_placeholder_scope_key; DROP INDEX provider_product_mappings_current_scope_key; --- A migration rollback explicitly discards new provider-mapping drafts/history --- because the Phase 3A placeholder schema cannot represent those records. -DELETE FROM provider_product_mappings WHERE status <> 'placeholder'; +-- The Phase 3A placeholder schema cannot represent real provider mappings, so a +-- rollback would have to delete them. Refuse instead of silently discarding +-- commerce configuration; recovery is restore-from-backup. +-- +goose StatementBegin +DO $$ +DECLARE real_mappings bigint; +BEGIN + SELECT count(*) INTO real_mappings FROM provider_product_mappings WHERE status <> 'placeholder'; + IF real_mappings > 0 THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = format('migration 00006 cannot be rolled back: %s non-placeholder provider Product mapping(s) would be deleted', real_mappings), + HINT = 'Restore from a backup taken before the upgrade: see docs/backend/operations/backup-restore.md'; + END IF; +END +$$; +-- +goose StatementEnd ALTER TABLE provider_product_mappings DROP CONSTRAINT provider_product_mappings_application_scope_fkey, diff --git a/apps/api/migrations/00010_configuration_delivery_v2.sql b/apps/api/migrations/00010_configuration_delivery_v2.sql index a9372763..6772d4f8 100644 --- a/apps/api/migrations/00010_configuration_delivery_v2.sql +++ b/apps/api/migrations/00010_configuration_delivery_v2.sql @@ -48,6 +48,21 @@ DROP TRIGGER immutable_configuration_release_rule_set_versions ON configuration_ DROP TRIGGER immutable_configuration_release_representations ON configuration_release_representations; DROP TABLE configuration_release_rule_set_versions; DROP TABLE configuration_release_representations; -UPDATE configuration_releases SET delivery_contract_version='1' WHERE delivery_contract_version='2'; +-- Rewriting a published v2 Release to claim contract version 1 would corrupt an +-- immutable Configuration Release. Refuse instead; recovery is restore-from-backup. +-- +goose StatementBegin +DO $$ +DECLARE v2_releases bigint; +BEGIN + SELECT count(*) INTO v2_releases FROM configuration_releases WHERE delivery_contract_version = '2'; + IF v2_releases > 0 THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = format('migration 00010 cannot be rolled back: %s Delivery v2 Configuration Release(s) exist', v2_releases), + HINT = 'Restore from a backup taken before the upgrade: see docs/backend/operations/backup-restore.md'; + END IF; +END +$$; +-- +goose StatementEnd ALTER TABLE configuration_releases DROP CONSTRAINT configuration_releases_delivery_contract_version_check; ALTER TABLE configuration_releases ADD CONSTRAINT configuration_releases_delivery_contract_version_check CHECK (delivery_contract_version='1'); diff --git a/apps/api/migrations/00018_phase_7_experiment_delivery_analytics.sql b/apps/api/migrations/00018_phase_7_experiment_delivery_analytics.sql index 685b76bf..351094cf 100644 --- a/apps/api/migrations/00018_phase_7_experiment_delivery_analytics.sql +++ b/apps/api/migrations/00018_phase_7_experiment_delivery_analytics.sql @@ -38,12 +38,18 @@ ALTER TABLE analytics_events OR (event_schema_version='2' AND experiment_id IS NOT NULL AND experiment_version_id IS NOT NULL AND experiment_variant_id IS NOT NULL AND experiment_allocation_version IS NOT NULL AND char_length(experiment_allocation_version) BETWEEN 1 AND 128) ), - ADD CONSTRAINT analytics_events_experiment_tuple_fk FOREIGN KEY (experiment_version_id, experiment_id, project_id, environment_id, experiment_allocation_version) REFERENCES experiment_versions(id, experiment_id, project_id, environment_id, allocation_version) ON DELETE RESTRICT, - ADD CONSTRAINT analytics_events_experiment_variant_tuple_fk FOREIGN KEY (experiment_variant_id, experiment_version_id, project_id) REFERENCES experiment_variants(id, experiment_version_id, project_id) ON DELETE RESTRICT; + ADD CONSTRAINT analytics_events_experiment_tuple_fk FOREIGN KEY (experiment_version_id, experiment_id, project_id, environment_id, experiment_allocation_version) REFERENCES experiment_versions(id, experiment_id, project_id, environment_id, allocation_version) ON DELETE RESTRICT NOT VALID, + ADD CONSTRAINT analytics_events_experiment_variant_tuple_fk FOREIGN KEY (experiment_variant_id, experiment_version_id, project_id) REFERENCES experiment_variants(id, experiment_version_id, project_id) ON DELETE RESTRICT NOT VALID; -CREATE INDEX analytics_events_experiment_analysis_idx - ON analytics_events(environment_id,experiment_version_id,experiment_variant_id,occurred_at,subject_id) - WHERE experiment_version_id IS NOT NULL AND experiment_qa_override=false; +-- The columns above were just added, so no existing row can violate these +-- constraints. Adding them NOT VALID and validating separately keeps the +-- write-blocking window on a populated analytics_events table to the ALTER +-- itself instead of a full validating scan. +ALTER TABLE analytics_events VALIDATE CONSTRAINT analytics_events_experiment_tuple_fk; +ALTER TABLE analytics_events VALIDATE CONSTRAINT analytics_events_experiment_variant_tuple_fk; + +-- The analysis index is built CONCURRENTLY by migration 00019 so ingestion is +-- never blocked by an index build on a populated table. CREATE TABLE experiment_daily_unique_units ( project_id text NOT NULL, @@ -91,6 +97,33 @@ CREATE TRIGGER immutable_release_experiment_versions BEFORE UPDATE OR DELETE ON FOR EACH ROW EXECUTE FUNCTION reject_experiment_immutable_change(); -- +goose Down +-- Rolling this migration back destroys Delivery v3 Releases, Analytics Event v2 +-- attribution, and Experiment analysis state, none of which the Phase 6 schema +-- can represent. Down migrations are not a rollback strategy: when affected +-- data exists the supported recovery is restore-from-backup +-- (docs/backend/operations/backup-restore.md). Immutability triggers are never +-- disabled to force a rollback through. +-- +goose StatementBegin +DO $$ +DECLARE + v3_releases bigint; + v2_events bigint; + experiment_rows bigint; +BEGIN + SELECT count(*) INTO v3_releases FROM configuration_releases WHERE delivery_contract_version = '3'; + SELECT count(*) INTO v2_events FROM analytics_events WHERE event_schema_version = '2'; + SELECT count(*) INTO experiment_rows FROM configuration_release_experiment_versions; + IF v3_releases > 0 OR v2_events > 0 OR experiment_rows > 0 THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = format( + 'migration 00018 cannot be rolled back: %s Delivery v3 Release(s), %s Analytics Event v2 row(s), and %s Release-to-Experiment link(s) would be destroyed', + v3_releases, v2_events, experiment_rows), + HINT = 'Restore from a backup taken before the upgrade: see docs/backend/operations/backup-restore.md'; + END IF; +END +$$; +-- +goose StatementEnd DROP TRIGGER immutable_release_experiment_versions ON configuration_release_experiment_versions; ALTER TABLE analytics_export_jobs DROP CONSTRAINT analytics_export_jobs_experiment_fk; ALTER TABLE analytics_export_jobs DROP CONSTRAINT analytics_export_jobs_kind_check; @@ -99,7 +132,7 @@ ALTER TABLE analytics_export_jobs DROP COLUMN include_identity; ALTER TABLE analytics_export_jobs DROP COLUMN experiment_version_id; DROP TABLE experiment_analysis_rebuilds; DROP TABLE experiment_daily_unique_units; -DROP INDEX analytics_events_experiment_analysis_idx; +DROP INDEX IF EXISTS analytics_events_experiment_analysis_idx; ALTER TABLE analytics_events DROP CONSTRAINT analytics_events_experiment_variant_tuple_fk, DROP CONSTRAINT analytics_events_experiment_tuple_fk, diff --git a/apps/api/migrations/00019_experiment_analysis_index_concurrent.sql b/apps/api/migrations/00019_experiment_analysis_index_concurrent.sql new file mode 100644 index 00000000..3270eca1 --- /dev/null +++ b/apps/api/migrations/00019_experiment_analysis_index_concurrent.sql @@ -0,0 +1,14 @@ +-- +goose NO TRANSACTION +-- +goose Up +-- Migration 00018 originally built this index inline. On a populated +-- analytics_events table a plain CREATE INDEX holds a SHARE lock for a full +-- table scan, which blocks event ingestion for the length of the upgrade. +-- Building it CONCURRENTLY keeps ingestion available. IF NOT EXISTS makes the +-- migration a no-op for databases that already applied 00018 with the inline +-- index (every v1.0.0-rc.1 installation). +CREATE INDEX CONCURRENTLY IF NOT EXISTS analytics_events_experiment_analysis_idx + ON analytics_events(environment_id,experiment_version_id,experiment_variant_id,occurred_at,subject_id) + WHERE experiment_version_id IS NOT NULL AND experiment_qa_override=false; + +-- +goose Down +DROP INDEX CONCURRENTLY IF EXISTS analytics_events_experiment_analysis_idx; diff --git a/apps/api/migrations/00020_experiment_schedule_job_reliability.sql b/apps/api/migrations/00020_experiment_schedule_job_reliability.sql new file mode 100644 index 00000000..8807ba9a --- /dev/null +++ b/apps/api/migrations/00020_experiment_schedule_job_reliability.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Experiment scheduling jobs had no retry budget and no backoff column, so a +-- transient failure marked the job permanently failed and an expired lease was +-- never reclaimed: a scheduled Experiment start or completion could be lost +-- silently. These columns bring the table in line with the analytics job tables. +ALTER TABLE experiment_scheduling_jobs + ADD COLUMN max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts >= 1), + ADD COLUMN available_at timestamptz, + ADD COLUMN last_error_code text; + +UPDATE experiment_scheduling_jobs SET available_at = scheduled_at WHERE available_at IS NULL; + +ALTER TABLE experiment_scheduling_jobs + ALTER COLUMN available_at SET NOT NULL; + +DROP INDEX experiment_scheduling_jobs_lease_idx; +CREATE INDEX experiment_scheduling_jobs_lease_idx + ON experiment_scheduling_jobs(available_at, scheduled_at, id) + WHERE status IN ('queued','leased'); + +-- +goose Down +DROP INDEX experiment_scheduling_jobs_lease_idx; +CREATE INDEX experiment_scheduling_jobs_lease_idx ON experiment_scheduling_jobs(scheduled_at,id) WHERE status IN ('queued','leased'); +ALTER TABLE experiment_scheduling_jobs + DROP COLUMN last_error_code, + DROP COLUMN available_at, + DROP COLUMN max_attempts; diff --git a/apps/api/migrations/00021_experiment_version_environment_integrity.sql b/apps/api/migrations/00021_experiment_version_environment_integrity.sql new file mode 100644 index 00000000..902d02b0 --- /dev/null +++ b/apps/api/migrations/00021_experiment_version_environment_integrity.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- experiment_versions carried two independent composite keys: one binding the +-- version to its Experiment within a Project, and one binding it to an +-- Environment within that Project. Nothing bound the version's Environment to +-- its Experiment's Environment, so a version could be attributed to a different +-- Environment than the Experiment it belongs to. Every assignment, exposure, and +-- analysis row is keyed on that Environment, so the gap is a tenant-boundary +-- and Experiment-integrity risk rather than a cosmetic one. +-- +-- experiments already declares UNIQUE (id, environment_id), so the composite +-- reference below is exact. It is added NOT VALID and validated separately to +-- keep the write-blocking window short on a populated table. +ALTER TABLE experiment_versions + ADD CONSTRAINT experiment_versions_experiment_environment_fk + FOREIGN KEY (experiment_id, environment_id) + REFERENCES experiments(id, environment_id) ON DELETE RESTRICT NOT VALID; + +ALTER TABLE experiment_versions + VALIDATE CONSTRAINT experiment_versions_experiment_environment_fk; + +-- +goose Down +ALTER TABLE experiment_versions + DROP CONSTRAINT experiment_versions_experiment_environment_fk; diff --git a/apps/api/migrations/embed.go b/apps/api/migrations/embed.go index 65cc5049..dbc38a2f 100644 --- a/apps/api/migrations/embed.go +++ b/apps/api/migrations/embed.go @@ -1,8 +1,54 @@ +// Package migrations owns Mosaic's versioned SQL schema migrations. package migrations -import "embed" +import ( + "embed" + "fmt" + "sort" + "strconv" + "strings" +) // Files contains the versioned SQL migrations used by the explicit migration command. // //go:embed *.sql var Files embed.FS + +// Versions lists every embedded migration version in ascending order. +func Versions() ([]int64, error) { + entries, err := Files.ReadDir(".") + if err != nil { + return nil, fmt.Errorf("read embedded migrations: %w", err) + } + versions := make([]int64, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + prefix, _, found := strings.Cut(entry.Name(), "_") + if !found { + return nil, fmt.Errorf("migration %q does not start with a version prefix", entry.Name()) + } + version, err := strconv.ParseInt(prefix, 10, 64) + if err != nil { + return nil, fmt.Errorf("migration %q has a non-numeric version prefix", entry.Name()) + } + versions = append(versions, version) + } + sort.Slice(versions, func(i, j int) bool { return versions[i] < versions[j] }) + return versions, nil +} + +// ExpectedVersion is the highest migration version this binary ships with. The +// API refuses readiness when the database is behind it, and the migrate +// preflight compares it against the applied version. +func ExpectedVersion() (int64, error) { + versions, err := Versions() + if err != nil { + return 0, err + } + if len(versions) == 0 { + return 0, fmt.Errorf("no embedded migrations found") + } + return versions[len(versions)-1], nil +} diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile new file mode 100644 index 00000000..92a19a42 --- /dev/null +++ b/apps/dashboard/Dockerfile @@ -0,0 +1,72 @@ +# Mosaic dashboard image. +# +# Build context is the repository root, because the dashboard consumes the +# local `file:` packages under packages/. Paths are excluded through +# apps/dashboard/Dockerfile.dockerignore, which BuildKit prefers over the +# repository-root .dockerignore for this Dockerfile. +# +# docker build -f apps/dashboard/Dockerfile -t mosaic-dashboard:dev . +# +# The runtime serves the TanStack Start server on port 3000. + +FROM node:22.23.1-alpine AS build +ARG VERSION=dev +ARG COMMIT="" +ARG SOURCE_DATE_EPOCH="" +ENV MOSAIC_COMMIT=${COMMIT} \ + SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} +WORKDIR /src + +# Manifests first so dependency installation is cached independently of source. +COPY apps/dashboard/package.json apps/dashboard/package-lock.json ./apps/dashboard/ +COPY packages/design-system/package.json ./packages/design-system/ +COPY packages/design-tokens/package.json ./packages/design-tokens/ +# `npm ci` is deterministic: it installs exactly the locked tree or fails. +# --include=dev is explicit so the build toolchain is present regardless of the +# base image's NODE_ENV. +RUN cd apps/dashboard && npm ci --include=dev + +# The dashboard imports the generated protocol browser bundle directly. +COPY protocol/ ./protocol/ +COPY packages/ ./packages/ +COPY apps/dashboard/ ./apps/dashboard/ +# NODE_ENV must be production for this step: building with a development +# NODE_ENV emits the development JSX runtime, which then fails at runtime +# against production react-dom with "jsxDEV is not a function". +# Source maps are intentionally not emitted (see vite.config.ts). +RUN cd apps/dashboard && NODE_ENV=production npm run build + +# Reinstall production dependencies only, for the runtime layer. +RUN cd apps/dashboard && npm ci --omit=dev + +FROM node:22.23.1-alpine AS runtime +ARG VERSION=dev +ARG COMMIT="" +LABEL org.opencontainers.image.title="Mosaic Dashboard" \ + org.opencontainers.image.source="https://github.com/Mujhtech/mosaic" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${COMMIT}" + +ENV NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 +WORKDIR /app + +COPY --from=build /src/apps/dashboard/dist ./dist +COPY --from=build /src/apps/dashboard/node_modules ./node_modules +COPY --from=build /src/apps/dashboard/package.json ./package.json +COPY --from=build /src/packages ../packages + +# The node image ships an unprivileged `node` user; the dashboard never needs +# to write to its own filesystem. +USER node +EXPOSE 3000 + +# The server answers GET / with the SSR shell; a 200 means the Node process and +# the built bundle are both healthy. It intentionally does not probe the Mosaic +# API: the dashboard must stay up and report API problems, not fail with them. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "run", "start"] diff --git a/apps/dashboard/Dockerfile.dockerignore b/apps/dashboard/Dockerfile.dockerignore new file mode 100644 index 00000000..e6b324d7 --- /dev/null +++ b/apps/dashboard/Dockerfile.dockerignore @@ -0,0 +1,23 @@ +# BuildKit prefers this file over the repository-root .dockerignore when +# building apps/dashboard/Dockerfile, so the dashboard can use the repository +# root as its context (it needs packages/) without the API's exclusions. +.git +.github +.vscode +.codex +.claude +**/.DS_Store + +apps/api/ +apps/worker/ +sdk/ +examples/ +deploy/ +docs/ +scripts/ + +**/node_modules/ +**/dist/ +**/.output/ +**/coverage/ +**/*.log diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index eb88aba7..d43f9ef5 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -12,9 +12,11 @@ Applications, Environments, environment-scoped API keys, memberships, and the pr Catalog. Hosted REST calls use the generated client under `src/generated/api`; regenerate it from the backend OpenAPI document after a contract change with `npm run generate:api`. -Hosted authentication remains an explicit owner-decision gate. The login and signup forms validate -locally with TanStack Form but do not create a browser session or simulate a signed-in user. -`/studio` remains account-free. +Hosted authentication is implemented. The login and signup forms in +`src/features/auth/components` validate with TanStack Form and call the Mosaic REST API, which +establishes an HttpOnly browser session cookie. `/_hosted` routes are guarded: an unauthenticated +visitor is redirected to `/login?returnTo=…` with a sanitized internal path, and the sidebar footer +provides sign-out. `/studio` remains account-free and needs no session. ## Analytics workspace @@ -45,6 +47,100 @@ payloads do not submit tenant or Application identifiers; trusted scope is deriv - Node.js 22.12 or newer - npm 10 or newer +## Supported browsers + +| Browser | Minimum | +| ------- | ------- | +| Chrome | 111 | +| Edge | 111 | +| Safari | 16.4 | +| Firefox | 128 | + +The floor is set by Tailwind CSS v4 (cascade layers and `@property`); older browsers render an +unusable layout rather than a degraded one. The matrix is encoded as `browserslist` in +`package.json`, so PostCSS and Tailwind target exactly these engines. No polyfills are shipped. + +**HTTPS or `localhost` is required.** The session cookie is `Secure`, and clipboard access used to +copy correlation identifiers is restricted to secure contexts. Serving the dashboard over plain +`http://` on any other host produces a sign-in loop. + +Viewport floors: Studio is **desktop-only** and requires at least 768 px of width, below which it +shows a desktop-required state that still allows a safe local export. The hosted workspace is +**desktop-first** — usable on a tablet, but its wide tables and panels are laid out for desktop +widths and it is not a supported phone experience. + +## Runtime configuration + +Configuration is read from the **server's** environment at render time and injected into the page as +`window.__MOSAIC_CONFIG__` before any application module executes. The shipped bundle contains no +baked-in API URL, so one image can be deployed against any API host without rebuilding. + +| Variable | Default | Meaning | +| ------------------------------------- | ----------------------------- | ------------------------------------------------------ | +| `MOSAIC_DASHBOARD_API_BASE_URL` | `http://localhost:8080` | Mosaic API origin. Absolute `http:`/`https:` URL. | +| `MOSAIC_DASHBOARD_PREVIEW_URL` | `ws://127.0.0.1:4317/preview` | Local Studio preview relay. Absolute `ws:`/`wss:` URL. | +| `MOSAIC_DASHBOARD_PREVIEW_SESSION_ID` | `session_local_01` | Preview session identifier. | +| `PORT` / `HOST` | `3000` / `0.0.0.0` | Listen address for the production server. | + +Values are validated in `src/config/environment.ts`. A missing, malformed, or wrong-protocol value +falls back to the documented default instead of throwing, so a misconfigured deployment still +renders a page that can explain the problem. Confirm the resolved values on `/diagnostics`. + +The build-time `VITE_API_BASE_URL`, `VITE_MOSAIC_PREVIEW_URL`, and `VITE_MOSAIC_PREVIEW_SESSION_ID` +remain **development** fallbacks only. + +`MOSAIC_DASHBOARD_PORT` and `MOSAIC_DASHBOARD_API_BASE_URL` are declared in the repository-root +`.env.example` because the Compose stack injects them into the dashboard container. +`MOSAIC_DASHBOARD_PREVIEW_URL` and `MOSAIC_DASHBOARD_PREVIEW_SESSION_ID` are Studio-local and are +deliberately absent from it. + +Three dashboard behaviours ship documented rather than fixed for v1 — the SSR cookie caveat, the +desktop-first workspace floor, and the absence of client-side error reporting. Each has an entry in +[`docs/known-limitations.md`](../../docs/known-limitations.md). + +## Deployment + +The dashboard ships as its own container image listening on **port 3000**: + +```bash +docker build \ + -f apps/dashboard/Dockerfile \ + --build-arg VERSION=1.0.0-rc.1 \ + --build-arg COMMIT="$(git rev-parse --short HEAD)" \ + -t mosaic-dashboard:1.0.0-rc.1 \ + . +``` + +The build context is the repository root, because the dashboard consumes the local `file:` packages +under `packages/` and the generated protocol bundle under `protocol/browser`. Exclusions come from +`apps/dashboard/Dockerfile.dockerignore`. The image runs as the unprivileged `node` user, sets +`NODE_ENV=production`, installs with `npm ci` for a deterministic tree, and exposes a `HEALTHCHECK` +that requests `/` from itself without probing the Mosaic API. + +Source maps are not emitted or shipped: they would publish Mosaic's client source to every visitor, +and Mosaic sends no client error reports anywhere that would consume them. + +Full operational detail, including troubleshooting for blank pages, 401 loops, CORS, missing +cookies, a wrong API URL, and the SSR cookie caveat, is in +[`docs/dashboard/operations.md`](../../docs/dashboard/operations.md). + +## Error-reporting policy + +Mosaic ships **no client-side error reporting** — no Sentry, no telemetry beacon, no automatic crash +upload. This is deliberate: an operator who self-hosts Mosaic must not have their users' browsing +silently forwarded to a third party, and Mosaic has no service to forward it to. + +What replaces it: + +- Every API failure carries an `X-Request-ID` correlation identifier, surfaced in error boundaries + and on `/diagnostics` with a copy control plus a selectable fallback for browsers without + clipboard access. +- User-facing copy never contains raw server messages or stack traces. `describeApiError` in + `src/lib/api/errors.ts` maps failures onto Mosaic-owned text; server detail stays in the API logs, + already correlated by the same identifier. +- `/diagnostics` reports the dashboard version, commit, build time, resolved API base URL, session + state, and an on-demand API liveness probe. + ## Setup ```bash @@ -140,9 +236,10 @@ Layers, Components, Products, or Localization. `F` fits the canvas, `Shift+0` re diagnostics panel. Global shortcuts pause while an input, textarea, select, contenteditable, or command search owns focus. -Set `VITE_API_BASE_URL` for the hosted REST workspace. The default is `http://localhost:8080`; the -generated client supplies the versioned `/v1/...` paths. Local Studio itself does not require the -API. +In development, `VITE_API_BASE_URL` still points the hosted REST workspace at an API; deployed +instances use `MOSAIC_DASHBOARD_API_BASE_URL` instead (see **Runtime configuration**). The default is +`http://localhost:8080`, and the generated client supplies the versioned `/v1/...` paths. Local +Studio itself does not require the API. ## Purchase setup diff --git a/apps/dashboard/package-lock.json b/apps/dashboard/package-lock.json index c2538f5c..e2c93708 100644 --- a/apps/dashboard/package-lock.json +++ b/apps/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mosaic/dashboard", - "version": "0.1.0", + "version": "1.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mosaic/dashboard", - "version": "0.1.0", + "version": "1.0.0-rc.1", "dependencies": { "@base-ui/react": "^1.6.0", "@mosaic/design-system": "file:../../packages/design-system", @@ -24,6 +24,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.12.2", + "srvx": "^0.11.22", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "ws": "^8.21.1" @@ -48,7 +49,6 @@ "jsdom": "^29.1.1", "prettier": "^3.9.5", "prettier-plugin-tailwindcss": "^0.8.1", - "srvx": "^0.11.22", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", @@ -3165,16 +3165,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -4008,9 +4008,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 38a4d15b..507ef793 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,11 +1,17 @@ { "name": "@mosaic/dashboard", - "version": "0.1.0", + "version": "1.0.0-rc.1", "private": true, "type": "module", "sideEffects": [ "**/*.css" ], + "browserslist": [ + "chrome >= 111", + "edge >= 111", + "firefox >= 128", + "safari >= 16.4" + ], "engines": { "node": ">=22.12.0" }, @@ -22,10 +28,10 @@ "format:check": "prettier --check .", "lint": "eslint . --max-warnings=0", "typecheck": "tsc --noEmit", - "test": "vitest run && npm run test:relay", + "test": "vitest run", "test:relay": "node --test scripts/local-preview-relay.test.mjs", "test:watch": "vitest", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build" + "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run test:relay && npm run build" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -44,6 +50,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.12.2", + "srvx": "^0.11.22", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "ws": "^8.21.1" @@ -68,7 +75,6 @@ "jsdom": "^29.1.1", "prettier": "^3.9.5", "prettier-plugin-tailwindcss": "^0.8.1", - "srvx": "^0.11.22", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", diff --git a/apps/dashboard/src/build-info.d.ts b/apps/dashboard/src/build-info.d.ts new file mode 100644 index 00000000..2ebc5de2 --- /dev/null +++ b/apps/dashboard/src/build-info.d.ts @@ -0,0 +1,8 @@ +/** + * Build identity stamped by Vite `define` (see vite.config.ts). These make the + * running bundle self-identifying, which is what a deployment diagnosis needs + * when several dashboard versions may be in circulation. + */ +declare const __MOSAIC_VERSION__: string +declare const __MOSAIC_COMMIT__: string +declare const __MOSAIC_BUILD_TIME__: string diff --git a/apps/dashboard/src/components/feedback/app-error-boundary.tsx b/apps/dashboard/src/components/feedback/app-error-boundary.tsx new file mode 100644 index 00000000..8e2687d6 --- /dev/null +++ b/apps/dashboard/src/components/feedback/app-error-boundary.tsx @@ -0,0 +1,55 @@ +import * as React from "react" + +import { ErrorState } from "@/components/feedback/error-state" +import { describeApiError } from "@/lib/api/errors" + +interface AppErrorBoundaryState { + error: unknown +} + +/** + * Last-resort boundary mounted above the application providers. + * + * A render failure inside a provider (query client, tooltip portal, theme) + * escapes the router's route-level boundaries and would otherwise blank the + * page. Mosaic ships no client error reporting by design, so nothing is sent + * anywhere: the operator gets recovery actions and, when available, the + * correlation identifier to quote to whoever runs the API. + */ +export class AppErrorBoundary extends React.Component< + { children: React.ReactNode }, + AppErrorBoundaryState +> { + state: AppErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { + return { error } + } + + render() { + if (!this.state.error) return this.props.children + + const described = describeApiError(this.state.error) + + return ( +
+ { + this.setState({ error: null }) + }} + retryLabel="Try rendering again" + title="The dashboard could not be displayed" + /> + {described.correlationId ? ( +

+ Request ID:{" "} + + {described.correlationId} + +

+ ) : null} +
+ ) + } +} diff --git a/apps/dashboard/src/components/feedback/connectivity-banner.tsx b/apps/dashboard/src/components/feedback/connectivity-banner.tsx new file mode 100644 index 00000000..5295b70a --- /dev/null +++ b/apps/dashboard/src/components/feedback/connectivity-banner.tsx @@ -0,0 +1,36 @@ +import { WifiSlashIcon } from "@phosphor-icons/react/dist/ssr/WifiSlash" + +import { useConnectivityStatus } from "@/hooks/use-connectivity-status" + +/** + * Always-mounted status region. It stays in the tree so assistive technology + * announces the transition rather than a region appearing from nowhere. + */ +export function ConnectivityBanner() { + const { isDegraded, isOffline } = useConnectivityStatus() + const visible = isDegraded || isOffline + + return ( +
+ {visible ? ( + <> + + + {isOffline + ? "You are offline. Mosaic will keep showing the last loaded data and retry automatically." + : "Mosaic cannot reach the API. Displayed data may be stale; retries continue in the background."} + + + ) : null} +
+ ) +} diff --git a/apps/dashboard/src/components/feedback/live-announcer.tsx b/apps/dashboard/src/components/feedback/live-announcer.tsx new file mode 100644 index 00000000..1a25f0cf --- /dev/null +++ b/apps/dashboard/src/components/feedback/live-announcer.tsx @@ -0,0 +1,27 @@ +/** + * Always-mounted screen-reader announcement region. + * + * A live region that is mounted at the same moment its text appears is + * frequently missed by assistive technology. Mosaic keeps one empty region in + * the tree per surface and only changes its text, so every announcement is + * observed as a content change. + */ +export function LiveAnnouncer({ + assertive = false, + message, +}: { + assertive?: boolean + message?: string +}) { + return ( +
+ {message ?? ""} +
+ ) +} diff --git a/apps/dashboard/src/components/feedback/route-feedback.tsx b/apps/dashboard/src/components/feedback/route-feedback.tsx index 66036e24..3b3601d1 100644 --- a/apps/dashboard/src/components/feedback/route-feedback.tsx +++ b/apps/dashboard/src/components/feedback/route-feedback.tsx @@ -1,24 +1,76 @@ -import type { ErrorComponentProps } from "@tanstack/react-router" +import { Link, type ErrorComponentProps } from "@tanstack/react-router" import { EmptyState } from "@/components/feedback/empty-state" import { ErrorState } from "@/components/feedback/error-state" +import { LoadingState } from "@/components/feedback/loading-state" +import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button-variants" +import { describeApiError } from "@/lib/api/errors" -export function RouteErrorState({ reset }: ErrorComponentProps) { +function RouteRecoveryActions() { return ( - +
+ + Go to workspace + + +
+ ) +} + +export function RouteErrorState({ error, reset }: ErrorComponentProps) { + // Stack traces and raw server messages are never rendered; operators get the + // correlation ID instead (client error reporting is off by design). + const described = describeApiError(error) + + return ( +
+ + {described.correlationId ? ( +

+ Request ID:{" "} + + {described.correlationId} + +

+ ) : null} + +
) } export function RouteNotFoundState() { return ( - + + + + ) +} + +export function RoutePendingState() { + return ( + ) } diff --git a/apps/dashboard/src/components/layout/root-document.tsx b/apps/dashboard/src/components/layout/root-document.tsx index b902f7e0..5f1837e8 100644 --- a/apps/dashboard/src/components/layout/root-document.tsx +++ b/apps/dashboard/src/components/layout/root-document.tsx @@ -1,6 +1,8 @@ import { HeadContent, Scripts } from "@tanstack/react-router" import type { ReactNode } from "react" +import { runtimeConfigScript } from "@/config/environment" + export function RootDocument({ children }: { children: ReactNode }) { return ( @@ -8,6 +10,14 @@ export function RootDocument({ children }: { children: ReactNode }) { + {/* Runtime configuration is assigned before the application modules at + the end of the body execute, so the same image works against any + API host. The value is resolved identically on the server and the + client, so hydration stays stable. */} +