@@ -270,8 +270,20 @@ def _claim():
270270
271271
272272@router .get ("/verify/{key_id}" )
273- async def verify (key_id : str ):
273+ async def verify (key_id : str , request : Request ):
274274 """Public status probe for a key fingerprint (no key material needed)."""
275+ # Shares the /register + /team-invite burst budget. One indexed SELECT is far cheaper
276+ # than the Ed25519 verify that budget was sized for, and key_id is a SHA-256
277+ # fingerprint so there is nothing to enumerate — but "cheap" is not "free", and an
278+ # unmetered public endpoint on the same SQLite file as the relay is not worth keeping
279+ # as a deliberate exception. Sharing rather than adding a bucket is safe here because
280+ # no client polls this route (only scripts/smoke_cloud.py), so there is no legitimate
281+ # high-frequency caller to starve. The same budget covers both /start-trial/verify
282+ # handlers, which are the genuinely expensive unauthenticated routes here.
283+ if not _register_rate_ok (_register_rate_key (request )):
284+ return JSONResponse (
285+ {"error" : "too many verification probes — try again shortly" },
286+ status_code = 429 , headers = {"Retry-After" : "60" })
275287 conn = reg .connect ()
276288 try :
277289 row = conn .execute (
@@ -822,6 +834,44 @@ def _trial_verify_error_html(message: str) -> str:
822834</body></html>"""
823835
824836
837+ def _trial_confirm_html (token : str , plan : str , email : str ) -> str :
838+ """The interstitial a human sees before the trial is actually granted.
839+
840+ Exists so that redemption needs a deliberate POST rather than the bare GET an email
841+ link-prescanner performs on the recipient's behalf — see :func:`confirm_team_trial`.
842+ The form posts back to this same URL with the token in the query string, so there is
843+ no request body to parse."""
844+ import html as _html
845+ label = _html .escape (plan .title ())
846+ # A QUERY-ONLY relative reference, deliberately: it resolves against the current
847+ # document's own path, so the form posts back to wherever this page was actually
848+ # served from. A root-absolute "/license/v1/start-trial/verify?..." would silently
849+ # break every sub-path deployment — validate_cloud_base_url PRESERVES the path
850+ # component (it only rejects query/fragment), so ENGRAPHIS_RELAY_PUBLIC_URL=
851+ # https://example.com/relay is valid config; the emailed link would render fine and
852+ # then POST to a 404, stranding the customer with a token that expires in 30 minutes
853+ # and no diagnostic. Same breakage behind a root_path reverse proxy.
854+ # The token goes into an attribute, so escape it with quote=True (the default).
855+ action = "?token=%s" % _html .escape (token )
856+ return f"""<!doctype html><html><head><meta charset="utf-8">
857+ <meta name="viewport" content="width=device-width,initial-scale=1">
858+ <meta name="robots" content="noindex,nofollow">
859+ <title>Confirm your Engraphis { label } trial</title></head>
860+ <body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px">
861+ <h2>Confirm your { label } trial</h2>
862+ <p>You're one click away. This will activate a trial for
863+ <strong>{ _html .escape (email )} </strong>.</p>
864+ <form method="post" action="{ action } ">
865+ <button type="submit" style="font:inherit;font-weight:600;padding:12px 20px;border:0;
866+ border-radius:8px;background:#5941c2;color:#fff;cursor:pointer">
867+ Activate my { label } trial</button>
868+ </form>
869+ <p style="color:#666;font-size:13px;margin-top:24px">This link can only be used once.
870+ If you didn't request a trial, you can ignore this page — nothing happens until you
871+ click the button.</p>
872+ </body></html>"""
873+
874+
825875def _reserve_trial (mid : str , email : str , plan : str ) -> Optional [str ]:
826876 """Reserve the newest pending magic link without blocking the event loop."""
827877 conn = reg .connect ()
@@ -878,7 +928,9 @@ async def start_team_trial(request: Request):
878928
879929 Does NOT issue a key synchronously (see the 2026-07-14 module comment above): it
880930 emails a one-time magic link to *email* and mints the real signed key only when
881- that link is opened (:func:`verify_team_trial`, the ``GET`` companion below).
931+ that link is CONFIRMED. Opening it (``GET``) only renders :func:`confirm_team_trial`'s
932+ page; the key is minted by :func:`verify_team_trial`, the ``POST`` that page's button
933+ sends — so a mail link-prescanner cannot burn the grant on the recipient's behalf.
882934 ``plan`` selects the tier ("pro" or "team", default "team"). 429 if this source IP
883935 has requested too many trials recently (``ENGRAPHIS_TRIAL_RATE_LIMIT_PER_HOUR``,
884936 default 5/hour); 400 for a missing machine_id, an unknown plan, or a missing/
@@ -948,16 +1000,106 @@ async def start_team_trial(request: Request):
9481000 "expires_in" : _TRIAL_TOKEN_TTL_SECONDS }
9491001
9501002
1003+ #: Applied to EVERY /start-trial/verify response, not just the success page: the request
1004+ #: URL itself carries the one-time token, so even an error page must stay out of shared
1005+ #: caches and out of the Referer of anything the reader clicks from there.
1006+ _TRIAL_PAGE_HEADERS = {
1007+ "Cache-Control" : "no-store, no-cache, must-revalidate, private" ,
1008+ "Pragma" : "no-cache" ,
1009+ "Referrer-Policy" : "no-referrer" ,
1010+ }
1011+
1012+
1013+ def _trial_verify_rate_limited (request : Request ) -> Optional [HTMLResponse ]:
1014+ """Shared burst gate for both /start-trial/verify handlers, or None when allowed.
1015+
1016+ Both are unauthenticated and both hit SQLite before they can know whether the token
1017+ is even real: the GET runs connect + two executescripts + a PRAGMA + a SELECT, and
1018+ the POST additionally takes BEGIN IMMEDIATE — a RESERVED write lock on the same
1019+ relay.db that carries seat claims and sync bundles — BEFORE it discovers the token is
1020+ garbage. Flooding either one with junk tokens is therefore a bigger lever than the
1021+ single indexed SELECT behind /verify/{key_id}. Both are sync defs, so each request
1022+ also pins a threadpool worker. Answers HTML, not JSON: these routes are opened by a
1023+ human in a browser."""
1024+ if _register_rate_ok (_register_rate_key (request )):
1025+ return None
1026+ return HTMLResponse (
1027+ _trial_verify_error_html ("Too many attempts — please wait a minute and retry." ),
1028+ status_code = 429 , headers = dict (_TRIAL_PAGE_HEADERS , ** {"Retry-After" : "60" }))
1029+
1030+
9511031@router .get ("/start-trial/verify" )
952- def verify_team_trial (token : str = "" ):
1032+ def confirm_team_trial (request : Request , token : str = "" ):
1033+ """Render the confirmation page for a magic-link token — WITHOUT redeeming it.
1034+
1035+ Redemption lives in the POST companion below, and this split is load-bearing rather
1036+ than cosmetic. Corporate mail gateways and antivirus link-prescanners (Outlook Safe
1037+ Links, Proofpoint URL Defense, and friends) routinely GET every URL they find in an
1038+ email body before the recipient ever sees it. When a bare GET redeemed the token, a
1039+ prescanner silently burned the one-time grant, and the human who then clicked the
1040+ link got "this link is invalid or has already been used" on a completely legitimate
1041+ first attempt — and it hit hardest at exactly the corporate mail estates most likely
1042+ to be buying Team.
1043+
1044+ So: GET is safe and idempotent (it only reads), and the actual grant happens on a
1045+ POST that a human has to click a button to send. Prescanners do not submit forms.
1046+ The token still rides in the query string, which is what the form posts back to, so
1047+ no request body parsing — and therefore no multipart dependency — is involved."""
1048+ limited = _trial_verify_rate_limited (request )
1049+ if limited is not None :
1050+ return limited
1051+ token = (token or "" ).strip ()
1052+ if not token :
1053+ return HTMLResponse (_trial_verify_error_html ("Missing token." ),
1054+ status_code = 400 , headers = _TRIAL_PAGE_HEADERS )
1055+
1056+ conn = reg .connect ()
1057+ try :
1058+ conn .executescript (_TRIAL_SCHEMA )
1059+ _ensure_trial_plan_column (conn )
1060+ conn .executescript (_TRIAL_PENDING_SCHEMA )
1061+ row = conn .execute (
1062+ "SELECT machine_id, email, plan, expires_at FROM trial_pending "
1063+ "WHERE token_hash=?" , (_hash_token (token ),)).fetchone ()
1064+ finally :
1065+ conn .close ()
1066+
1067+ # Mirror the POST's diagnostics so a user learns the real problem before clicking,
1068+ # not after. Read-only: a lapsed row is left for the retention sweep in
1069+ # _reserve_trial, never deleted here (deleting on GET would hand a prescanner a way
1070+ # to destroy the row it cannot redeem).
1071+ if row is None :
1072+ return HTMLResponse (
1073+ _trial_verify_error_html ("This link is invalid or has already been used." ),
1074+ status_code = 400 , headers = _TRIAL_PAGE_HEADERS )
1075+ if row ["expires_at" ] < time .time ():
1076+ return HTMLResponse (
1077+ _trial_verify_error_html (
1078+ "This link has expired — request a new trial from the dashboard." ),
1079+ status_code = 400 , headers = _TRIAL_PAGE_HEADERS )
1080+
1081+ return HTMLResponse (_trial_confirm_html (token , row ["plan" ], row ["email" ]),
1082+ headers = _TRIAL_PAGE_HEADERS )
1083+
1084+
1085+ @router .post ("/start-trial/verify" )
1086+ def verify_team_trial (request : Request , token : str = "" ):
9531087 """Redeem a magic-link token from :func:`start_team_trial` — mints and displays the
9541088 real signed trial key. Answers a small HTML page, not JSON: this is meant to be
955- opened directly from the confirmation email by a human, who needs to read and copy
956- a key, not parse a response body. One-time: the token is deleted on first use
957- (success OR a stale/losing race), so replaying a link never mints twice."""
1089+ reached by a human clicking the confirm button on the GET page above, who needs to
1090+ read and copy a key, not parse a response body. One-time: the token is deleted on
1091+ first use (success OR a stale/losing race), so replaying it never mints twice.
1092+
1093+ Takes the token from the QUERY STRING, not a form body: the GET page posts back to
1094+ its own URL, so there is nothing to parse and no python-multipart dependency (which
1095+ has broken the [server] extra before)."""
1096+ limited = _trial_verify_rate_limited (request )
1097+ if limited is not None :
1098+ return limited
9581099 token = (token or "" ).strip ()
9591100 if not token :
960- return HTMLResponse (_trial_verify_error_html ("Missing token." ), status_code = 400 )
1101+ return HTMLResponse (_trial_verify_error_html ("Missing token." ),
1102+ status_code = 400 , headers = _TRIAL_PAGE_HEADERS )
9611103
9621104 conn = reg .connect ()
9631105 try :
@@ -978,14 +1120,14 @@ def verify_team_trial(token: str = ""):
9781120 return HTMLResponse (
9791121 _trial_verify_error_html (
9801122 "This link is invalid or has already been used." ),
981- status_code = 400 )
1123+ status_code = 400 , headers = _TRIAL_PAGE_HEADERS )
9821124 if row ["expires_at" ] < now :
9831125 conn .execute ("DELETE FROM trial_pending WHERE token_hash=?" , (token_hash ,))
9841126 conn .execute ("COMMIT" )
9851127 return HTMLResponse (
9861128 _trial_verify_error_html (
9871129 "This link has expired — request a new trial from the dashboard." ),
988- status_code = 400 )
1130+ status_code = 400 , headers = _TRIAL_PAGE_HEADERS )
9891131 mid , email , plan = row ["machine_id" ], row ["email" ], row ["plan" ]
9901132 existing = conn .execute (
9911133 "SELECT 1 FROM trial_grants WHERE machine_id=?" , (mid ,)).fetchone ()
@@ -996,7 +1138,7 @@ def verify_team_trial(token: str = ""):
9961138 return HTMLResponse (
9971139 _trial_verify_error_html (
9981140 "The free trial has already been used on this device." ),
999- status_code = 409 )
1141+ status_code = 409 , headers = _TRIAL_PAGE_HEADERS )
10001142 from engraphis .inspector .webhooks import issue_key
10011143 from engraphis .licensing import TRIAL_DAYS
10021144 seats = TEAM_TRIAL_SEATS if plan == "team" else 1
@@ -1025,9 +1167,7 @@ def verify_team_trial(token: str = ""):
10251167 pass
10261168 # This body contains the full signed license key and the URL still carries the
10271169 # one-time token, so keep both out of shared caches and out of Referer headers on
1028- # any link the reader clicks from here.
1029- return HTMLResponse (_trial_verify_success_html (key , plan , TRIAL_DAYS ), headers = {
1030- "Cache-Control" : "no-store, no-cache, must-revalidate, private" ,
1031- "Pragma" : "no-cache" ,
1032- "Referrer-Policy" : "no-referrer" ,
1033- })
1170+ # any link the reader clicks from here. Uses the shared constant rather than an
1171+ # inline copy so the success page and the error pages can never drift apart.
1172+ return HTMLResponse (_trial_verify_success_html (key , plan , TRIAL_DAYS ),
1173+ headers = _TRIAL_PAGE_HEADERS )
0 commit comments