Skip to main content

max / makenotwork

Require a claim link for free guest claims too claim_free_guest auto-attached a free item to an existing account on an email match — the same unverified-email trust gap just closed on the paid path. Always issue a claim token and email the claim link; never attach by email match. Consistent trust model across paid and free guest claims; license keys (if any) still mint at claim time via claim_purchase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:54 UTC
Commit: 3474d6a4d00cea32e07584b973d029f81cfed8dd
Parent: 5bedb26
1 file changed, +9 insertions, -9 deletions
@@ -10,7 +10,6 @@ use axum::{
10 10 Json,
11 11 };
12 12 use serde::{Deserialize, Serialize};
13 - use uuid::Uuid;
14 13
15 14 use crate::{
16 15 db::{self, Cents, ItemId},
@@ -356,24 +355,24 @@ pub(super) async fn claim_free_guest(
356 355 return Err(AppError::NotFound);
357 356 }
358 357
359 - // Check if email matches an existing user — auto-attach
360 - let existing_user_id = db::users::get_verified_user_id_by_email(&state.db, &email).await?;
361 -
362 - let claim_token = if existing_user_id.is_some() { None } else { Some(db::ClaimToken::new()) };
358 + // Never auto-attach on an email match — a typed address isn't proof the
359 + // claimant controls it. The buyer always claims via the emailed link,
360 + // consistent with the paid guest path (Run #21 / Max's call 2026-06-15).
361 + let claim_token = db::ClaimToken::new();
363 362 let download_token = db::DownloadToken::new();
364 363 let checkout_session_id = format!("free-guest-{}-{}", email, item_id);
365 364
366 365 // Create completed transaction
367 366 let result = db::transactions::create_free_guest_transaction(
368 367 &state.db,
369 - existing_user_id,
368 + None,
370 369 seller.id,
371 370 item_id,
372 371 &checkout_session_id,
373 372 &item.title,
374 373 &seller.username,
375 374 email.as_str(),
376 - claim_token,
375 + Some(claim_token),
377 376 download_token,
378 377 )
379 378 .await;
@@ -403,9 +402,10 @@ pub(super) async fn claim_free_guest(
403 402 // Send download email
404 403 let host_url = &state.config.host_url;
405 404 let download_url = format!("{}/download/{}", host_url, download_token);
406 - let claim_url = format!("{}/claim?token={}", host_url, claim_token.unwrap_or(db::ClaimToken::from_uuid(Uuid::nil())));
405 + let claim_url = format!("{}/claim?token={}", host_url, claim_token);
407 406
408 - if existing_user_id.is_none() {
407 + // Always send the claim link — there's no auto-attach to skip it for.
408 + {
409 409 let email_client = state.email.clone();
410 410 let email_addr = email.clone().into_inner();
411 411 let item_title = item.title.clone();