Skip to content

Commit 507a34c

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 457673a commit 507a34c

63 files changed

Lines changed: 3562 additions & 191 deletions

Some content is hidden

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

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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,29 @@ 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 the self-host default: no billing, and the
671+
resolver hands every account the ``selfhost`` plan. Prices are display
672+
values only; the provider owns what is charged.
673+
"""
674+
675+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
676+
677+
billing_provider: Literal["none", "paddle"] = "none"
678+
pro_monthly_usd: int = Field(default=15, ge=0)
679+
pro_year_usd: int = Field(default=144, ge=0)
680+
founding_monthly_usd: int = Field(default=9, ge=0)
681+
founding_year_usd: int = Field(default=90, ge=0)
682+
founding_seats: int = Field(default=100, ge=0)
683+
founding_window_days: int = Field(default=90, ge=0)
684+
685+
@property
686+
def selfhost(self) -> bool:
687+
return self.billing_provider == "none"
688+
689+
667690
class AppSettings(BaseSettings):
668691
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
669692

@@ -854,6 +877,7 @@ def _password_max_length_sane(cls, v: int) -> int:
854877
scheduler: SchedulerSettings | None = None
855878
llm: LlmSettings | None = None
856879
posthog_erasure: PostHogErasureSettings | None = None
880+
billing: BillingSettings | None = None
857881

858882
@model_validator(mode="after")
859883
def _populate_sub_configs_and_secret(self) -> AppSettings:
@@ -903,6 +927,8 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
903927
self.scheduler = SchedulerSettings()
904928
if self.posthog_erasure is None:
905929
self.posthog_erasure = PostHogErasureSettings()
930+
if self.billing is None:
931+
self.billing = BillingSettings()
906932
if self.webhooks.enabled and not self.secret_key:
907933
# Signing secrets are encrypted with a key derived from
908934
# SECRET_KEY; an empty master would mean a predictable key.

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: 66 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,
@@ -258,6 +265,47 @@ def build_posthog_eraser(settings: AppSettings, http_client) -> PostHogEraser:
258265
)
259266

260267

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

359410
return AccountErasureService(
360411
user_repo=user_repo,
@@ -372,6 +423,9 @@ def build_account_erasure_service(
372423
report_repo=ReportRepository(db["reports"]),
373424
report_submission_repo=ReportSubmissionRepository(db["report_submissions"]),
374425
feature_flag_repo=FeatureFlagRepository(db["feature_flags"]),
426+
subscription_repo=subscription_repo,
427+
override_repo=override_repo,
428+
entitlement_event_repo=ent_events,
375429
r2_storage=r2_storage,
376430
posthog=build_posthog_eraser(settings, http_client),
377431
mailer=build_erasure_mailer(settings, http_client),
@@ -403,6 +457,12 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
403457
blocked_url_repo = BlockedUrlRepository(db["blocked-urls"])
404458
app_grant_repo = AppGrantRepository(db["app-grants"])
405459
feature_flag_repo = FeatureFlagRepository(db["feature_flags"])
460+
_, ent_events, subscription_repo, override_repo = build_entitlement_store(
461+
db, redis_client
462+
)
463+
app.state.entitlement_service = build_entitlement_service(
464+
db, settings, redis_client
465+
)
406466

407467
# ── Infrastructure ───────────────────────────────────────────────────
408468
url_cache = UrlCache(redis_client, ttl_seconds=settings.redis.redis_ttl_seconds)
@@ -771,7 +831,9 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
771831
max_active_keys=settings.max_active_api_keys,
772832
)
773833
app.state.page_layout_service = PageLayoutService(page_layout_repo)
774-
token_factory = TokenFactory(settings.jwt)
834+
token_factory = TokenFactory(
835+
settings.jwt, plan_of=app.state.entitlement_service.plan_hint_for
836+
)
775837
otp_service = OtpService(token_repo)
776838

777839
app.state.user_repo = user_repo
@@ -1010,6 +1072,9 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
10101072
report_repo=report_repo,
10111073
report_submission_repo=report_submission_repo,
10121074
feature_flag_repo=feature_flag_repo,
1075+
subscription_repo=subscription_repo,
1076+
override_repo=override_repo,
1077+
entitlement_event_repo=ent_events,
10131078
r2_storage=r2_storage,
10141079
posthog=build_posthog_eraser(settings, http_client),
10151080
mailer=erasure_mailer,

errors.py

Lines changed: 45 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,47 @@ 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+
134+
class PlanRequiredError(ForbiddenError):
135+
"""The account's plan does not include the feature being written."""
136+
137+
error_code = "plan_required"
138+
139+
def __init__(self, feature: str) -> None:
140+
super().__init__(f"{feature} is not included in your plan")
141+
self.feature = feature
142+
143+
def to_dict(self) -> ErrorBody:
144+
d = super().to_dict()
145+
d["feature"] = self.feature
146+
return d
147+
148+
149+
class LimitReachedError(ForbiddenError):
150+
"""A per-plan count limit is full; ``max`` and ``current`` let the UI
151+
render the counter."""
152+
153+
error_code = "limit_reached"
154+
155+
def __init__(self, limit: str, *, max_: int, current: int) -> None:
156+
super().__init__(f"{limit} limit reached ({current}/{max_})")
157+
self.limit = limit
158+
self.max = max_
159+
self.current = current
160+
161+
def to_dict(self) -> ErrorBody:
162+
d = super().to_dict()
163+
d["limit"] = self.limit
164+
d["max"] = self.max
165+
d["current"] = self.current
166+
return d
167+
168+
124169
class BlockedUrlError(AppError):
125170
status_code = 451
126171
error_code = "blocked"

0 commit comments

Comments
 (0)