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,630 @@
1
+ """'Fill it in for me' (PLAN_V1 D9): Free My ID fills the opt-out form, the person submits it.
2
+
3
+ Flow (only ever started by the person, from the dashboard; scheduled runs never do this):
4
+ 1. consent gate, broker checks (web form with an opt-out page; covered_by -> parent);
5
+ 2. a VISIBLE browser window (system Edge on Windows) opens the opt-out page through
6
+ BrowserManager (NetPolicy allowlist, `bm.goto`, `check_escaped`);
7
+ 3. `filler.fill_page` fills the minimized fields; only registry safe_checkboxes are ticked, CAPTCHAs are not
8
+ touched, Submit is NOT pressed;
9
+ 4. a big plain-English banner asks the person to tick the robot box and press Submit;
10
+ 5. Free My ID watches for the registry's success signal (the `expect_text` / `wait_for` steps
11
+ after the form's submit step, same as web_form, plus `success_text` /
12
+ `success_url_pattern`). SEC-32: success needs BOTH a submit-like event after the fill
13
+ (a main-frame navigation, or an XHR/fetch POST to an allowed host) AND a signal that
14
+ is new compared with the baseline taken after filling (kept up to date until the
15
+ first submit event): a success phrase that appears more often than before, a success
16
+ selector that was not there, a URL that changed to match `success_url_pattern`, or the
17
+ filled form gone while a success phrase/selector is on the page. A POST that re-shows
18
+ the same form never counts. On success: screenshots saved as evidence (the form as it
19
+ was when the person pressed Submit, SEC-37, and the confirmation without our banner),
20
+ a SENT web-form Request, the broker moves to SUBMITTED (or AWAITING_VERIFICATION)
21
+ exactly like a successful web_form submission, and the Assisted to-do is closed;
22
+ 6. timeout / window closed / no success signal / any error: the Assisted to-do card
23
+ stays (or is created), so nothing is lost.
24
+
25
+ Practice mode (`real_submissions_enabled: false`) still fills: the person, not Free My ID,
26
+ presses Submit. The banner then says plainly that Submit sends a real request.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import logging
32
+ import re
33
+ import threading
34
+ from collections.abc import Awaitable, Callable
35
+ from dataclasses import dataclass, field
36
+ from typing import TYPE_CHECKING, Any, Literal
37
+
38
+ from .. import config as cfgmod
39
+ from ..models import (
40
+ Actor,
41
+ Broker,
42
+ Event,
43
+ EvidenceKind,
44
+ Listing,
45
+ ListingState,
46
+ MatchLevel,
47
+ Method,
48
+ Request,
49
+ RequestKind,
50
+ RequestStatus,
51
+ TodoKind,
52
+ utcnow,
53
+ )
54
+ from . import filler
55
+
56
+ if TYPE_CHECKING:
57
+ from playwright.async_api import Page
58
+
59
+ from ..models import Profile, Registry, StateLaw
60
+ from ..storage import Store
61
+ from .browser import BrowserManager
62
+
63
+ log = logging.getLogger(__name__)
64
+
65
+ BANNER_ID = "freemyid-fill-banner"
66
+ _POLL_S = 0.5
67
+
68
+ MSG_FILLED = ("Free My ID filled this in for you. Please check it, tick the box that says "
69
+ "you're not a robot (if there is one), then press Submit.")
70
+ MSG_MISSING = "A few boxes are still empty. Please fill those in yourself."
71
+ MSG_PRACTICE = ("Practice mode is on, but this is the real website: pressing Submit "
72
+ "really sends your removal request.")
73
+ MSG_HINT = "Free My ID will notice when you're done. To stop, just close this window."
74
+ MSG_DONE = "Done! Free My ID saved proof that you sent it. You can close this window now."
75
+
76
+ Status = Literal["submitted", "fallback", "refused"]
77
+
78
+
79
+ @dataclass
80
+ class FillResult:
81
+ """Outcome of one 'Fill it in for me' attempt. PII-free (keys, ids, labels)."""
82
+ status: Status
83
+ reason: str # success_text | success_selector | timeout |
84
+ # window_closed | unverified | nothing_filled | blocked |
85
+ # browser_unavailable | error:<Type> | consent | ...
86
+ broker_id: str
87
+ filled: list[str] = field(default_factory=list)
88
+ not_found: list[str] = field(default_factory=list)
89
+ withheld: list[str] = field(default_factory=list)
90
+ submit_seen: bool = False # a navigation / POST happened after filling
91
+ practice_mode: bool = False
92
+ todo_id: str | None = None # Assisted card to fall back to (fallback)
93
+ request_id: str | None = None # SENT request (submitted)
94
+ evidence_ids: list[str] = field(default_factory=list)
95
+
96
+ @property
97
+ def ok(self) -> bool:
98
+ return self.status == "submitted"
99
+
100
+
101
+ # --------------------------------------------------------------------------- helpers
102
+
103
+
104
+ def success_signals(broker: Broker, vars: dict | None = None) -> tuple[list[str], list[str]]:
105
+ """(texts, selectors) that mean "it worked": the `expect_text` values and `wait_for`
106
+ selectors after the last click/submit step of `Broker.form` (what web_form checks),
107
+ plus `Broker.success_text`."""
108
+ from ..submitters.web_form import render_value
109
+ steps = broker.form
110
+ last = max((i for i, s in enumerate(steps) if s.action in ("click", "submit")), default=-1)
111
+ texts: list[str] = []
112
+ sels: list[str] = []
113
+ for s in steps[last + 1:]:
114
+ if s.action == "expect_text" and s.value:
115
+ try:
116
+ t = render_value(s.value, vars or {}).strip()
117
+ except Exception: # noqa: BLE001, S112 - template needs a var we don't have
118
+ continue
119
+ if t:
120
+ texts.append(t.lower())
121
+ elif s.action == "wait_for" and s.selector and last >= 0:
122
+ sels.append(s.selector)
123
+ for t in broker.success_text:
124
+ t = (t or "").strip().lower()
125
+ if t and t not in texts:
126
+ texts.append(t)
127
+ return texts, sels
128
+
129
+
130
+ _URL_MATCH_MAX = 2048
131
+
132
+
133
+ def url_matches(pattern: str | None, url: str) -> bool:
134
+ """re.search of a registry regex against the URL's path+query (bounded length)."""
135
+ if not pattern or not url:
136
+ return False
137
+ from urllib.parse import urlsplit
138
+ try:
139
+ u = urlsplit(url)
140
+ except ValueError:
141
+ return False
142
+ target = (u.path or "/") + (f"?{u.query}" if u.query else "")
143
+ try:
144
+ return re.search(pattern, target[:_URL_MATCH_MAX]) is not None
145
+ except re.error:
146
+ return False
147
+
148
+
149
+ def _banner_js(lines: list[str], done: bool) -> str:
150
+ import json
151
+ bg, fg = ("#0b6b2e", "#ffffff") if done else ("#ffe600", "#000000")
152
+ return f"""
153
+ (() => {{
154
+ const id = {json.dumps(BANNER_ID)};
155
+ let b = document.getElementById(id);
156
+ if (b) b.remove();
157
+ b = document.createElement('div');
158
+ b.id = id;
159
+ b.setAttribute('role', 'alert');
160
+ b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;' +
161
+ 'background:{bg};color:{fg};font:bold 26px/1.35 "Segoe UI",Arial,sans-serif;' +
162
+ 'padding:18px 28px;border-bottom:6px solid {fg};box-shadow:0 4px 12px rgba(0,0,0,.4);' +
163
+ 'text-align:left;pointer-events:none;';
164
+ for (const [i, t] of {json.dumps(lines)}.entries()) {{
165
+ const p = document.createElement('div');
166
+ p.textContent = t;
167
+ if (i > 0) p.style.cssText = 'font-size:22px;font-weight:600;margin-top:8px;';
168
+ b.appendChild(p);
169
+ }}
170
+ (document.body || document.documentElement).appendChild(b);
171
+ if (document.body) document.body.style.marginTop = (b.offsetHeight + 8) + 'px';
172
+ return true;
173
+ }})()
174
+ """
175
+
176
+
177
+ _PAGE_TEXT_JS = f"""
178
+ () => {{
179
+ const b = document.getElementById({BANNER_ID!r});
180
+ const t = document.body ? document.body.innerText : '';
181
+ return (b ? t.replace(b.innerText, '') : t).toLowerCase();
182
+ }}
183
+ """
184
+
185
+
186
+ # Banner gone but a form is still there (reload after a validation error): show it again.
187
+ # A page without a form (e.g. the site's own thank-you page) gets no "press Submit" banner.
188
+ _NEEDS_BANNER_JS = f"""
189
+ () => !document.getElementById({BANNER_ID!r}) &&
190
+ !!document.querySelector('form input:not([type=hidden]), form textarea, form select')
191
+ """
192
+
193
+
194
+ async def _matched(page: Page, texts: list[str], sels: list[str],
195
+ url_pattern: str | None = None) -> str | None:
196
+ if url_matches(url_pattern, page.url):
197
+ return "success_url"
198
+ if texts:
199
+ body = await page.evaluate(_PAGE_TEXT_JS)
200
+ if any(t in body for t in texts):
201
+ return "success_text"
202
+ for s in sels:
203
+ if await page.query_selector(s) is not None:
204
+ return "success_selector"
205
+ return None
206
+
207
+
208
+ # One look at the page for SEC-32: how often each success phrase occurs (our banner's words
209
+ # subtracted), which success selectors exist, how many of the boxes we filled are still
210
+ # there and visible, and the URL. All inputs are passed as arguments, never interpolated.
211
+ _SNAPSHOT_JS = """
212
+ ([banner, texts, sels, controls]) => {
213
+ const b = document.getElementById(banner);
214
+ let t = document.body ? document.body.innerText : '';
215
+ if (b) t = t.replace(b.innerText, '');
216
+ t = t.toLowerCase();
217
+ const counts = texts.map((x) => {
218
+ let n = 0, i = 0;
219
+ while (x && (i = t.indexOf(x, i)) !== -1) { n++; i += x.length; }
220
+ return n;
221
+ });
222
+ const present = sels.map((s) => { try { return !!document.querySelector(s); }
223
+ catch (e) { return false; } });
224
+ const vis = (el) => {
225
+ const r = el.getBoundingClientRect(); const cs = getComputedStyle(el);
226
+ return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none';
227
+ };
228
+ let formLeft = 0;
229
+ for (const [name, id] of controls) {
230
+ let el = null;
231
+ try {
232
+ if (id) el = document.getElementById(id);
233
+ if (!el && name) el = document.querySelector('[name="' + CSS.escape(name) + '"]');
234
+ } catch (e) { el = null; }
235
+ if (el && vis(el)) formLeft++;
236
+ }
237
+ return {counts, present, formLeft, url: location.href};
238
+ }
239
+ """
240
+
241
+ _REMOVE_BANNER_JS = f"""
242
+ () => {{
243
+ const b = document.getElementById({BANNER_ID!r});
244
+ if (b) {{ b.remove(); if (document.body) document.body.style.marginTop = ''; }}
245
+ return true;
246
+ }}
247
+ """
248
+
249
+
250
+ @dataclass
251
+ class _Snap:
252
+ counts: list[int]
253
+ present: list[bool]
254
+ form_left: int
255
+ url: str
256
+
257
+
258
+ async def _snapshot(page: Page, texts: list[str], sels: list[str],
259
+ controls: list[tuple[str, str]]) -> _Snap:
260
+ d = await page.evaluate(_SNAPSHOT_JS, [BANNER_ID, texts, sels,
261
+ [list(c) for c in controls]])
262
+ counts = [int(x) for x in (d.get("counts") or [])][:len(texts)]
263
+ present = [bool(x) for x in (d.get("present") or [])][:len(sels)]
264
+ counts += [0] * (len(texts) - len(counts))
265
+ present += [False] * (len(sels) - len(present))
266
+ return _Snap(counts, present, int(d.get("formLeft") or 0), page.url)
267
+
268
+
269
+ # SEC-39: no single page call may outlive the fill_for_me deadline (an evaluate can hang
270
+ # forever while a navigation is paused in a route handler).
271
+ _CALL_TIMEOUT_S = 5.0
272
+
273
+
274
+ async def _eval_t(page: Page, js: str, arg: Any = None) -> Any:
275
+ return await asyncio.wait_for(page.evaluate(js, arg), _CALL_TIMEOUT_S)
276
+
277
+
278
+ async def _snap_t(page: Page, texts: list[str], sels: list[str],
279
+ controls: list[tuple[str, str]]) -> _Snap:
280
+ return await asyncio.wait_for(_snapshot(page, texts, sels, controls), _CALL_TIMEOUT_S)
281
+
282
+
283
+ def _merge_baseline(base: _Snap, now: _Snap) -> _Snap:
284
+ """Before the first submit event, anything that shows up (late-rendered FAQ text, ...)
285
+ becomes part of the baseline: it cannot be the result of submitting."""
286
+ return _Snap([max(a, b) for a, b in zip(base.counts, now.counts, strict=True)],
287
+ [a or b for a, b in zip(base.present, now.present, strict=True)],
288
+ max(base.form_left, now.form_left), base.url)
289
+
290
+
291
+ def _path_of(url: str) -> str:
292
+ from urllib.parse import urlsplit
293
+ try:
294
+ u = urlsplit(url)
295
+ except ValueError:
296
+ return url
297
+ return (u.path or "/") + (f"?{u.query}" if u.query else "")
298
+
299
+
300
+ def new_signal(base: _Snap, now: _Snap, url_pattern: str | None) -> str | None:
301
+ """A success signal that was NOT there at baseline (SEC-32), or None."""
302
+ if url_pattern and url_matches(url_pattern, now.url) and \
303
+ not url_matches(url_pattern, base.url) and _path_of(now.url) != _path_of(base.url):
304
+ return "success_url"
305
+ if any(n > b for n, b in zip(now.counts, base.counts, strict=True)):
306
+ return "success_text"
307
+ if any(n and not b for n, b in zip(now.present, base.present, strict=True)):
308
+ return "success_selector"
309
+ # The filled form is gone (e.g. replaced by a thank-you page that repeats a phrase the
310
+ # form page already had in its instructions) and a success phrase / selector is shown.
311
+ if base.form_left > 0 and now.form_left == 0:
312
+ if any(now.counts):
313
+ return "success_text"
314
+ if any(now.present):
315
+ return "success_selector"
316
+ return None
317
+
318
+
319
+ def _confirmed_listing(store: Store, profile_id: str, broker_id: str) -> tuple[
320
+ Listing | None, list[Listing]]:
321
+ lis = [li for li in store.list_listings(profile_id, broker_id) if li.gone_since is None]
322
+ conf = [li for li in lis if li.match_level == MatchLevel.CONFIRMED]
323
+ return (conf[0] if conf else None), lis
324
+
325
+
326
+ def _event(store: Store, profile_id: str, broker_id: str, action: str, actor: Actor = Actor.ENGINE,
327
+ frm: ListingState | None = None, to: ListingState | None = None, **detail: Any) -> None:
328
+ store.log(Event(profile_id=profile_id, broker_id=broker_id, actor=actor, action=action,
329
+ from_state=frm, to_state=to, detail=detail))
330
+
331
+
332
+ def _fallback_todo(store: Store, profile: Profile, broker: Broker, listings: list[Listing],
333
+ law: StateLaw | None, reason: str | None) -> str:
334
+ """Make sure the Assisted card exists (deduplicated) and return its id."""
335
+ from ..submitters import assisted
336
+ from .handoff import persist_todo
337
+ for t in store.list_todos(profile.id):
338
+ if t.broker_id == broker.id and t.kind == TodoKind.ASSISTED_OPT_OUT:
339
+ return t.id
340
+ todo = assisted.assisted_todo(profile, broker, listings, reason=reason)
341
+ return persist_todo(store, todo).id
342
+
343
+
344
+ def _record_success(store: Store, profile: Profile, broker: Broker, listing: Listing | None,
345
+ shots: list[tuple[bytes, str]], how: str) -> tuple[str, list[str]]:
346
+ """Same bookkeeping as a successful web_form submission (runner._apply_outcome)."""
347
+ from ..state import can_transition, mark_submitted, next_check_at, transition
348
+ from ..submitters import base
349
+ now = utcnow()
350
+ conf = [listing] if listing else []
351
+ req = Request(profile_id=profile.id, broker_id=broker.id, kind=RequestKind.OPT_OUT,
352
+ method=Method.WEB_FORM, listing_ids=[li.id for li in conf],
353
+ status=RequestStatus.SENT, created_at=now, sent_at=now)
354
+ store.save_request(req)
355
+ ev_ids: list[str] = []
356
+ for png, caption in shots:
357
+ ev = base.evidence(profile.id, broker.id, EvidenceKind.SCREENSHOT, "png",
358
+ listing_id=listing.id if listing else None, request_id=req.id,
359
+ caption=caption)
360
+ ev_ids.append(store.save_evidence(ev, png).id)
361
+ st = store.get_status(profile.id, broker.id)
362
+ frm = st.state
363
+ if st.state in (ListingState.UNKNOWN, ListingState.NOT_FOUND):
364
+ st = transition(st, ListingState.FOUND, now) # the person found it worth removing
365
+ if can_transition(st.state, ListingState.SUBMITTED):
366
+ st = mark_submitted(st, now)
367
+ if broker.needs_email_verification:
368
+ st = transition(st, ListingState.AWAITING_VERIFICATION, now)
369
+ st = st.model_copy(update={"next_check_at": next_check_at(broker, st.state, now)})
370
+ store.save_status(st)
371
+ _event(store, profile.id, broker.id, "submitted", Actor.USER, frm, st.state,
372
+ request_id=req.id, method=Method.WEB_FORM.value, via="fill_for_me", signal=how)
373
+ for t in store.list_todos(profile.id):
374
+ if t.broker_id == broker.id and t.kind in (TodoKind.ASSISTED_OPT_OUT,
375
+ TodoKind.CAPTCHA):
376
+ store.complete_todo(t.id)
377
+ return req.id, ev_ids
378
+
379
+
380
+ # --------------------------------------------------------------------------- entry point
381
+
382
+
383
+ async def fill_for_me(store: Store, profile_id: str, broker_id: str, *, timeout_s: int = 600,
384
+ registry: Registry | None = None, browser: BrowserManager | None = None,
385
+ headed: bool = True, cfg: cfgmod.Config | None = None,
386
+ state_laws: dict[str, StateLaw] | None = None,
387
+ on_ready: Callable[[Page], Awaitable[Any]] | None = None,
388
+ close_delay_s: float = 4.0) -> FillResult:
389
+ """Open the broker's opt-out page in a visible window, fill it, let the person submit.
390
+
391
+ `broker_id` may be a covered_by child; the parent's form is used. `browser` (an
392
+ unstarted or started BrowserManager) is for tests; by default one is created and closed
393
+ here. `on_ready(page)` runs (as a task) once the banner is up; tests use it to play the
394
+ person. Never raises for page/browser problems: those return status "fallback".
395
+ """
396
+ from ..consent import consent_ok
397
+ from . import tiers
398
+ cfg = cfg or cfgmod.load()
399
+ profile = store.get_profile(profile_id)
400
+ if profile is None:
401
+ return FillResult("refused", "no_profile", broker_id)
402
+ ok, _why = consent_ok(profile, store)
403
+ if not ok:
404
+ return FillResult("refused", "consent", broker_id)
405
+ if registry is None:
406
+ from ..registry import load_registry
407
+ registry = load_registry()
408
+ try:
409
+ broker = tiers.submit_target(registry.get(broker_id), registry)
410
+ except KeyError:
411
+ return FillResult("refused", "unknown_broker", broker_id)
412
+ if state_laws is None:
413
+ try:
414
+ from ..registry import load_state_laws
415
+ state_laws = load_state_laws()
416
+ except Exception: # noqa: BLE001
417
+ state_laws = {}
418
+ law = state_laws.get(profile.state_of_residence)
419
+ practice = not cfg.real_submissions_enabled
420
+ listing, listings = _confirmed_listing(store, profile.id, broker.id)
421
+ if broker.id != broker_id:
422
+ # covered_by child: the listing finder (D10) records the listing on the child site
423
+ c_listing, c_listings = _confirmed_listing(store, profile.id, broker_id)
424
+ listing = listing or c_listing
425
+ listings = listings + [li for li in c_listings if li not in listings]
426
+ res = FillResult("fallback", "", broker.id, practice_mode=practice)
427
+
428
+ def fallback(reason: str) -> FillResult:
429
+ res.status, res.reason = "fallback", reason
430
+ res.todo_id = _fallback_todo(store, profile, broker, listings, law,
431
+ "error" if reason.startswith("error") else None)
432
+ _event(store, profile.id, broker.id, "fill_for_me_fallback", reason=reason[:40],
433
+ submit_seen=res.submit_seen)
434
+ return res
435
+
436
+ if broker.method != Method.WEB_FORM or not broker.opt_out_url:
437
+ res.status, res.reason = "refused", "not_a_web_form"
438
+ return res
439
+ if broker.needs_id_upload and not profile.permissions.allow_id_upload:
440
+ return fallback("id_upload")
441
+
442
+ from ..netpolicy import NetPolicy
443
+ from .browser import BlockedByPolicy, BrowserManager
444
+ from .outbox import dev_fixture_base
445
+ own = browser is None
446
+ bm = browser or BrowserManager(NetPolicy(registry), headless=True, cfg=cfg,
447
+ fixture_base=dev_fixture_base())
448
+ bctx = None
449
+ try:
450
+ try:
451
+ await bm.start()
452
+ # SEC-36: the person may need the site's robot box; headed windows only.
453
+ bctx = await bm.new_context(broker.domains, headed=headed,
454
+ allow_captcha_frames=headed)
455
+ page = await bctx.new_page()
456
+ except Exception as e: # noqa: BLE001 - no browser: the card still works
457
+ log.warning("fill_for_me: browser unavailable (%s)", type(e).__name__)
458
+ return fallback("browser_unavailable")
459
+ try:
460
+ return await _drive(store, profile, broker, listing, bm, page, res, timeout_s,
461
+ practice, on_ready, close_delay_s, fallback)
462
+ except BlockedByPolicy:
463
+ return fallback("blocked")
464
+ except Exception as e: # noqa: BLE001 - window closed mid-call, odd page, ...
465
+ if page.is_closed():
466
+ return fallback("window_closed")
467
+ log.warning("fill_for_me failed: %s", type(e).__name__)
468
+ return fallback(f"error:{type(e).__name__}")
469
+ finally:
470
+ if bctx is not None:
471
+ try:
472
+ await bctx.close()
473
+ except Exception as e: # noqa: BLE001
474
+ log.debug("closing fill window failed: %s", type(e).__name__)
475
+ if own:
476
+ await bm.close()
477
+
478
+
479
+ async def _drive(store: Store, profile: Profile, broker: Broker, listing: Listing | None,
480
+ bm: BrowserManager, page: Page, res: FillResult, timeout_s: int,
481
+ practice: bool, on_ready: Callable[[Page], Awaitable[Any]] | None,
482
+ close_delay_s: float, fallback: Callable[[str], FillResult]) -> FillResult:
483
+ from ..submitters.base import form_vars
484
+ closed = {"v": False}
485
+ page.on("close", lambda _p: closed.__setitem__("v", True))
486
+
487
+ await bm.goto(page, str(broker.opt_out_url), check_challenge=False)
488
+ values = filler.plan_values(profile, broker, listing)
489
+ rep = await filler.fill_page(page, values)
490
+ if broker.safe_checkboxes:
491
+ rep.ticked = await filler.tick_safe_checkboxes(page, broker.safe_checkboxes)
492
+ res.filled, res.not_found, res.withheld = (sorted(set(rep.filled)), rep.not_found,
493
+ sorted(set(rep.withheld)))
494
+ _event(store, profile.id, broker.id, "fill_for_me_filled", **rep.summary())
495
+ if not rep.filled:
496
+ return fallback("nothing_filled")
497
+ # Kept only as a fallback: the evidence shot is taken when the person submits (SEC-37).
498
+ filled_png = await page.screenshot(full_page=True)
499
+
500
+ texts, sels = success_signals(broker, form_vars(profile, broker, listing))
501
+ url_re = broker.success_url_pattern
502
+ lines = [MSG_FILLED]
503
+ if rep.not_found or rep.withheld or rep.unknown:
504
+ lines.append(MSG_MISSING)
505
+ if practice:
506
+ lines.append(MSG_PRACTICE)
507
+ lines.append(MSG_HINT)
508
+ banner = _banner_js(lines, done=False)
509
+ await page.evaluate(banner)
510
+ controls = rep.controls
511
+ hosts: set[str] = getattr(page.context, "freemyid_hosts", set())
512
+ # "base" = the page as it was before the person submitted: taken now, merged with every
513
+ # poll until the first submit event, and with a last look taken at that event itself.
514
+ submit: dict[str, Any] = {"n": 0, "shot": None,
515
+ "base": await _snapshot(page, texts, sels, controls)}
516
+
517
+ async def _watch(route: Any) -> None:
518
+ """Submit-like events after the fill (SEC-32): a main-frame navigation, or an
519
+ XHR/fetch POST to an allowed host. The page is captured while the request waits,
520
+ i.e. exactly as the person sent it (SEC-37). Then the context route decides.
521
+
522
+ SEC-39: while a main-frame navigation is paused in this handler, Playwright never
523
+ answers a page.evaluate, so for navigations the baseline is the one from the last
524
+ poll (no snapshot here), and the request is always released (finally)."""
525
+ try:
526
+ req = route.request
527
+ try:
528
+ nav = req.is_navigation_request() and req.frame == page.main_frame
529
+ post = (req.resource_type in ("xhr", "fetch") and
530
+ req.method not in ("GET", "HEAD", "OPTIONS") and
531
+ bm.url_allowed(req.url, hosts))
532
+ except Exception: # noqa: BLE001 - no frame (worker): not a submit
533
+ nav = post = False
534
+ if nav or post:
535
+ first = submit["n"] == 0
536
+ submit["n"] += 1
537
+ if first and post and not nav: # the page before submitting
538
+ try:
539
+ submit["base"] = _merge_baseline(
540
+ submit["base"], await _snap_t(page, texts, sels, controls))
541
+ except Exception as e: # noqa: BLE001 - keep the last poll's baseline
542
+ log.debug("submit-time baseline failed: %s", type(e).__name__)
543
+ try:
544
+ submit["shot"] = await asyncio.wait_for(
545
+ page.screenshot(full_page=True), _CALL_TIMEOUT_S)
546
+ except Exception as e: # noqa: BLE001 - the fill-time shot is the fallback
547
+ log.debug("submit-time screenshot failed: %s", type(e).__name__)
548
+ finally:
549
+ try:
550
+ await route.fallback()
551
+ except Exception as e: # noqa: BLE001 - route already handled / page closed
552
+ log.debug("route fallback failed: %s", type(e).__name__)
553
+
554
+ await page.route("**/*", _watch)
555
+ task: asyncio.Task[Any] | None = (
556
+ asyncio.ensure_future(on_ready(page)) if on_ready else None)
557
+
558
+ loop = asyncio.get_running_loop()
559
+ deadline = loop.time() + max(1, timeout_s)
560
+ how: str | None = None
561
+ try:
562
+ while loop.time() < deadline:
563
+ if closed["v"] or page.is_closed():
564
+ return fallback("window_closed")
565
+ bm.check_escaped(page)
566
+ submitted_before = submit["n"] > 0
567
+ res.submit_seen = res.submit_seen or submitted_before
568
+ try:
569
+ now = await _snap_t(page, texts, sels, controls)
570
+ if not submitted_before:
571
+ if submit["n"] == 0: # nothing was submitted while we looked
572
+ submit["base"] = _merge_baseline(submit["base"], now)
573
+ else:
574
+ how = new_signal(submit["base"], now, url_re)
575
+ if how:
576
+ break
577
+ if await _eval_t(page, _NEEDS_BANNER_JS):
578
+ await _eval_t(page, banner) # page reloaded (e.g. a validation error)
579
+ except Exception: # noqa: BLE001 - mid-navigation; try again next tick
580
+ if closed["v"] or page.is_closed():
581
+ return fallback("window_closed")
582
+ await asyncio.sleep(_POLL_S)
583
+ if how is None:
584
+ if not (texts or sels or url_re) and res.submit_seen:
585
+ return fallback("unverified")
586
+ return fallback("timeout")
587
+ bm.check_escaped(page)
588
+ try:
589
+ await _eval_t(page, _REMOVE_BANNER_JS) # SEC-37: no banner in the proof
590
+ except Exception: # noqa: BLE001, S110 - mid-navigation: shoot what is there
591
+ pass
592
+ done_png = await page.screenshot(full_page=True)
593
+ sent_png = submit["shot"]
594
+ shots = [(sent_png, "form as sent") if sent_png else
595
+ (filled_png, "form filled by Free My ID"), (done_png, "confirmation page")]
596
+ res.request_id, res.evidence_ids = _record_success(store, profile, broker, listing,
597
+ shots, how)
598
+ res.status, res.reason = "submitted", how
599
+ try:
600
+ await _eval_t(page, _banner_js([MSG_DONE], done=True))
601
+ if close_delay_s > 0:
602
+ await asyncio.sleep(close_delay_s)
603
+ except Exception: # noqa: BLE001, S110 - person closed the window already
604
+ pass
605
+ return res
606
+ finally:
607
+ if task is not None and not task.done():
608
+ task.cancel()
609
+
610
+
611
+ def fill_for_me_sync(store: Store, profile_id: str, broker_id: str, **kw: Any) -> FillResult:
612
+ """Blocking wrapper for the wizard (safe to call from a thread or with a loop running)."""
613
+ try:
614
+ asyncio.get_running_loop()
615
+ except RuntimeError:
616
+ return asyncio.run(fill_for_me(store, profile_id, broker_id, **kw))
617
+ box: dict[str, Any] = {}
618
+
619
+ def _t() -> None:
620
+ try:
621
+ box["r"] = asyncio.run(fill_for_me(store, profile_id, broker_id, **kw))
622
+ except BaseException as e: # noqa: BLE001
623
+ box["e"] = e
624
+
625
+ th = threading.Thread(target=_t, name="freemyid-fill", daemon=True)
626
+ th.start()
627
+ th.join()
628
+ if "e" in box:
629
+ raise box["e"]
630
+ return box["r"]