Phase 3 backend: tier-gate enforcement + billing webhook
CI / policy (push) Successful in 5s
CI / profile (push) Successful in 11s
CI / mobile (push) Successful in 27s
CI / backend (push) Failing after 1m7s

- Tier capability model (pro-layers, geo-analytics, custom-domain, retention)
- Pro-only layer types (video/particle/animatedGradient) rejected on card
  create/replace/patch for Free; geo analytics gated to Pro+ (402 tier_limit)
- Gates read the authoritative DB tier, so upgrades apply without re-login
- POST /v1/webhooks/revenuecat: shared-secret auth (constant-time), maps
  RevenueCat events to users.tier (purchase→pro/team/enterprise, cancel→free),
  unknown user = 2xx no-op; users::update_tier query

93 backend tests; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 13:55:21 -05:00
co-authored by Claude Opus 4.8
parent 9b7c845319
commit 4ad3220917
17 changed files with 518 additions and 4 deletions
@@ -33,6 +33,7 @@ pub async fn create(
) -> Result<CardRow, AppError> {
crate::validation::validate_handle(handle)?;
require_object(definition)?;
enforce_layer_tier(definition, current_tier(state, user_id).await?)?;
cards::insert(
&state.db,
@@ -54,6 +55,7 @@ pub async fn replace(
) -> Result<CardRow, AppError> {
get_owned(state, id, user_id).await?;
require_object(definition)?;
enforce_layer_tier(definition, current_tier(state, user_id).await?)?;
cards::update_definition(&state.db, id, definition)
.await
.map_db()
@@ -76,6 +78,7 @@ pub async fn patch(
for (k, v) in patch {
base.insert(k.clone(), v.clone());
}
enforce_layer_tier(&merged, current_tier(state, user_id).await?)?;
cards::update_definition(&state.db, id, &merged)
.await
.map_db()
@@ -175,6 +178,42 @@ pub async fn export_vcf(state: &AppState, id: Uuid, user_id: Uuid) -> Result<Str
// ---- Helpers --------------------------------------------------------------
/// The caller's current tier from the DB (authoritative — reflects webhook
/// upgrades immediately, unlike the tier embedded in the access token).
async fn current_tier(state: &AppState, user_id: Uuid) -> Result<Tier, AppError> {
let user = users::find_by_id(&state.db, user_id)
.await
.map_db()?
.ok_or(AppError::Unauthorized)?;
Ok(user.tier)
}
/// Pro-and-above layer types (PRD §6.1.1). Free cards may not use them.
const PRO_LAYER_TYPES: &[&str] = &["video", "particle", "animatedGradient"];
/// Reject Pro-only layer types for tiers that don't allow them.
fn enforce_layer_tier(definition: &serde_json::Value, tier: Tier) -> Result<(), AppError> {
if tier.allows_pro_layers() {
return Ok(());
}
for side in ["face", "back"] {
let layers = definition
.get(side)
.and_then(|s| s.get("layers"))
.and_then(|l| l.as_array());
for layer in layers.into_iter().flatten() {
if let Some(t) = layer.get("type").and_then(|t| t.as_str()) {
if PRO_LAYER_TYPES.contains(&t) {
return Err(AppError::TierLimit(format!(
"{t} layers require a Pro plan"
)));
}
}
}
}
Ok(())
}
fn require_object(definition: &serde_json::Value) -> Result<(), AppError> {
if definition.is_object() {
Ok(())