0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
391 lines
15 KiB
JavaScript
391 lines
15 KiB
JavaScript
// 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),
|
|
};
|
|
}
|
|
|
|
// Crude IPv4-or-IPv6 string extraction. Trusts the X-Forwarded-For
|
|
// header's first hop because Recap sits behind StartOS's tunnel — the
|
|
// header is set by the operator's infrastructure, not by clients
|
|
// directly. If you ever expose the server without a trusted proxy,
|
|
// revisit this.
|
|
export function getClientIp(req) {
|
|
const xff = req.headers?.["x-forwarded-for"];
|
|
if (xff) {
|
|
const first = String(xff).split(",")[0].trim();
|
|
if (first) return first;
|
|
}
|
|
return (req.socket?.remoteAddress || "").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;
|
|
}
|