freemyid 0.2.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
freemyid/models.py ADDED
@@ -0,0 +1,700 @@
1
+ """Shared data contracts for Free My ID.
2
+
3
+ OWNER: orchestrator. Other agents must not edit this file; send change requests instead.
4
+
5
+ Everything here is plain data (pydantic v2). No I/O, no network, no Playwright.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import string
11
+ import uuid
12
+ from datetime import UTC, date, datetime
13
+ from enum import StrEnum
14
+ from pathlib import PurePosixPath
15
+ from typing import Annotated, Any, Literal
16
+ from urllib.parse import urlsplit
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator, model_validator
19
+
20
+
21
+ def utcnow() -> datetime:
22
+ return datetime.now(UTC)
23
+
24
+
25
+ def new_id() -> str:
26
+ return uuid.uuid4().hex
27
+
28
+
29
+ # SEC-02: ids end up in filesystem paths (evidence/<profile_id>/...), so they are constrained.
30
+ Id = Annotated[str, Field(pattern=r"^[0-9a-f]{32}$")]
31
+ BrokerId = Annotated[str, Field(pattern=r"^[a-z0-9_]{1,64}$")]
32
+
33
+
34
+ def _safe_relpath(v: str | None) -> str | None:
35
+ if v is None:
36
+ return v
37
+ if not v or "\\" in v or "\x00" in v:
38
+ raise ValueError("relpath must be a non-empty POSIX relative path")
39
+ p = PurePosixPath(v)
40
+ if p.is_absolute() or any(part in ("..", "") for part in v.split("/")):
41
+ raise ValueError("relpath must be relative and must not contain '..'")
42
+ return v
43
+
44
+
45
+ # SEC-07: hosts a (remotely updatable) registry may name. Public suffixes / shared hosting
46
+ # would make NetPolicy allow unrelated sites.
47
+ _HOST_RE = re.compile(r"^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
48
+ _SHARED_SUFFIXES = frozenset({
49
+ "github.io", "githubusercontent.com", "gitlab.io", "herokuapp.com", "appspot.com",
50
+ "cloudfront.net", "amazonaws.com", "azurewebsites.net", "blogspot.com", "netlify.app",
51
+ "vercel.app", "pages.dev", "workers.dev", "web.app", "firebaseapp.com", "co.uk", "com.au",
52
+ "co.nz", "co.jp", "com.br", "co.in",
53
+ })
54
+ _SEARCH_PLACEHOLDERS = frozenset(
55
+ {"first", "last", "middle", "city", "state", "state_name", "zip", "phone"})
56
+ _SUBMITTER_RE = re.compile(r"^freemyid\.submitters\.[a-z0-9_.]+:[A-Za-z_][A-Za-z0-9_]*$")
57
+ # SEC-35: "(a+)+", "(a*)*", "(a{1,9})+", "(a|aa)+" style groups followed by a quantifier
58
+ _NESTED_QUANT_RE = re.compile(r"\((?:[^()\\]|\\.)*(?:[+*}]|\|)(?:[^()\\]|\\.)*\)[+*{]")
59
+ # SEC-31: input#id or input[name="x"] only
60
+ _SAFE_CHECKBOX_RE = re.compile(r"input(?:#[A-Za-z][\w-]{0,63}|\[name=\"[\w.\[\]-]{1,64}\"\])")
61
+ _ROBOTISH_RE = re.compile(r"robot|human|bot\b|captcha|verif|recaptcha|hcaptcha|turnstile", re.IGNORECASE)
62
+
63
+
64
+ def _check_host(h: str) -> str:
65
+ if not _HOST_RE.match(h) or h in _SHARED_SUFFIXES:
66
+ raise ValueError(f"invalid or shared-suffix hostname: {h!r}")
67
+ return h
68
+
69
+
70
+ def _host_in(host: str | None, domains: list[str]) -> bool:
71
+ if not host:
72
+ return False
73
+ host = host.lower().rstrip(".")
74
+ return any(host == d or host.endswith("." + d) for d in domains)
75
+
76
+
77
+ class _Base(BaseModel):
78
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
79
+
80
+
81
+ # --------------------------------------------------------------------------- enums
82
+
83
+
84
+ class Method(StrEnum):
85
+ WEB_FORM = "web_form"
86
+ EMAIL = "email"
87
+ POSTAL = "postal"
88
+ PHONE = "phone"
89
+ ACCOUNT_REQUIRED = "account_required"
90
+
91
+
92
+ class BrokerCategory(StrEnum):
93
+ PEOPLE_SEARCH = "people_search"
94
+ BACKGROUND_CHECK = "background_check"
95
+ DATA_BROKER = "data_broker" # B2B / marketing data (Acxiom, Epsilon, ...)
96
+ RISK_DATA = "risk_data" # LexisNexis-style identity/risk data
97
+ FAMILY_TREE = "family_tree"
98
+ PHONE_LOOKUP = "phone_lookup"
99
+ OTHER = "other"
100
+
101
+
102
+ class VerificationStatus(StrEnum):
103
+ VERIFIED = "verified" # opt-out URL loaded live and matched the cited source
104
+ UNVERIFIED = "unverified" # could not be confirmed live; see verification_notes
105
+ BROKEN = "broken" # health check found it dead/changed
106
+
107
+
108
+ class ProfileField(StrEnum):
109
+ """Profile data a broker's opt-out may require."""
110
+ FULL_NAME = "full_name"
111
+ FIRST_NAME = "first_name"
112
+ LAST_NAME = "last_name"
113
+ MIDDLE_NAME = "middle_name"
114
+ CITY = "city"
115
+ STATE = "state"
116
+ ZIP = "zip"
117
+ STREET_ADDRESS = "street_address"
118
+ AGE = "age"
119
+ DOB = "dob"
120
+ PHONE = "phone"
121
+ EMAIL = "email" # the dedicated removal mailbox, never personal email
122
+ LISTING_URL = "listing_url"
123
+ ID_DOCUMENT = "id_document"
124
+
125
+
126
+ class ListingState(StrEnum):
127
+ """Per (profile, broker) state. See freemyid.state for transitions."""
128
+ UNKNOWN = "unknown" # never scanned
129
+ NOT_FOUND = "not_found"
130
+ FOUND = "found"
131
+ SUBMITTED = "submitted"
132
+ AWAITING_VERIFICATION = "awaiting_verification"
133
+ CONFIRMED = "confirmed" # broker acknowledged / verification clicked
134
+ REMOVED = "removed" # re-scan no longer finds listing
135
+ REAPPEARED = "reappeared"
136
+ NEEDS_HUMAN = "needs_human" # captcha / ID / unexpected page / manual method
137
+ ERROR = "error"
138
+
139
+
140
+ class RequestKind(StrEnum):
141
+ OPT_OUT = "opt_out"
142
+ RESUBMIT = "resubmit"
143
+ FOLLOW_UP = "follow_up"
144
+ LEGAL_DEMAND = "legal_demand"
145
+
146
+
147
+ class RequestStatus(StrEnum):
148
+ DRAFT = "draft"
149
+ QUEUED_FOR_APPROVAL = "queued_for_approval" # email waiting on user approval
150
+ SENT = "sent"
151
+ FAILED = "failed"
152
+ CANCELLED = "cancelled"
153
+
154
+
155
+ class Actor(StrEnum):
156
+ USER = "user"
157
+ SCHEDULER = "scheduler"
158
+ ENGINE = "engine"
159
+ WIZARD = "wizard"
160
+ SYSTEM = "system"
161
+
162
+
163
+ class ConsentBasis(StrEnum):
164
+ SELF = "self" # "I am this person"
165
+ GUARDIAN_OF_MINOR = "guardian_of_minor"
166
+ AUTHORIZED_AGENT = "authorized_agent" # signed written permission from the adult
167
+
168
+
169
+ class EmailMode(StrEnum):
170
+ APPROVE_EACH = "approve_each" # default: every outgoing email queued for user approval
171
+ AUTO = "auto" # opt-in; legal demands STILL require approval
172
+
173
+
174
+ class Tier(StrEnum):
175
+ """How Free My ID handles a broker for a profile (docs/PLAN_V1.md D1)."""
176
+ EMAIL = "email" # automatic privacy-request letter from the removal mailbox
177
+ ASSISTED = "assisted" # user does it in their own browser with copy buttons
178
+ AUTOMATIC = "automatic" # Playwright, data-driven Broker.search/form, verified brokers only
179
+
180
+
181
+ class TodoKind(StrEnum):
182
+ CAPTCHA = "captcha"
183
+ ID_UPLOAD = "id_upload"
184
+ UNEXPECTED_PAGE = "unexpected_page"
185
+ POSTAL_LETTER = "postal_letter"
186
+ PHONE_CALL = "phone_call"
187
+ ACCOUNT_REQUIRED = "account_required"
188
+ APPROVE_EMAIL = "approve_email"
189
+ COMPLAINT = "complaint" # escalate to AG / CPPA
190
+ GOVERNMENT_PORTAL = "government_portal" # e.g. California DROP
191
+ REVIEW_MATCH = "review_match" # possible match; user confirms it's them
192
+ ASSISTED_OPT_OUT = "assisted_opt_out" # "Do it with me": user fills the form in own browser
193
+ SEND_EMAIL_MANUALLY = "send_email_manually" # no mailbox: user sends the prepared letter
194
+ PERMISSION_NEEDED = "permission_needed" # broker wants ID/account/phone, flag is off
195
+ OTHER = "other"
196
+
197
+
198
+ class EvidenceKind(StrEnum):
199
+ SCREENSHOT = "screenshot"
200
+ HTML = "html"
201
+ EMAIL_SENT = "email_sent"
202
+ EMAIL_RECEIVED = "email_received"
203
+ LETTER = "letter"
204
+
205
+
206
+ # --------------------------------------------------------------------------- profile
207
+
208
+
209
+ class Consent(_Base):
210
+ basis: ConsentBasis
211
+ attested_by: str = Field(description="Name typed by the person giving consent")
212
+ attested_at: datetime = Field(default_factory=utcnow)
213
+ text_version: str = Field(description="Version id of the consent text shown")
214
+ authorization_doc: str | None = Field(
215
+ default=None, description="Evidence id of signed agent authorization (AUTHORIZED_AGENT)"
216
+ )
217
+
218
+ @model_validator(mode="after")
219
+ def _agent_needs_doc_flag(self) -> Consent:
220
+ # Authorization doc may be attached after setup; enforcement happens at run time.
221
+ return self
222
+
223
+
224
+ class PersonName(_Base):
225
+ first: str
226
+ middle: str | None = None
227
+ last: str
228
+ suffix: str | None = None
229
+
230
+ @property
231
+ def full(self) -> str:
232
+ parts = [self.first, self.middle, self.last, self.suffix]
233
+ return " ".join(p for p in parts if p)
234
+
235
+
236
+ class Address(_Base):
237
+ street: str | None = None
238
+ city: str
239
+ state: str = Field(min_length=2, max_length=2, description="USPS 2-letter code")
240
+ zip: str | None = None
241
+ current: bool = False
242
+ from_year: int | None = None
243
+ to_year: int | None = None
244
+
245
+ @field_validator("state")
246
+ @classmethod
247
+ def _upper(cls, v: str) -> str:
248
+ return v.upper()
249
+
250
+
251
+ class MailboxConfig(_Base):
252
+ """Dedicated removal mailbox. Password lives ONLY in the OS keychain."""
253
+ address: str
254
+ provider: Literal["gmail", "yahoo", "icloud", "outlook", "proton_bridge", "other"] = "other"
255
+ imap_host: str
256
+ imap_port: int = 993
257
+ imap_ssl: bool = True # implicit TLS (993)
258
+ imap_starttls: bool = False # e.g. Proton Bridge 127.0.0.1:1143
259
+ smtp_host: str
260
+ smtp_port: int = 465
261
+ smtp_starttls: bool = False
262
+ username: str
263
+ keyring_service: str = "freemyid-mailbox"
264
+ # keyring username is f"{profile_id}:{address}"
265
+ tls_cert_sha256: str | None = Field(
266
+ default=None,
267
+ description="Pinned SHA-256 (lowercase hex) of the server's DER leaf certificate. "
268
+ "Only honoured for loopback hosts (e.g. Proton Bridge on 127.0.0.1); "
269
+ "a pin on any other host is refused at connect time.")
270
+
271
+ @field_validator("tls_cert_sha256")
272
+ @classmethod
273
+ def _pin_hex(cls, v: str | None) -> str | None:
274
+ if v is None:
275
+ return None
276
+ h = re.sub(r"[\s:]", "", v).lower() # accept "AB:CD:.." as shown by cert viewers
277
+ if not re.fullmatch(r"[0-9a-f]{64}", h):
278
+ raise ValueError("tls_cert_sha256 must be 64 hex characters (SHA-256)")
279
+ return h
280
+
281
+ @model_validator(mode="after")
282
+ def _imap_encrypted(self) -> MailboxConfig:
283
+ # SEC-53: never send the mailbox password in cleartext, loopback included
284
+ # (any local process could listen there). SMTP is always TLS in mail.py.
285
+ if not (self.imap_ssl or self.imap_starttls):
286
+ raise ValueError("incoming mail must be encrypted: turn on SSL or STARTTLS")
287
+ return self
288
+
289
+
290
+ class Permissions(_Base):
291
+ """Off by default. If a broker demands one of these and the flag is False, NEEDS_HUMAN.
292
+
293
+ Paying money is never permitted and has no flag.
294
+ """
295
+ allow_id_upload: bool = False
296
+ allow_account_creation: bool = False
297
+ allow_phone_disclosure: bool = False
298
+
299
+
300
+ class MatchLevel(StrEnum):
301
+ CONFIRMED = "confirmed" # name match AND >=2 supporting signals -> auto-submit allowed
302
+ POSSIBLE = "possible" # name match AND exactly 1 signal -> NEEDS_HUMAN review
303
+ NO_MATCH = "no_match" # ignored
304
+
305
+
306
+ class ScheduleConfig(_Base):
307
+ weekly_recheck: bool = True
308
+ weekday: int = Field(default=6, ge=0, le=6, description="0=Mon .. 6=Sun")
309
+ hour: int = Field(default=10, ge=0, le=23)
310
+ respect_broker_intervals: bool = True
311
+
312
+
313
+ class Profile(_Base):
314
+ id: Id = Field(default_factory=new_id)
315
+ label: str = Field(description="Display name, e.g. 'Mom'")
316
+ consent: Consent
317
+ name: PersonName
318
+ nicknames: list[str] = []
319
+ previous_names: list[PersonName] = []
320
+ addresses: list[Address] = Field(min_length=1)
321
+ dob: date | None = None
322
+ age: int | None = Field(default=None, ge=0, le=130)
323
+ phones: list[str] = []
324
+ emails: list[str] = [] # personal emails: for matching only, never sent anywhere
325
+ relatives: list[str] = []
326
+ is_minor: bool = False
327
+ mailbox: MailboxConfig | None = None
328
+ email_mode: EmailMode = EmailMode.APPROVE_EACH
329
+ permissions: Permissions = Permissions()
330
+ schedule: ScheduleConfig = ScheduleConfig()
331
+ created_at: datetime = Field(default_factory=utcnow)
332
+ updated_at: datetime = Field(default_factory=utcnow)
333
+
334
+ @property
335
+ def state_of_residence(self) -> str:
336
+ cur = [a for a in self.addresses if a.current]
337
+ return (cur or self.addresses)[0].state
338
+
339
+ def estimated_age(self, today: date | None = None) -> int | None:
340
+ if self.dob:
341
+ t = today or date.today()
342
+ return t.year - self.dob.year - ((t.month, t.day) < (self.dob.month, self.dob.day))
343
+ return self.age
344
+
345
+ def all_names(self) -> list[PersonName]:
346
+ return [self.name, *self.previous_names]
347
+
348
+
349
+ # --------------------------------------------------------------------------- broker registry
350
+
351
+
352
+ class FormStep(_Base):
353
+ """Declarative web-form step, so most brokers need no Python.
354
+
355
+ `value` is a jinja2 template rendered with {profile, listing, mailbox, broker}.
356
+ """
357
+ action: Literal[
358
+ "goto", "fill", "select", "check", "click", "wait_for", "screenshot", "submit",
359
+ "expect_text", "human",
360
+ ]
361
+ selector: str | None = None
362
+ value: str | None = None
363
+ url: str | None = None
364
+ timeout_ms: int = 15000
365
+ note: str | None = None
366
+
367
+
368
+ class SearchSpec(_Base):
369
+ """How to find listings. Selectors are CSS; all optional so researcher can fill later."""
370
+ result_selector: str | None = None
371
+ name_selector: str | None = None
372
+ age_selector: str | None = None
373
+ location_selector: str | None = None
374
+ relatives_selector: str | None = None
375
+ link_selector: str | None = None
376
+ no_results_text: str | None = None
377
+
378
+
379
+ class PostalContact(_Base):
380
+ recipient: str
381
+ lines: list[str]
382
+
383
+
384
+ class Broker(_Base):
385
+ id: BrokerId
386
+ name: str
387
+ url: HttpUrl
388
+ category: BrokerCategory
389
+ domains: list[str] = Field(min_length=1, description="Hostnames the engine may contact")
390
+ search_url_pattern: str | None = Field(
391
+ default=None,
392
+ description="Python format string. Placeholders: {first} {last} {middle} {city} {state} "
393
+ "{state_name} {zip} {phone}; values are slugified/URL-encoded by the engine",
394
+ )
395
+ opt_out_url: HttpUrl | None = None
396
+ search_page_url: HttpUrl | None = Field(
397
+ default=None, description="Page with the people-search form, if not Broker.url "
398
+ "(listing finder)",
399
+ )
400
+ method: Method
401
+ alt_methods: list[Method] = []
402
+ opt_out_email: str | None = None
403
+ postal: PostalContact | None = None
404
+ phone: str | None = None
405
+ required_fields: list[ProfileField] = []
406
+ needs_email_verification: bool = False
407
+ confirmation_domains: list[str] = Field(
408
+ default=[], description="Only links on these domains are auto-clicked from IMAP"
409
+ )
410
+ has_captcha: bool = False
411
+ needs_id_upload: bool = False
412
+ typical_processing_days: int = Field(default=30, ge=0, le=365)
413
+ recheck_interval_days: int = Field(default=30, ge=1, le=365)
414
+ parent_company: str | None = None
415
+ feeds_from: list[str] = Field(default=[], description="Broker ids this site copies from")
416
+ covered_by: str | None = Field(
417
+ default=None, description="Broker id whose opt-out also removes this site (shared "
418
+ "suppression center); engine submits once to covered_by and only re-scans this one",
419
+ )
420
+ legal_basis_notes: str | None = None
421
+ ca_registered: bool | None = None # on California data broker registry
422
+ vt_registered: bool | None = None
423
+ steps: list[str] = Field(default=[], description="Human-readable step-by-step notes")
424
+ search: SearchSpec | None = None
425
+ form: list[FormStep] = []
426
+ success_text: list[str] = Field(
427
+ default=[], description="Visible text that means the opt-out was accepted (any match). "
428
+ "Used by 'Fill it in for me' when the form steps have no expect_text",
429
+ )
430
+ success_url_pattern: str | None = Field(
431
+ default=None, max_length=200,
432
+ description="Regex matched against the URL path+query after submit; a match = success",
433
+ )
434
+ safe_checkboxes: list[str] = Field(
435
+ default=[], description="CSS selectors of plain consent checkboxes 'Fill it in for me' "
436
+ "may tick (never CAPTCHA, never marketing opt-ins). Empty = tick nothing",
437
+ )
438
+ listing_url_pattern: str | None = Field(
439
+ default=None, max_length=200,
440
+ description="Regex matched against a listing page's URL path (listing finder)",
441
+ )
442
+ submitter: str | None = Field(
443
+ default=None, description="Optional custom submitter 'module:Class'"
444
+ )
445
+ source_urls: list[HttpUrl] = Field(default=[], description="Citations for opt-out info")
446
+ verification_status: VerificationStatus = VerificationStatus.UNVERIFIED
447
+ verification_notes: str | None = None
448
+ last_verified: date | None = None
449
+ priority: int = Field(default=50, ge=0, le=100, description="Higher runs first")
450
+ enabled: bool = True
451
+
452
+ @field_validator("domains", "confirmation_domains")
453
+ @classmethod
454
+ def _hosts(cls, v: list[str]) -> list[str]:
455
+ return [_check_host(h) for h in v]
456
+
457
+ @field_validator("success_url_pattern", "listing_url_pattern")
458
+ @classmethod
459
+ def _regex(cls, v: str | None) -> str | None:
460
+ # SEC-35: registry is remotely updatable; refuse patterns that can backtrack
461
+ # catastrophically (a quantified group that itself contains a quantifier).
462
+ if v is not None:
463
+ if _NESTED_QUANT_RE.search(v) or "(?" in v.replace("(?:", ""):
464
+ raise ValueError("pattern too complex (nested quantifier or lookaround)")
465
+ re.compile(v) # raises -> validation error
466
+ return v
467
+
468
+ @field_validator("safe_checkboxes")
469
+ @classmethod
470
+ def _safe_checkboxes(cls, v: list[str]) -> list[str]:
471
+ # SEC-31: only narrow, anchored selectors: input#id or input[name="..."]
472
+ if len(v) > 3:
473
+ raise ValueError("at most 3 safe_checkboxes")
474
+ for sel in v:
475
+ if not _SAFE_CHECKBOX_RE.fullmatch(sel):
476
+ raise ValueError("safe_checkboxes entries must be input#id or "
477
+ "input[name=\"...\"]")
478
+ if _ROBOTISH_RE.search(sel):
479
+ raise ValueError("safe_checkboxes must never target a robot/verify box")
480
+ return v
481
+
482
+ @field_validator("success_text")
483
+ @classmethod
484
+ def _success_text(cls, v: list[str]) -> list[str]:
485
+ # SEC-32: short phrases ("thank you") appear on form pages too
486
+ if any(len(s.strip()) < 12 for s in v):
487
+ raise ValueError("success_text phrases must be at least 12 characters")
488
+ return v
489
+
490
+ @field_validator("submitter")
491
+ @classmethod
492
+ def _submitter_ns(cls, v: str | None) -> str | None:
493
+ if v is not None and not _SUBMITTER_RE.match(v):
494
+ raise ValueError("submitter must be 'freemyid.submitters.<module>:<Class>'")
495
+ return v
496
+
497
+ @model_validator(mode="after")
498
+ def _hosts_inside_domains(self) -> Broker:
499
+ doms = self.domains
500
+ urls = [str(self.url), str(self.opt_out_url) if self.opt_out_url else None,
501
+ str(self.search_page_url) if self.search_page_url else None]
502
+ urls += [st.url for st in self.form if st.url]
503
+ for u in filter(None, urls):
504
+ if not _host_in(urlsplit(u).hostname, doms):
505
+ raise ValueError(f"{self.id}: URL host outside broker domains: {u}")
506
+ if self.search_url_pattern:
507
+ fields = {f for _, f, _, _ in string.Formatter().parse(self.search_url_pattern) if f}
508
+ if bad := fields - _SEARCH_PLACEHOLDERS:
509
+ raise ValueError(f"{self.id}: unknown search placeholders {sorted(bad)}")
510
+ probe = self.search_url_pattern.format(**{k: "x" for k in fields})
511
+ if urlsplit(probe).scheme != "https" or not _host_in(urlsplit(probe).hostname, doms):
512
+ raise ValueError(f"{self.id}: search_url_pattern must be https on broker domains")
513
+ if self.opt_out_email:
514
+ local, _, host = self.opt_out_email.rpartition("@")
515
+ if not local or not _host_in(host, doms):
516
+ raise ValueError(f"{self.id}: opt_out_email must be on a broker domain")
517
+ return self
518
+
519
+ @model_validator(mode="after")
520
+ def _method_requirements(self) -> Broker:
521
+ if self.verification_status == VerificationStatus.VERIFIED:
522
+ if not self.source_urls:
523
+ raise ValueError(f"{self.id}: verified brokers must cite source_urls")
524
+ if not self.last_verified:
525
+ raise ValueError(f"{self.id}: verified brokers need last_verified")
526
+ if self.method == Method.EMAIL and not self.opt_out_email:
527
+ raise ValueError(f"{self.id}: method=email requires opt_out_email")
528
+ if self.method == Method.POSTAL and not self.postal:
529
+ raise ValueError(f"{self.id}: method=postal requires postal")
530
+ if self.method == Method.PHONE and not self.phone:
531
+ raise ValueError(f"{self.id}: method=phone requires phone")
532
+ if self.method in (Method.WEB_FORM, Method.ACCOUNT_REQUIRED) and not self.opt_out_url:
533
+ raise ValueError(f"{self.id}: method={self.method} requires opt_out_url")
534
+ return self
535
+
536
+
537
+ class StateLaw(_Base):
538
+ state: str = Field(min_length=2, max_length=2)
539
+ statute_name: str | None = None # e.g. "California Consumer Privacy Act (CPRA)"
540
+ citation: str | None = None # e.g. "Cal. Civ. Code § 1798.105"
541
+ effective_date: date | None = None
542
+ rights: list[str] = [] # delete, opt_out_sale, opt_out_targeting, ...
543
+ response_days: int | None = None
544
+ extension_days: int | None = None
545
+ data_broker_law: str | None = None # e.g. CA Delete Act
546
+ government_portal_url: HttpUrl | None = None # e.g. DROP
547
+ ag_complaint_url: HttpUrl | None = None
548
+ other_complaint_urls: list[HttpUrl] = []
549
+ authorized_agent_allowed: bool | None = None
550
+ template: str = Field(default="generic", description="Template family to use")
551
+ notes: str | None = None
552
+ source_urls: list[HttpUrl] = []
553
+ last_verified: date | None = None
554
+
555
+
556
+ class Registry(_Base):
557
+ version: str
558
+ updated: date
559
+ brokers: list[Broker]
560
+
561
+ @model_validator(mode="after")
562
+ def _unique_ids(self) -> Registry:
563
+ ids = [b.id for b in self.brokers]
564
+ dupes = {i for i in ids if ids.count(i) > 1}
565
+ if dupes:
566
+ raise ValueError(f"duplicate broker ids: {sorted(dupes)}")
567
+ known = set(ids)
568
+ for b in self.brokers:
569
+ bad = [f for f in [*b.feeds_from, *([b.covered_by] if b.covered_by else [])]
570
+ if f not in known]
571
+ if bad:
572
+ raise ValueError(f"{b.id}: feeds_from references unknown ids {bad}")
573
+ return self
574
+
575
+ def get(self, broker_id: str) -> Broker:
576
+ for b in self.brokers:
577
+ if b.id == broker_id:
578
+ return b
579
+ raise KeyError(broker_id)
580
+
581
+
582
+ class StateLawRegistry(_Base):
583
+ version: str
584
+ updated: date
585
+ states: list[StateLaw]
586
+
587
+
588
+ # --------------------------------------------------------------------------- runtime records
589
+
590
+
591
+ class Listing(_Base):
592
+ id: Id = Field(default_factory=new_id)
593
+ profile_id: Id
594
+ broker_id: BrokerId
595
+ url: str
596
+ name_shown: str | None = None
597
+ age_shown: int | None = None
598
+ locations_shown: list[str] = []
599
+ relatives_shown: list[str] = []
600
+ match_score: float = Field(ge=0, le=100)
601
+ match_level: MatchLevel
602
+ match_reasons: list[str] = [] # signal names only: "city", "age", "relative", "phone"
603
+ shown_fields: list[ProfileField] = Field(
604
+ default=[], description="What the listing displays. Data minimization: submitters "
605
+ "may only send the broker fields in this list, plus the removal mailbox address.",
606
+ )
607
+ first_seen: datetime = Field(default_factory=utcnow)
608
+ last_seen: datetime = Field(default_factory=utcnow)
609
+ gone_since: datetime | None = None
610
+
611
+
612
+ class Request(_Base):
613
+ id: Id = Field(default_factory=new_id)
614
+ profile_id: Id
615
+ broker_id: BrokerId
616
+ kind: RequestKind
617
+ method: Method
618
+ listing_ids: list[Id] = []
619
+ status: RequestStatus = RequestStatus.DRAFT
620
+ created_at: datetime = Field(default_factory=utcnow)
621
+ sent_at: datetime | None = None
622
+ original_request_id: Id | None = None # for follow-ups / legal demands
623
+ reference: str | None = None # broker ticket / confirmation number
624
+ attempt: int = 1
625
+ dry_run: bool = False
626
+
627
+
628
+ class BrokerStatus(_Base):
629
+ """Current state for one (profile, broker). History lives in Event rows."""
630
+ profile_id: Id
631
+ broker_id: BrokerId
632
+ state: ListingState = ListingState.UNKNOWN
633
+ updated_at: datetime = Field(default_factory=utcnow)
634
+ last_scan_at: datetime | None = None
635
+ last_submitted_at: datetime | None = None
636
+ attempts: int = 0
637
+ escalation_level: int = 0 # 0 none, 1 resubmitted, 2 follow-up, 3 legal demand, 4 complaint
638
+ last_escalated_at: datetime | None = None
639
+ next_check_at: datetime | None = None
640
+ needs_human_reason: str | None = None
641
+ tier: Tier | None = None # set by engine.runner each run
642
+
643
+
644
+ class Evidence(_Base):
645
+ id: Id = Field(default_factory=new_id)
646
+ profile_id: Id
647
+ broker_id: BrokerId
648
+ kind: EvidenceKind
649
+ listing_id: Id | None = None
650
+ request_id: Id | None = None
651
+ relpath: str = Field(description="Path under FREEMYID_HOME/evidence, encrypted file")
652
+ sha256: str
653
+ created_at: datetime = Field(default_factory=utcnow)
654
+ caption: str | None = None
655
+
656
+ _relpath_ok = field_validator("relpath")(classmethod(lambda cls, v: _safe_relpath(v)))
657
+
658
+
659
+ class Event(_Base):
660
+ """Append-only audit row. `detail` MUST NOT contain PII (ids/enums/counts only)."""
661
+ id: Id = Field(default_factory=new_id)
662
+ ts: datetime = Field(default_factory=utcnow)
663
+ profile_id: Id | None = None
664
+ broker_id: BrokerId | None = None
665
+ actor: Actor
666
+ action: str
667
+ from_state: ListingState | None = None
668
+ to_state: ListingState | None = None
669
+ detail: dict[str, Any] = {}
670
+
671
+
672
+ class Todo(_Base):
673
+ id: Id = Field(default_factory=new_id)
674
+ profile_id: Id
675
+ broker_id: BrokerId | None = None
676
+ kind: TodoKind
677
+ title: str
678
+ body: str = ""
679
+ links: list[str] = []
680
+ request_id: Id | None = None
681
+ artifact_relpath: str | None = None # letter / call script
682
+ created_at: datetime = Field(default_factory=utcnow)
683
+ done_at: datetime | None = None
684
+ resume_token: str | None = None # engine uses to resume after human step
685
+ copy_fields: dict[str, str] = Field(
686
+ default={}, repr=False,
687
+ description="Label -> value shown with a Copy button on Assisted cards. "
688
+ "Only fields the broker needs (data minimization); never stored in logs.")
689
+
690
+ _relpath_ok = field_validator("artifact_relpath")(
691
+ classmethod(lambda cls, v: _safe_relpath(v)))
692
+
693
+
694
+ class SubmitOutcome(_Base):
695
+ """What a submitter returns to the engine."""
696
+ new_state: ListingState
697
+ request: Request | None = None
698
+ evidence: list[Evidence] = []
699
+ todos: list[Todo] = []
700
+ message: str = "" # non-PII summary for logs