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
@@ -0,0 +1,237 @@
1
+ """Human handoff (CAPTCHA, ID upload, unexpected page, match review). Never solves anything.
2
+
3
+ * `QueuedHandoff` (scheduled / headless / non-tty): persist the Todo with a resume_token,
4
+ notify (PII-free), return False immediately. Never blocks.
5
+ * `InteractiveHandoff`: persist the Todo, open a HEADED browser on the same URL (cookies
6
+ carried over), notify, then wait until the user presses Enter, marks the todo done in the
7
+ dashboard, or the timeout passes. Falls back to queued behaviour when there is no tty or
8
+ no display.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import os
15
+ import sys
16
+ from typing import TYPE_CHECKING, Any, cast
17
+
18
+ from ..models import Actor, Event, Todo
19
+ from ..models import ListingState as S
20
+
21
+ if TYPE_CHECKING:
22
+ from playwright.async_api import Page
23
+
24
+ from ..interfaces import Notifier
25
+ from ..storage import Store
26
+ from .browser import BrowserManager
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ class LogNotifier:
32
+ """Fallback when freemyid.notify is not available. Body is PII-free by contract."""
33
+
34
+ def notify(self, title: str, body: str, url: str | None = None) -> None:
35
+ log.info("notify: %s: %s", title, body)
36
+
37
+
38
+ def get_notifier() -> Notifier:
39
+ try:
40
+ from ..notify import get_notifier as _gn # type: ignore[import-not-found]
41
+ return _gn()
42
+ except Exception: # noqa: BLE001 - module may not exist yet / no desktop session
43
+ return LogNotifier()
44
+
45
+
46
+ def resume_token(todo: Todo) -> str:
47
+ return f"{todo.profile_id}:{todo.broker_id or '-'}:{todo.kind.value}"
48
+
49
+
50
+ def _existing_open(store: Store, todo: Todo) -> Todo | None:
51
+ for t in store.list_todos(todo.profile_id):
52
+ if t.broker_id == todo.broker_id and t.kind == todo.kind and t.request_id == \
53
+ todo.request_id:
54
+ return t
55
+ return None
56
+
57
+
58
+ def persist_todo(store: Store, todo: Todo) -> Todo:
59
+ """Save a todo unless an identical open one exists (dedupe across scheduled runs)."""
60
+ if not todo.resume_token:
61
+ todo = todo.model_copy(update={"resume_token": resume_token(todo)})
62
+ cur = _existing_open(store, todo)
63
+ if cur:
64
+ return cur
65
+ store.save_todo(todo)
66
+ return todo
67
+
68
+
69
+ GENERIC_HELP = "Free My ID needs your help with 1 site. Open Free My ID to see it."
70
+
71
+
72
+ class QueuedHandoff:
73
+ def __init__(self, store: Store, notifier: Notifier, label: str = "") -> None:
74
+ self.store, self.notifier, self.label = store, notifier, label
75
+ self.count = 0
76
+
77
+ async def request(self, todo: Todo, page: Page | None, timeout_s: int = 900) -> bool:
78
+ persist_todo(self.store, todo)
79
+ self.count += 1
80
+ # SEC-28: lock-screen text stays generic (no label, kind or site)
81
+ self.notifier.notify("Free My ID needs you", GENERIC_HELP)
82
+ return False
83
+
84
+
85
+ class InteractiveHandoff(QueuedHandoff):
86
+ def __init__(self, store: Store, notifier: Notifier, browser: BrowserManager | None,
87
+ label: str = "") -> None:
88
+ super().__init__(store, notifier, label)
89
+ self.browser = browser
90
+
91
+ @staticmethod
92
+ def can_block() -> bool:
93
+ if not sys.stdin or not sys.stdin.isatty():
94
+ return False
95
+ if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or
96
+ os.environ.get("WAYLAND_DISPLAY")):
97
+ return False
98
+ return True
99
+
100
+ async def request(self, todo: Todo, page: Page | None, timeout_s: int = 900) -> bool:
101
+ if not self.can_block():
102
+ return await super().request(todo, page, timeout_s)
103
+ todo = persist_todo(self.store, todo)
104
+ self.count += 1
105
+ headed_ctx = None
106
+ if page is not None and self.browser is not None:
107
+ try:
108
+ state = await page.context.storage_state()
109
+ hosts = sorted(getattr(page.context, "freemyid_hosts", set()))
110
+ headed_ctx = await self.browser.new_context(hosts, storage_state=state,
111
+ headed=True)
112
+ hp = await headed_ctx.new_page()
113
+ await self.browser.goto(hp, page.url, check_challenge=False)
114
+ except Exception as e: # noqa: BLE001
115
+ log.warning("could not open visible browser: %s", type(e).__name__)
116
+ self.notifier.notify("Free My ID needs you", GENERIC_HELP)
117
+ print(f"\n[Free My ID] {todo.title}\n {todo.body}\n"
118
+ " Finish it in the browser window, then press Enter here "
119
+ f"(or wait {timeout_s // 60} min to skip).", flush=True)
120
+ done = await self._wait(todo, timeout_s)
121
+ if headed_ctx is not None:
122
+ try:
123
+ if page is not None and done:
124
+ # carry the solved session back to the automated page's context
125
+ await page.context.add_cookies(cast(Any, await headed_ctx.cookies()))
126
+ await headed_ctx.close()
127
+ except Exception as e: # noqa: BLE001
128
+ log.debug("closing visible browser failed: %s", type(e).__name__)
129
+ if done:
130
+ self.store.complete_todo(todo.id)
131
+ return done
132
+
133
+ async def _wait(self, todo: Todo, timeout_s: int) -> bool:
134
+ loop = asyncio.get_running_loop()
135
+ enter = loop.run_in_executor(None, sys.stdin.readline)
136
+ deadline = loop.time() + timeout_s
137
+ while loop.time() < deadline:
138
+ if enter.done():
139
+ return True
140
+ if all(t.id != todo.id for t in self.store.list_todos(todo.profile_id)):
141
+ return True # resolved from the dashboard
142
+ await asyncio.sleep(1.0)
143
+ return False
144
+
145
+
146
+ def make_handoff(store: Store, notifier: Notifier, interactive: bool,
147
+ browser: BrowserManager | None, label: str = "") -> QueuedHandoff:
148
+ if interactive:
149
+ return InteractiveHandoff(store, notifier, browser, label)
150
+ return QueuedHandoff(store, notifier, label)
151
+
152
+
153
+ # --------------------------------------------------------------------------- resolution
154
+ # Helpers for the dashboard / CLI (wizard-packager) once a human has acted.
155
+
156
+
157
+ def confirm_review(store: Store, listing_id: str, profile_id: str, is_me: bool) -> None:
158
+ """User answered a REVIEW_MATCH todo. A 'yes' promotes the listing to CONFIRMED (the
159
+ matcher keeps user confirmations on re-scan); 'no' leaves it ignored."""
160
+ from ..models import MatchLevel
161
+ for li in store.list_listings(profile_id):
162
+ if li.id == listing_id:
163
+ if is_me:
164
+ li = li.model_copy(update={
165
+ "match_level": MatchLevel.CONFIRMED,
166
+ "match_reasons": sorted({*li.match_reasons, "user_confirmed"})})
167
+ else:
168
+ li = li.model_copy(update={"match_level": MatchLevel.NO_MATCH,
169
+ "match_reasons": ["user_rejected"]})
170
+ store.save_listing(li)
171
+ st = store.get_status(profile_id, li.broker_id)
172
+ store.save_status(st.model_copy(update={"next_check_at": None})) # re-run soon
173
+ store.log(Event(profile_id=profile_id, broker_id=li.broker_id, actor=Actor.USER,
174
+ action="review_match", detail={"is_me": is_me}))
175
+ return
176
+
177
+
178
+ def mark_manual_submitted(store: Store, profile_id: str, broker_id: str,
179
+ request_id: str | None = None) -> None:
180
+ """User mailed the letter / made the call / finished the form by hand."""
181
+ from ..models import RequestStatus, utcnow
182
+ from ..state import transition
183
+ now = utcnow()
184
+ st = store.get_status(profile_id, broker_id)
185
+ new = transition(st, S.SUBMITTED, now)
186
+ store.save_status(new)
187
+ if request_id and (r := store.get_request(request_id)):
188
+ store.save_request(r.model_copy(update={"status": RequestStatus.SENT, "sent_at": now}))
189
+ store.log(Event(profile_id=profile_id, broker_id=broker_id, actor=Actor.USER,
190
+ action="manual_submitted", from_state=st.state, to_state=new.state))
191
+
192
+
193
+ _SUBMIT_KINDS = frozenset({"assisted_opt_out", "send_email_manually", "postal_letter",
194
+ "phone_call", "account_required"})
195
+
196
+
197
+ def mark_resolved(store: Store, todo: Todo) -> None:
198
+ """Dashboard "I did it" hook (wizard/server.py looks this up by name).
199
+
200
+ For hand-done opt-outs (Assisted card, manual email, letter, call) the broker becomes
201
+ SUBMITTED so the re-check and escalation clocks start. Follow-up/demand letters sent by
202
+ hand do not restart the clock. Other kinds just get closed by the caller.
203
+ """
204
+ if todo.broker_id and (todo.resume_token or "").startswith("id_requested:"):
205
+ _resume_after_id_request(store, todo)
206
+ return
207
+ if not todo.broker_id or todo.kind.value not in _SUBMIT_KINDS:
208
+ return
209
+ tok = todo.resume_token or ""
210
+ if tok.count(":") >= 2: # manual_email:<broker>:<follow_up|legal_demand>
211
+ store.log(Event(profile_id=todo.profile_id, broker_id=todo.broker_id,
212
+ actor=Actor.USER, action="manual_letter_sent",
213
+ detail={"todo_kind": todo.kind.value}))
214
+ return
215
+ from ..state import can_transition
216
+ st = store.get_status(todo.profile_id, todo.broker_id)
217
+ if st.state == S.SUBMITTED or not can_transition(st.state, S.SUBMITTED):
218
+ return
219
+ if st.state in (S.UNKNOWN, S.NOT_FOUND):
220
+ return # nothing was found to remove; just close the to-do
221
+ mark_manual_submitted(store, todo.profile_id, todo.broker_id, todo.request_id)
222
+
223
+
224
+ def _resume_after_id_request(store: Store, todo: Todo) -> None:
225
+ """The person dealt with a site's request for ID themselves: the site goes back to
226
+ SUBMITTED, so the re-check and escalation clocks restart from today."""
227
+ from ..models import utcnow
228
+ from ..state import transition
229
+ assert todo.broker_id is not None
230
+ st = store.get_status(todo.profile_id, todo.broker_id)
231
+ if st.state != S.NEEDS_HUMAN or st.last_submitted_at is None:
232
+ return
233
+ new = transition(st, S.SUBMITTED, utcnow())
234
+ store.save_status(new.model_copy(update={"needs_human_reason": None}))
235
+ store.log(Event(profile_id=todo.profile_id, broker_id=todo.broker_id, actor=Actor.USER,
236
+ action="id_request_resolved", from_state=st.state, to_state=S.SUBMITTED,
237
+ detail={"todo_kind": todo.kind.value}))
@@ -0,0 +1,125 @@
1
+ """Letter / email / call-script rendering from jinja2 templates in freemyid/templates/.
2
+
3
+ `render(template, **vars) -> str` is the CLI-facing entry point (interfaces.py).
4
+ `letter_vars(...)` builds the variable set with DATA MINIMIZATION: only fields the broker's
5
+ listings already show (Listing.shown_fields) plus the removal mailbox are included.
6
+
7
+ Every email template starts with a `Subject: ...` line; `split_subject` separates it.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from datetime import date, datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from jinja2 import FileSystemLoader, StrictUndefined
16
+ from jinja2.sandbox import SandboxedEnvironment
17
+
18
+ from ..models import Broker, Listing, Profile, ProfileField, StateLaw
19
+ from .matcher import STATE_NAMES
20
+
21
+ TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
22
+ TEMPLATES = ("generic_removal", "ccpa", "state_privacy", "follow_up", "legal_demand",
23
+ "postal_letter", "phone_script")
24
+ _ALIASES = {"generic": "generic_removal", "cpra": "ccpa", "california": "ccpa",
25
+ "state": "state_privacy"}
26
+
27
+ _env = SandboxedEnvironment(loader=FileSystemLoader(str(TEMPLATES_DIR)),
28
+ undefined=StrictUndefined, autoescape=False,
29
+ trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
30
+
31
+
32
+ def _resolve(template: str) -> str:
33
+ name = template.removesuffix(".j2").removesuffix(".txt")
34
+ name = _ALIASES.get(name, name)
35
+ if name not in TEMPLATES:
36
+ raise ValueError(f"unknown template {template!r}")
37
+ return f"{name}.txt.j2"
38
+
39
+
40
+ def render(template: str, **vars: Any) -> str:
41
+ """Render a named template. Missing variables raise (StrictUndefined)."""
42
+ defaults: dict[str, Any] = {
43
+ "listing_urls": [], "city": None, "state": None, "age": None, "phone": None,
44
+ "statute_name": None, "citation": None, "response_days": None,
45
+ "original_request_date": None, "response_deadline": None, "reference": None,
46
+ "broker_postal": None, "complaint_urls": [], "today": date.today().isoformat(),
47
+ "is_agent": False, "agent_name": None, "street_address": None, "zip": None,
48
+ }
49
+ defaults.update(vars)
50
+ return _env.get_template(_resolve(template)).render(**defaults)
51
+
52
+
53
+ def split_subject(text: str) -> tuple[str, str]:
54
+ first, _, rest = text.partition("\n")
55
+ if first.lower().startswith("subject:"):
56
+ return first.split(":", 1)[1].strip(), rest.lstrip("\n")
57
+ return "Personal data deletion request", text
58
+
59
+
60
+ def choose_template(state_law: StateLaw | None) -> str:
61
+ """Initial-request template family for a law that is ALREADY known to apply and be in
62
+ effect (see engine.outbox.effective_law): ccpa for CA, state_privacy for another state
63
+ with a statute, generic_removal otherwise."""
64
+ if state_law is None:
65
+ return "generic_removal"
66
+ if state_law.state.upper() == "CA":
67
+ return "ccpa"
68
+ return "state_privacy" if state_law.statute_name else "generic_removal"
69
+
70
+
71
+ def letter_vars(profile: Profile, broker: Broker, listings: list[Listing],
72
+ state_law: StateLaw | None = None, *,
73
+ original_request_date: datetime | None = None,
74
+ reference: str | None = None, today: date | None = None) -> dict[str, Any]:
75
+ """Variables for templates, limited to what the listings already show.
76
+
77
+ If there are no listings (blind request to a non-searchable broker) only the name and
78
+ the removal mailbox are included.
79
+ """
80
+ today = today or date.today()
81
+ shown: set[ProfileField] = {f for li in listings for f in li.shown_fields}
82
+ cur = next((a for a in profile.addresses if a.current), profile.addresses[0])
83
+ v: dict[str, Any] = {
84
+ # SEC-29: middle name / suffix only if the listing itself shows the middle name
85
+ "name": profile.name.full if ProfileField.MIDDLE_NAME in shown
86
+ else f"{profile.name.first} {profile.name.last}",
87
+ "broker_name": broker.name,
88
+ "listing_urls": [li.url for li in listings],
89
+ "mailbox": profile.mailbox.address if profile.mailbox else None,
90
+ "today": today.isoformat(),
91
+ "is_agent": profile.consent.basis.value != "self",
92
+ "agent_name": profile.consent.attested_by
93
+ if profile.consent.basis.value != "self" else None,
94
+ }
95
+ if ProfileField.CITY in shown:
96
+ v["city"] = cur.city
97
+ if ProfileField.STATE in shown:
98
+ v["state"] = cur.state
99
+ if ProfileField.AGE in shown:
100
+ v["age"] = profile.estimated_age(today)
101
+ if ProfileField.STREET_ADDRESS in shown and cur.street:
102
+ v["street_address"] = cur.street
103
+ if ProfileField.ZIP in shown and cur.zip:
104
+ v["zip"] = cur.zip
105
+ if ProfileField.PHONE in shown and profile.permissions.allow_phone_disclosure and \
106
+ profile.phones:
107
+ v["phone"] = profile.phones[0]
108
+ if state_law:
109
+ v["statute_name"] = state_law.statute_name
110
+ v["citation"] = state_law.citation
111
+ v["response_days"] = state_law.response_days
112
+ v["state_name"] = STATE_NAMES.get(state_law.state, state_law.state)
113
+ v["complaint_urls"] = [str(u) for u in [state_law.ag_complaint_url,
114
+ *state_law.other_complaint_urls] if u]
115
+ if original_request_date:
116
+ v["original_request_date"] = original_request_date.date().isoformat()
117
+ days = (state_law.response_days if state_law and state_law.response_days
118
+ else broker.typical_processing_days)
119
+ v["response_deadline"] = (original_request_date.date() +
120
+ timedelta(days=days)).isoformat()
121
+ if reference:
122
+ v["reference"] = reference
123
+ if broker.postal:
124
+ v["broker_postal"] = "\n".join([broker.postal.recipient, *broker.postal.lines])
125
+ return v