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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- 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
|