Skip to content

Commit 3bacc13

Browse files
committed
feat(entitlements): feature catalog, resolver, subscriptions, /me/entitlements
One catalog declares each feature's rollout flag and per-plan defaults, and two evaluators read it: the flag service for deployment rollout and the new resolver for what a principal holds. Plan state is a status machine on the subscriptions document, versioned per owner in Redis and invalidated inside every repository write. Replaces users.plan, the TIER rollout and CurrentUser.tier.
1 parent f5d6595 commit 3bacc13

71 files changed

Lines changed: 3780 additions & 194 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ HCAPTCHA_SITEKEY="" # hCaptcha site key (public, used in temp
1919
SECRET_KEY="" # To generate: python -c "import os; print(os.urandom(32).hex())"
2020
HOST_URI="127.0.0.1:8000"
2121
ENV="development" # change to "production" in production
22+
# none = self-host (every account holds every feature); paddle = spoo.me cloud.
23+
# Production refuses to boot unless this is set explicitly.
24+
BILLING_PROVIDER="none"
2225

2326
# CORS — allowed origins for private routes (auth, oauth, dashboard)
2427
# Public API routes (/api/v1/*) always allow all origins.

app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from infrastructure.oauth_clients import OAUTH_STATE_TTL_SECONDS, init_oauth
3131
from infrastructure.queue_redis import connect_queue_redis
3232
from infrastructure.templates import configure_template_globals, templates
33+
from middleware.entitlements import EntitlementsVersionMiddleware
3334
from middleware.error_handler import register_error_handlers
3435
from middleware.logging import RequestLoggingMiddleware
3536
from middleware.openapi import (
@@ -314,6 +315,9 @@ async def docs(request: Request):
314315
# writes view_rate_limit into shared scope state during endpoint
315316
# execution, before any response starts flowing outward
316317
app.add_middleware(RateLimitHeadersMiddleware)
318+
# 8. Entitlement version header on authenticated responses; reads the
319+
# version the Entitled dependency stored, or one cache lookup.
320+
app.add_middleware(EntitlementsVersionMiddleware)
317321

318322
# ── Error handlers + rate limiter ────────────────────────────────────
319323
app.state.limiter = limiter

config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,31 @@ class SchedulerSettings(BaseSettings):
664664
lease_seconds: int = Field(default=600, ge=30)
665665

666666

667+
class BillingSettings(BaseSettings):
668+
"""Billing provider and the display prices the plans endpoint shows.
669+
670+
``BILLING_PROVIDER=none`` is self-host: no billing, and the resolver hands
671+
every account the ``selfhost`` plan. Production must set it explicitly so
672+
the cloud can never fall into self-host by omission. Prices are display
673+
values only; the provider owns what is charged.
674+
"""
675+
676+
model_config = SettingsConfigDict(
677+
env_file=".env", env_prefix="BILLING_", extra="ignore"
678+
)
679+
680+
provider: Literal["none", "paddle"] = "none"
681+
pro_monthly_usd: int = Field(default=15, ge=0)
682+
pro_year_usd: int = Field(default=144, ge=0)
683+
founding_monthly_usd: int = Field(default=9, ge=0)
684+
founding_year_usd: int = Field(default=90, ge=0)
685+
founding_seats: int = Field(default=100, ge=0)
686+
687+
@property
688+
def selfhost(self) -> bool:
689+
return self.provider == "none"
690+
691+
667692
class AppSettings(BaseSettings):
668693
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
669694

@@ -859,6 +884,7 @@ def _password_max_length_sane(cls, v: int) -> int:
859884
scheduler: SchedulerSettings | None = None
860885
llm: LlmSettings | None = None
861886
posthog_erasure: PostHogErasureSettings | None = None
887+
billing: BillingSettings | None = None
862888

863889
@model_validator(mode="after")
864890
def _populate_sub_configs_and_secret(self) -> AppSettings:
@@ -908,6 +934,8 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
908934
self.scheduler = SchedulerSettings()
909935
if self.posthog_erasure is None:
910936
self.posthog_erasure = PostHogErasureSettings()
937+
if self.billing is None:
938+
self.billing = BillingSettings()
911939
if self.webhooks.enabled and not self.secret_key:
912940
# Signing secrets are encrypted with a key derived from
913941
# SECRET_KEY; an empty master would mean a predictable key.
@@ -926,6 +954,13 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
926954
if self.env == "production" and self.account_deletion_grace_days < 1:
927955
raise ValueError("ACCOUNT_DELETION_GRACE_DAYS must be >= 1 in production")
928956

957+
# Unset means self-host, which hands every account every feature.
958+
if self.env == "production" and "provider" not in self.billing.model_fields_set:
959+
raise ValueError(
960+
"BILLING_PROVIDER must be set explicitly in production: "
961+
"none for self-host, paddle for the cloud"
962+
)
963+
929964
return self
930965

931966
@property

dependencies/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
require_session_or_scopes,
3636
require_verified_email,
3737
)
38+
from dependencies.entitlements import (
39+
Entitled,
40+
EntitlementSvc,
41+
get_entitlement_service,
42+
get_entitlements,
43+
)
3844
from dependencies.infra import (
3945
AppRegistryDep,
4046
GeoIP,
@@ -132,6 +138,8 @@
132138
"CustomDomainSvc",
133139
"DeviceAuthSvc",
134140
"DomainIntelSvc",
141+
"Entitled",
142+
"EntitlementSvc",
135143
"ExportSvc",
136144
"FeatureFlagSvc",
137145
"GeoIP",
@@ -175,6 +183,8 @@
175183
"get_db",
176184
"get_device_auth_service",
177185
"get_email_provider",
186+
"get_entitlement_service",
187+
"get_entitlements",
178188
"get_export_service",
179189
"get_feature_flag_service",
180190
"get_geoip_service",

dependencies/auth.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,9 @@ class CurrentUser:
6464
# access tokens minted before the claim existed; those users match by
6565
# user_id only until their next token refresh.
6666
email: str | None = field(default=None)
67-
# UserDoc.plan value (e.g. "FREE") — consumed by FeatureFlagService's
68-
# TIER rollout via getattr(user, "tier"). Populated from the DB on the
69-
# API-key path and from the (future) "plan" claim on the JWT path.
70-
tier: str | None = field(default=None)
67+
# The JWT "plan" claim: a hint for the fail mode only, never authority.
68+
# None on the API-key path; the resolver reads the owner's plan by id.
69+
plan_claim: str | None = field(default=None)
7170

7271

7372
async def get_current_user(
@@ -189,7 +188,6 @@ async def get_current_user(
189188
# The owning UserDoc is already fetched above for
190189
# email_verified — no extra DB hit to carry the email.
191190
email=user.email.lower() if user and user.email else None,
192-
tier=user.plan.value if user and user.plan else None,
193191
)
194192

195193
# ── JWT path ──────────────────────────────────────────────────────────────
@@ -246,9 +244,7 @@ async def get_current_user(
246244
email_verified=email_verified,
247245
amr=amr,
248246
email=email,
249-
# Not issued yet — the paid-plans launch adds the claim; TIER
250-
# flag rollouts become a pure data change at that point.
251-
tier=claims.get("plan"),
247+
plan_claim=claims.get("plan"),
252248
scopes=scopes,
253249
app_id=claims.get("app_id"),
254250
)

dependencies/entitlements.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
``Entitled``: the resolved entitlements of the request's principal.
3+
4+
Runs the resolver once per request (cache hit in the common case) and hands
5+
the map to routes and services. Nothing on the request path reads
6+
``subscriptions`` or overrides directly.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Annotated
12+
13+
from fastapi import Depends, Request
14+
15+
from dependencies.auth import CurrentUser, get_current_user
16+
from services.entitlements import EntitlementService, Resolved
17+
18+
19+
def get_entitlement_service(request: Request) -> EntitlementService:
20+
return request.app.state.entitlement_service
21+
22+
23+
async def get_entitlements(
24+
request: Request,
25+
user: CurrentUser | None = Depends(get_current_user),
26+
service: EntitlementService = Depends(get_entitlement_service),
27+
) -> Resolved:
28+
resolved = await service.resolve_for(
29+
user.user_id if user else None,
30+
plan_hint=user.plan_claim if user else None,
31+
)
32+
if user is not None and not resolved.degraded:
33+
request.state.entitlements_version = resolved.version
34+
return resolved
35+
36+
37+
Entitled = Annotated[Resolved, Depends(get_entitlements)]
38+
EntitlementSvc = Annotated[EntitlementService, Depends(get_entitlement_service)]

dependencies/wiring.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from fastapi import FastAPI
1313

1414
from config import AppSettings
15+
from infrastructure.cache.entitlement_cache import EntitlementCache
1516
from infrastructure.cache.feature_flag_cache import FeatureFlagCache
1617
from infrastructure.cache.meta_fetch_cache import MetaFetchCache
1718
from infrastructure.cache.onboarding_cache import OnboardingCache
@@ -37,6 +38,10 @@
3738
from repositories.blocked_url_repository import BlockedUrlRepository
3839
from repositories.click_repository import ClickRepository
3940
from repositories.custom_domain_repository import CustomDomainRepository
41+
from repositories.entitlement_event_repository import EntitlementEventRepository
42+
from repositories.entitlement_override_repository import (
43+
EntitlementOverrideRepository,
44+
)
4045
from repositories.feature_flag_repository import FeatureFlagRepository
4146
from repositories.feed_domain_repository import FeedDomainRepository
4247
from repositories.legacy.emoji_url_repository import EmojiUrlRepository
@@ -47,6 +52,7 @@
4752
ReportSubmissionRepository,
4853
)
4954
from repositories.scheduled_task_repository import ScheduledTaskRepository
55+
from repositories.subscription_repository import SubscriptionRepository
5056
from repositories.tag_repository import TagRepository
5157
from repositories.token_repository import TokenRepository
5258
from repositories.url_repository import UrlRepository
@@ -79,6 +85,7 @@
7985
from services.custom_domain_service import CustomDomainService
8086
from services.domain_intel_service import DomainIntelService
8187
from services.edge_cache.og_writethrough import OgEdgeWritethrough
88+
from services.entitlements import EntitlementService
8289
from services.events.sinks import (
8390
InlineDomainEventSink,
8491
NullDomainEventSink,
@@ -87,6 +94,7 @@
8794
from services.export.formatters import default_formatters
8895
from services.export.service import ExportService
8996
from services.feature_flag_service import FeatureFlagService
97+
from services.features.catalog import validate_override
9098
from services.meta_tags.sinks import NullMetaImageSink, RedisStreamMetaImageSink
9199
from services.mock_dcv_backend import MockDcvBackend
92100
from services.oauth_service import OAuthService
@@ -258,6 +266,49 @@ def build_posthog_eraser(settings: AppSettings, http_client) -> PostHogEraser:
258266
)
259267

260268

269+
def build_entitlement_store(
270+
db, redis_client
271+
) -> tuple[
272+
EntitlementCache,
273+
EntitlementEventRepository,
274+
SubscriptionRepository,
275+
EntitlementOverrideRepository,
276+
]:
277+
"""The three entitlement repositories sharing one cache, so every write
278+
to subscriptions or overrides invalidates the same ``ent:{id}`` key."""
279+
cache = EntitlementCache(redis_client)
280+
events = EntitlementEventRepository(db["entitlement_events"])
281+
subscriptions = SubscriptionRepository(db["subscriptions"], events, cache)
282+
overrides = EntitlementOverrideRepository(
283+
db["entitlement_overrides"], events, cache, check=validate_override
284+
)
285+
return cache, events, subscriptions, overrides
286+
287+
288+
def build_entitlement_service(
289+
db, settings: AppSettings, redis_client, *, store=None
290+
) -> EntitlementService:
291+
cache, events, subscriptions, overrides = store or build_entitlement_store(
292+
db, redis_client
293+
)
294+
return EntitlementService(
295+
subscriptions,
296+
overrides,
297+
events,
298+
cache,
299+
selfhost=settings.billing.selfhost,
300+
usage={
301+
"custom_domains_max": CustomDomainRepository(
302+
db["custom_domains"]
303+
).count_by_owner,
304+
"webhook_endpoints_max": WebhookEndpointRepository(
305+
db["webhook-endpoints"]
306+
).count_by_user,
307+
"api_keys_max": ApiKeyRepository(db["api-keys"]).count_by_user,
308+
},
309+
)
310+
311+
261312
def build_account_erasure_service(
262313
db,
263314
settings: AppSettings,
@@ -355,6 +406,9 @@ def build_account_erasure_service(
355406
redis_client=redis_client,
356407
url_service=url_service,
357408
)
409+
_, ent_events, subscription_repo, override_repo = build_entitlement_store(
410+
db, redis_client
411+
)
358412

359413
return AccountErasureService(
360414
user_repo=user_repo,
@@ -372,6 +426,9 @@ def build_account_erasure_service(
372426
report_repo=ReportRepository(db["reports"]),
373427
report_submission_repo=ReportSubmissionRepository(db["report_submissions"]),
374428
feature_flag_repo=FeatureFlagRepository(db["feature_flags"]),
429+
subscription_repo=subscription_repo,
430+
override_repo=override_repo,
431+
entitlement_event_repo=ent_events,
375432
r2_storage=r2_storage,
376433
posthog=build_posthog_eraser(settings, http_client),
377434
mailer=build_erasure_mailer(settings, http_client),
@@ -403,6 +460,11 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
403460
blocked_url_repo = BlockedUrlRepository(db["blocked-urls"])
404461
app_grant_repo = AppGrantRepository(db["app-grants"])
405462
feature_flag_repo = FeatureFlagRepository(db["feature_flags"])
463+
ent_store = build_entitlement_store(db, redis_client)
464+
_, ent_events, subscription_repo, override_repo = ent_store
465+
app.state.entitlement_service = build_entitlement_service(
466+
db, settings, redis_client, store=ent_store
467+
)
406468

407469
# ── Infrastructure ───────────────────────────────────────────────────
408470
url_cache = UrlCache(redis_client, ttl_seconds=settings.redis.redis_ttl_seconds)
@@ -773,7 +835,9 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
773835
max_active_keys=settings.max_active_api_keys,
774836
)
775837
app.state.page_layout_service = PageLayoutService(page_layout_repo)
776-
token_factory = TokenFactory(settings.jwt)
838+
token_factory = TokenFactory(
839+
settings.jwt, plan_of=app.state.entitlement_service.plan_hint_for
840+
)
777841
otp_service = OtpService(token_repo)
778842

779843
app.state.user_repo = user_repo
@@ -1012,6 +1076,9 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
10121076
report_repo=report_repo,
10131077
report_submission_repo=report_submission_repo,
10141078
feature_flag_repo=feature_flag_repo,
1079+
subscription_repo=subscription_repo,
1080+
override_repo=override_repo,
1081+
entitlement_event_repo=ent_events,
10151082
r2_storage=r2_storage,
10161083
posthog=build_posthog_eraser(settings, http_client),
10171084
mailer=erasure_mailer,

errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"details": NotRequired[Any],
2929
"message": NotRequired[str],
3030
"hint": NotRequired[str],
31+
"feature": NotRequired[str],
32+
"limit": NotRequired[str],
33+
"max": NotRequired[int],
34+
"current": NotRequired[int],
3135
},
3236
)
3337

@@ -121,6 +125,12 @@ class ConflictError(AppError):
121125
error_code = "conflict"
122126

123127

128+
class InvalidTransitionError(ConflictError):
129+
"""A subscription event arrived in a status it cannot legally change."""
130+
131+
error_code = "invalid_transition"
132+
133+
124134
class BlockedUrlError(AppError):
125135
status_code = 451
126136
error_code = "blocked"

0 commit comments

Comments
 (0)