@@ -87,6 +87,39 @@ class EmailSettings(BaseSettings):
8787 zepto_from_name : str = "spoo.me"
8888
8989
90+ class PostHogErasureSettings (BaseSettings ):
91+ """PostHog person deletion for the account-erasure cascade (GDPR Art. 17).
92+
93+ Off unless both ``api_key`` and ``project_id`` are set — the cascade
94+ then skips the step via the Noop eraser. The key is a personal API key
95+ with person-deletion scope, NOT the public project key. Env vars
96+ prefixed ``POSTHOG_ERASURE_`` (same convention as ``R2_``).
97+ """
98+
99+ model_config = SettingsConfigDict (
100+ env_file = ".env" ,
101+ extra = "ignore" ,
102+ env_prefix = "POSTHOG_ERASURE_" ,
103+ )
104+
105+ api_key : str = ""
106+ project_id : str = ""
107+ host : str = "https://eu.posthog.com"
108+
109+ @field_validator ("host" )
110+ @classmethod
111+ def _host_must_be_https (cls , v : str ) -> str :
112+ # The key is a person-deletion-scoped personal API key — a config
113+ # typo must never send it over plaintext. Fail at boot, not mid-sweep.
114+ if not v .startswith ("https://" ):
115+ raise ValueError ("POSTHOG_ERASURE_HOST must be an https:// URL" )
116+ return v
117+
118+ @property
119+ def enabled (self ) -> bool :
120+ return bool (self .api_key and self .project_id )
121+
122+
90123class LoggingSettings (BaseSettings ):
91124 model_config = SettingsConfigDict (env_file = ".env" , extra = "ignore" )
92125
@@ -694,6 +727,18 @@ def blocked_self_domains(self) -> tuple[str, ...]:
694727 max_active_api_keys : int = 20
695728 max_date_range_days : int = 90
696729 http_client_timeout : float = 5.0
730+ # Account deletion (GDPR Art. 17): days between the deletion request
731+ # and the erasure sweep purging the account (0 = purge on the next
732+ # sweep — integration smoke only), and how many due accounts one
733+ # sweep run erases (the */10 cron drains any backlog).
734+ account_deletion_grace_days : int = 7
735+ account_erasure_batch_limit : int = 25
736+ # Sweep-run budget: stop STARTING erasures past this (80% of the 600s
737+ # scheduler lease) so a batch of heavy cascades never outruns the lease.
738+ account_erasure_time_budget_seconds : int = 480
739+ # Erasure-claim lease: ERASING accounts re-claim only after this — must
740+ # exceed the sweep budget plus one heavy cascade (prod whale ~305s).
741+ account_erasure_claim_lease_seconds : int = 900
697742
698743 # Validator constraints (overridable by self-hosters via env vars)
699744 blocked_url_regex_timeout : float = 0.2
@@ -723,13 +768,24 @@ def blocked_self_domains(self) -> tuple[str, ...]:
723768 "max_emoji_alias_length" ,
724769 "emoji_generated_alias_length" ,
725770 "geo_rules_max_countries" ,
771+ "account_erasure_batch_limit" ,
772+ "account_erasure_time_budget_seconds" ,
773+ "account_erasure_claim_lease_seconds" ,
726774 )
727775 @classmethod
728776 def _must_be_positive_int (cls , v : int , info ) -> int :
729777 if v < 1 :
730778 raise ValueError (f"{ info .field_name } must be >= 1, got { v } " )
731779 return v
732780
781+ @field_validator ("account_deletion_grace_days" )
782+ @classmethod
783+ def _grace_days_non_negative (cls , v : int ) -> int :
784+ # 0 is legal (purge on the next sweep) — negatives are not.
785+ if v < 0 :
786+ raise ValueError (f"account_deletion_grace_days must be >= 0, got { v } " )
787+ return v
788+
733789 @field_validator ("emoji_accept_max_version" , "emoji_generate_max_version" )
734790 @classmethod
735791 def _emoji_version_cap_sane (cls , v : float , info ) -> float :
@@ -791,6 +847,7 @@ def _password_max_length_sane(cls, v: int) -> int:
791847 safety : SafetySettings | None = None
792848 scheduler : SchedulerSettings | None = None
793849 llm : LlmSettings | None = None
850+ posthog_erasure : PostHogErasureSettings | None = None
794851
795852 @model_validator (mode = "after" )
796853 def _populate_sub_configs_and_secret (self ) -> AppSettings :
@@ -838,6 +895,8 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
838895 self .safety = SafetySettings ()
839896 if self .scheduler is None :
840897 self .scheduler = SchedulerSettings ()
898+ if self .posthog_erasure is None :
899+ self .posthog_erasure = PostHogErasureSettings ()
841900 if self .webhooks .enabled and not self .secret_key :
842901 # Signing secrets are encrypted with a key derived from
843902 # SECRET_KEY; an empty master would mean a predictable key.
@@ -851,6 +910,11 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
851910 "CUSTOM_DOMAINS_MOCK_DCV must not be enabled in production"
852911 )
853912
913+ # Zero grace purges on the next sweep — a smoke-test convenience
914+ # that in production would void the restore window. Refuse to boot.
915+ if self .env == "production" and self .account_deletion_grace_days < 1 :
916+ raise ValueError ("ACCOUNT_DELETION_GRACE_DAYS must be >= 1 in production" )
917+
854918 return self
855919
856920 @property
0 commit comments