// Cookie-gated "taste before sign-up" trial for unauthenticated // visitors on a multi-tenant Recap. First time a visitor POSTs to // /api/process without a session cookie, we issue a recap_anon_trial // cookie (32-byte random), insert an anon_trials row with N credits, // and let them summarize without signing up. After credits_used >= // credits_total, the UI nudges them to create an account. // // Trial summaries forward the OPERATOR's install_id + license to the // relay — they're paid for out of the operator's credit pool, gated // solely by the anon_trials.credits_total field. tenant_credits is // irrelevant for trials (no user row exists yet). // // Multi-mode only. Single-mode never imports this module. import { randomBytes } from "crypto"; import { getDb } from "./db.js"; import { getConfigSnapshot } from "./config.js"; export const TRIAL_COOKIE = "recap_anon_trial"; const TRIAL_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days // Returns the trial config from the StartOS config snapshot. Cached // per-call by getConfigSnapshot (already polled by config.js), so // reading per-request is cheap. async function getTrialConfig() { const snap = await getConfigSnapshot(); // Prefer the new `trials_per_ip_lifetime` field; fall back to the // legacy `trials_per_ip_per_day` so a config that hasn't been // re-saved under the new name keeps working with its existing value. // (Semantics is now lifetime in BOTH cases — the rename is mostly // cosmetic for the operator-facing knob.) const ipCap = snap.trials_per_ip_lifetime ?? snap.trials_per_ip_per_day ?? 5; return { creditsPerVisitor: Math.max( 0, parseInt(snap.trial_credits_per_visitor ?? 1, 10) || 0, ), perIpLifetime: Math.max(1, parseInt(ipCap, 10) || 5), }; } // Resolve the real client IP. We rely on Express's `trust proxy` setting // (configured in index.js to the number of trusted proxies in front of the // app) so req.ip is the address the trusted proxy observed — NOT a value the // client can spoof by sending their own X-Forwarded-For. This previously took // the first XFF entry verbatim, which a client could forge to mint unlimited // trials. Falls back to the raw socket address if req.ip isn't populated. export function getClientIp(req) { const ip = req.ip || req.socket?.remoteAddress || ""; return ip.replace(/^::ffff:/, ""); } // Expand an IPv6 string to its full 8-group :-separated form with // each group lowercased + zero-padded to 4 hex chars. Returns null // if the input doesn't look like a valid IPv6 string. Used by // ipCapKey() below — we need a stable canonical form before we can // extract the /64 prefix for cap counting. function expandIpv6(addr) { if (!addr || typeof addr !== "string") return null; // Strip any IPv4-mapped suffix (e.g. ::ffff:1.2.3.4) — those are // really IPv4 addresses tunneled through an IPv6 envelope; the // caller has already stripped ::ffff: in getClientIp, but // defensively handle the bare form. if (/\d+\.\d+\.\d+\.\d+$/.test(addr)) return null; if (!addr.includes(":")) return null; const dblIdx = addr.indexOf("::"); let groups; if (dblIdx === -1) { // No ::, must be 8 groups. groups = addr.split(":"); if (groups.length !== 8) return null; } else { // :: shorthand — split into left and right halves, fill with 0s. if (addr.indexOf("::", dblIdx + 2) !== -1) return null; // two :: → invalid const left = addr.slice(0, dblIdx); const right = addr.slice(dblIdx + 2); const leftGroups = left ? left.split(":") : []; const rightGroups = right ? right.split(":") : []; const missing = 8 - leftGroups.length - rightGroups.length; if (missing < 0) return null; groups = [...leftGroups, ...Array(missing).fill("0"), ...rightGroups]; } // Validate + normalize each group. const out = []; for (const g of groups) { if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null; out.push(g.toLowerCase().padStart(4, "0")); } return out.join(":"); } // Cap-counting key for an IP. IPv4 returns the address unchanged // (whole-address caps work fine — only one device can claim a // public-routable IPv4). IPv6 returns the /64 prefix because RFC // 4941 privacy extensions rotate the lower 64 bits of the address // per-device, per-session, often hourly — counting by full IPv6 // address means a single laptop can mint a fresh trial cookie // every time its OS picks a new temporary address. /64 is the // smallest network unit an ISP delegates to a subscriber, so it's // the correct boundary for "this household / this network" caps. // Returns null when the input isn't usable; callers treat null as // "no cap" (same fallback behavior as before). // // Trade-off: a /64 might be shared by multiple unrelated subscribers // in some carrier configurations, so legitimate visitors could // (rarely) get capped together. Operator can tune // trials_per_ip_lifetime higher if they're seeing real complaints. export function ipCapKey(ip) { if (!ip) return null; // IPv4: whole address. if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip; // IPv6: /64 prefix. Expand to full form, take first 4 groups, // append `:` so the SQL LIKE match doesn't accidentally match // unrelated prefixes that happen to share a textual leading // substring (e.g. "2600:17" prefix-matching "2600:171…"). const expanded = expandIpv6(ip); if (!expanded) return null; const groups = expanded.split(":"); if (groups.length < 4) return null; return groups.slice(0, 4).join(":") + ":"; } // Truncate UA so a pathological 8KB header doesn't bloat the DB. 256 // chars covers every legit browser UA string with room to spare. function clipUA(ua) { if (!ua) return ""; return String(ua).slice(0, 256); } // Count ALL trial cookies ever issued from this IP. Switched from // rolling-24h to lifetime in 0.2.84 — a user who clears cookies can no // longer just wait a day and replay the trial. The trade-off: a // shared-NAT household whose router IP got 5 different family // members' trials over a year will eventually be capped. The // operator can tune `trials_per_ip_lifetime` higher if that's a // real concern, or grant credits manually from the admin panel. export function ipTrialsLifetime(ip) { const key = ipCapKey(ip); if (!key) return 0; // IPv4 → exact match. IPv6 → prefix LIKE match (key ends with ":", // so the SQL LIKE 'key%' walks every full IPv6 in that /64 prefix). // Both are index-friendly when ip_address is indexed. if (key.includes(":")) { const row = getDb() .prepare("SELECT COUNT(*) AS n FROM anon_trials WHERE ip_address LIKE ?") .get(key + "%"); return row?.n || 0; } const row = getDb() .prepare("SELECT COUNT(*) AS n FROM anon_trials WHERE ip_address = ?") .get(key); return row?.n || 0; } // Insert a new trial row and return it. Caller is responsible for // setting the Set-Cookie header on the response. function createTrial({ ip, ua, creditsTotal }) { const cookieId = randomBytes(32).toString("base64url"); const now = Date.now(); getDb() .prepare( `INSERT INTO anon_trials (cookie_id, ip_address, user_agent, created_at, credits_total, credits_used) VALUES (?, ?, ?, ?, ?, 0)`, ) .run(cookieId, ip || null, clipUA(ua), now, creditsTotal); return { cookie_id: cookieId, ip_address: ip || null, user_agent: clipUA(ua), created_at: now, credits_total: creditsTotal, credits_used: 0, last_used_at: null, converted_to_user_id: null, }; } // lookupTrial(cookieId) — returns the row or null. Doesn't check // credit balance; that's the caller's job. export function lookupTrial(cookieId) { if (!cookieId) return null; return ( getDb() .prepare("SELECT * FROM anon_trials WHERE cookie_id = ?") .get(cookieId) || null ); } // hasTrialBudget(trial) — true iff the trial row exists and has // unused credits. Centralized so the policy is one place. export function hasTrialBudget(trial) { if (!trial) return false; return trial.credits_used < trial.credits_total; } // issueIfEligible({ req, res, forceMint }) — atomic "do we issue a // trial cookie to this request?" decision. Called by the auth // middleware when no session cookie is present AND the request is // hitting a path that counts as "actually using the product" (e.g. // POST /api/process). // // Returns the trial row on success, or null if: // - trial_credits_per_visitor is 0 (trials disabled) AND // forceMint is false // - the IP has already exhausted its lifetime mint quota AND // forceMint is false // - a DB error occurred (logged, fail-closed to "no trial") // // forceMint: caller asserts this isn't a free-trial-abuse scenario // (e.g., the visitor is paying for credits — they need a tracking // cookie regardless of trial policy). When set: // - IP lifetime cap is bypassed // - Trials-disabled config is bypassed, but the minted cookie // gets credits_total = 0 (no free bonus on a trials-off install) // Normal /api/process traffic should NEVER set this; only paid-flow // callers (/api/credits/buy) where "no cookie → can't credit the // settle" is a worse failure than enforcing the free-trial cap. // // On success, also writes the Set-Cookie header so the browser // carries the trial id on subsequent requests. export async function issueIfEligible({ req, res, forceMint = false }) { let cfg; try { cfg = await getTrialConfig(); } catch (err) { console.warn("[anon-trial] config read failed:", err); return null; } // Decide the credits_total this trial row starts with. Paying // buyers on a trials-off install still get a cookie — credits_total // is just 0 — so /api/credits/buy has somewhere to land the purchase. const creditsTotal = forceMint ? Math.max(0, cfg.creditsPerVisitor) : cfg.creditsPerVisitor; if (!forceMint && creditsTotal <= 0) return null; // trials disabled const ip = getClientIp(req); if (!forceMint && ip && ipTrialsLifetime(ip) >= cfg.perIpLifetime) { // Over the lifetime IP cap. Don't issue; visitor sees the same // "sign up" nudge a returning trial-exhausted user sees. The // operator can grep magic_link_tokens + anon_trials by ip_address // if a pattern emerges, or manually grant credits to specific // tenants from the admin panel. return null; } let trial; try { trial = createTrial({ ip, ua: req.headers?.["user-agent"] || "", creditsTotal, }); } catch (err) { console.warn("[anon-trial] createTrial failed:", err); return null; } // Diagnostic: log the resolved IP + the cap-counting key so an // operator can grep mint events to verify the IP detector is // working (a flood of mints with ip=null or ip=127.0.0.1 means // the reverse-proxy isn't passing X-Forwarded-For). Cap key // shows the bucket this mint counted under — for IPv6 this is // the /64 prefix the mint will share with future addresses on // the same network. console.log( `[anon-trial] minted cookie for ip=${ip || "(unknown)"} cap_key=${ipCapKey(ip) || "(none)"} credits=${creditsTotal}`, ); // Set-Cookie. HttpOnly so browser-side JS can't read or replay it; // SameSite=Lax is enough since we never need cross-site trial // posts. Secure is on in production (StartOS terminates HTTPS at // the tunnel) but harmless on localhost dev. const maxAgeSeconds = Math.floor(TRIAL_COOKIE_MAX_AGE_MS / 1000); res.setHeader?.( "Set-Cookie", [ `${TRIAL_COOKIE}=${trial.cookie_id}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax", "Secure", ].join("; "), ); return trial; } // debitOne(cookieId) — atomic +1 to credits_used. Returns the new // row. Caller (the /api/process handler in multi-mode) calls this // AFTER the relay accepts the request, so a failed relay call // doesn't burn a trial credit. export function debitOne(cookieId) { const now = Date.now(); getDb() .prepare( "UPDATE anon_trials SET credits_used = credits_used + 1, last_used_at = ? WHERE cookie_id = ?", ) .run(now, cookieId); return lookupTrial(cookieId); } // linkToUser(cookieId, userId) — called by /auth/verify when a trial // holder completes signup. Records the conversion for analytics AND // transfers any unused credits on the trial to the user's // tenant_credits balance. This is the user-facing promise: "your free // trial credits + any credits you bought during the trial transfer // to your account when you sign up." // // "Unused" = credits_total - credits_used. The default trial allowance // (e.g. 1 free) plus any credits the visitor purchased anonymously // minus whatever they've already spent. // // Returns the number of credits transferred (0 if none). // // Async because we opportunistically sweep any settled-but-unapplied // pending purchases for this anon cookie FIRST, so a la carte credits // the visitor bought right before signup (and where the BTCPay // redirect killed the frontend poller) get rolled into credits_total // before we compute the transfer. Without this sweep, a buy-then- // immediately-sign-up flow drops the just-purchased credits on the // floor. export async function linkToUser(cookieId, userId) { if (!cookieId || !userId) return 0; try { const { sweepUnappliedPurchases } = await import("./credits-purchase.js"); await sweepUnappliedPurchases({ buyerType: "anon", buyerId: cookieId }); } catch (err) { console.warn( `[anon-trial] pre-link sweep failed for ${cookieId}: ${err?.message || err}`, ); } const db = getDb(); const trial = db .prepare( "SELECT credits_total, credits_used FROM anon_trials WHERE cookie_id = ?", ) .get(cookieId); const remaining = trial ? Math.max( 0, (trial.credits_total || 0) - (trial.credits_used || 0), ) : 0; // Carry-over credits go into the PURCHASED bucket — they're a mix // of "leftover free trial allowance" + "credits the anon bought a // la carte". Treating all of them as purchased (permanent) is the // safe interpretation; the user paid for some of these and the // others were earned by clicking through a signup. We don't want // the next replenishment cycle wiping them. const tx = db.transaction(() => { db.prepare( "UPDATE anon_trials SET converted_to_user_id = ? WHERE cookie_id = ?", ).run(userId, cookieId); if (remaining > 0) { const existing = db .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?") .get(userId); if (existing) { db.prepare( `UPDATE tenant_credits SET purchased_balance = purchased_balance + ?, lifetime_granted = lifetime_granted + ? WHERE user_id = ?`, ).run(remaining, remaining, userId); } else { db.prepare( `INSERT INTO tenant_credits (user_id, purchased_balance, replenish_balance, last_replenish_at, lifetime_granted, lifetime_consumed) VALUES (?, ?, 0, ?, ?, 0)`, ).run(userId, remaining, Date.now(), remaining); } } }); tx(); if (remaining > 0) { console.log( `[anon-trial] transferred ${remaining} unused credits from ${cookieId} → user ${userId}`, ); } return remaining; }