Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling

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
This commit is contained in:
Keysat
2026-06-13 14:25:05 -05:00
parent db580abad7
commit 0ae59f3550
176 changed files with 23823 additions and 803 deletions
+390
View File
@@ -0,0 +1,390 @@
// 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;
}