diff --git a/landing/.vercelignore b/landing/.vercelignore new file mode 100644 index 0000000..fc377c6 --- /dev/null +++ b/landing/.vercelignore @@ -0,0 +1,2 @@ +# Tests run locally; they are not site content. +tests diff --git a/landing/README.md b/landing/README.md index bf9da9d..f6361b7 100644 --- a/landing/README.md +++ b/landing/README.md @@ -16,17 +16,52 @@ Attach `dictx.splitlabs.io` to this Vercel project. ## Routes - `/` serves `landing/index.html` -- `/buy` redirects to Polar checkout -- `/api/pro/verify` validates a Polar license key (`lk_...`) for in-app Pro activation +- `/buy` runs `api/buy.js`: the Stripe Payment Link once Stripe is fully configured, otherwise the Polar checkout +- `/buy/success` shows the license key. Stripe purchases fetch it from `/api/pro/license`; earlier Polar purchases read it from the URL +- `/api/pro/license?session_id=cs_live_...` issues the `dxp-` license key for a verified Stripe purchase +- `/api/pro/verify` validates `dxp-` keys against Stripe and `lk_...` / `polar_cl_...` keys against Polar - `/api/pro/early-access/claim` grants free Pro for the first 100 unique installs +- `/js/script.cookieless.js` and `/api/events` proxy DataFast first-party; `middleware.ts` reports AI crawler requests + +## Stripe Managed Payments setup + +Link is the seller of record, as for the other SplitLabs products on the same Stripe account. + +1. Create the product **Dictx Pro** with a tax code Stripe labels "Eligible for Managed Payments" (downloadable software), and a one-time price of $29 USD. +2. Create a Payment Link for that price with Managed Payments on and quantity fixed at 1. +3. In the Payment Link, set **After payment** to redirect to `https://dictx.splitlabs.io/buy/success?session_id={CHECKOUT_SESSION_ID}`. +4. Create a restricted live key (`rk_live_...`) with read access to Checkout Sessions, Payment Intents, and Charges only. +5. Set the Stripe environment variables below in Vercel production. +6. `/buy` switches from Polar to the Payment Link on the next request once all four are valid. Nothing needs redeploying. + +A purchase earns a key only when the session is live, complete and paid (or fully discounted), holds exactly one Dictx Pro price at quantity 1, and its charge is neither refunded nor disputed. The app re-verifies keys, so a refund or dispute turns Pro off on its next check. A Stripe outage returns an error, which the app treats as "keep current state". + +If a buyer loses the key, find their Checkout Session id in Stripe and send them `https://dictx.splitlabs.io/buy/success?session_id=`. It reissues the same key. ## Environment Variables (Vercel) +Stripe (all required before `/buy` switches): + +- `STRIPE_SECRET_KEY`: restricted live read key (`rk_live_...`), Sensitive +- `DICTX_STRIPE_PRICE_ID`: the Dictx Pro price id (`price_...`) +- `DICTX_STRIPE_PAYMENT_LINK`: the canonical live Payment Link (`https://buy.stripe.com/...`, never `/test_`) +- `DICTX_LICENSE_SECRET`: 32+ character signing secret for license keys, Sensitive. **Rotating it invalidates every issued key.** +- `STRIPE_COMMERCE_MODE`: optional, `test` in non-production only; production always runs live + +DataFast: + +- `DATAFAST_WEBSITE_ID`: the public website id; enables crawler tracking in `middleware.ts` + +Polar (verifies keys from earlier purchases; keep until those customers are migrated): + - `POLAR_ACCESS_TOKEN`: Polar API token - `POLAR_ORGANIZATION_ID`: Polar organization id (`org_...`) used by license-key validation - `POLAR_DICTX_BENEFIT_IDS`: optional comma-separated benefit IDs allowed for Dictx Pro activation - `POLAR_DICTX_PRODUCT_IDS`: optional legacy fallback for checkout-key migration (`polar_cl_...`) - `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`) + +Rate limits and early access: + - `PRO_VERIFY_RATE_LIMIT_WINDOW_MS`: optional API rate-limit window - `PRO_VERIFY_RATE_LIMIT_MAX`: optional API rate-limit max requests per client per window - `UPSTASH_REDIS_REST_URL`: Upstash REST URL for early-access claim counter @@ -35,10 +70,8 @@ Attach `dictx.splitlabs.io` to this Vercel project. - `PRO_EARLY_ACCESS_RATE_LIMIT_WINDOW_MS`: optional rate-limit window for claim API - `PRO_EARLY_ACCESS_RATE_LIMIT_MAX`: optional rate-limit max for claim API -## Polar Checkout Success URL - -Set the product checkout success URL to: - -- `https://dictx.splitlabs.io/buy/success?checkout_id={CHECKOUT_ID}` +## Tests -For true Polar licensing, direct users to copy their `lk_...` key from the Polar customer portal/receipt and activate in-app. +```bash +node --test landing/tests/ +``` diff --git a/landing/api/_lib/http.js b/landing/api/_lib/http.js new file mode 100644 index 0000000..ff5f3a1 --- /dev/null +++ b/landing/api/_lib/http.js @@ -0,0 +1,49 @@ +/** Shared request helpers for the Stripe billing endpoints. */ +const getHeader = (req, name) => { + if (!req || !req.headers) return ""; + if (typeof req.headers.get === "function") return req.headers.get(name) || ""; + return req.headers[name.toLowerCase()] || req.headers[name] || ""; +}; + +const getClientId = (req) => { + const forwarded = getHeader(req, "x-forwarded-for"); + if (Array.isArray(forwarded)) return forwarded[0] || "unknown"; + if (forwarded.length > 0) return forwarded.split(",")[0].trim() || "unknown"; + return req?.socket?.remoteAddress || "unknown"; +}; + +const createRateLimiter = (windowMs, max) => { + const buckets = new Map(); + return (clientId) => { + const now = Date.now(); + if (buckets.size > 5000) { + for (const [key, bucket] of buckets.entries()) { + if (now > bucket.resetAt) buckets.delete(key); + } + } + const existing = buckets.get(clientId); + if (!existing || now > existing.resetAt) { + buckets.set(clientId, { count: 1, resetAt: now + windowMs }); + return false; + } + existing.count += 1; + return existing.count > max; + }; +}; + +const sendJson = (res, statusCode, payload) => { + res.setHeader("cache-control", "no-store"); + res.status(statusCode).json(payload); + return null; +}; + +const getQueryParam = (req, name) => { + if (req?.query && typeof req.query[name] === "string") return req.query[name]; + try { + return new URL(req.url, "http://localhost").searchParams.get(name) || ""; + } catch (_error) { + return ""; + } +}; + +module.exports = { createRateLimiter, getClientId, getQueryParam, sendJson }; diff --git a/landing/api/_lib/license.js b/landing/api/_lib/license.js new file mode 100644 index 0000000..6377ea8 --- /dev/null +++ b/landing/api/_lib/license.js @@ -0,0 +1,45 @@ +/** + * Dictx Pro license keys for Stripe purchases. + * + * A key is the Checkout Session id plus an HMAC over it: + * dxp--<32 hex chars> + * so issuing needs no storage, forging needs DICTX_LICENSE_SECRET, and every + * verification can re-check the live session for refunds and disputes. The + * charset stays within [A-Za-z0-9_-], which is all installed app versions + * send unchanged to /api/pro/verify. + * + * Rotating DICTX_LICENSE_SECRET invalidates every issued key. Do not rotate + * it without a migration. + */ +const crypto = require("node:crypto"); + +const KEY_PREFIX = "dxp-"; +const KEY_PATTERN = + /^dxp-(cs_(?:live|test)_[A-Za-z0-9]{10,200})-([0-9a-f]{32})$/; + +const mac = (sessionId, secret) => + crypto + .createHmac("sha256", secret) + .update(`dictx-pro:v1:${sessionId}`) + .digest("hex") + .slice(0, 32); + +const issueLicenseKey = (sessionId, secret) => + `${KEY_PREFIX}${sessionId}-${mac(sessionId, secret)}`; + +const isStripeLicenseKey = (value) => + typeof value === "string" && value.startsWith(KEY_PREFIX); + +/** Returns { sessionId } for an authentic key, otherwise null. */ +const parseLicenseKey = (value, secret) => { + const match = KEY_PATTERN.exec(value || ""); + if (!match || !secret) return null; + const expected = Buffer.from(mac(match[1], secret), "utf8"); + const given = Buffer.from(match[2], "utf8"); + if (expected.length !== given.length) return null; + return crypto.timingSafeEqual(expected, given) + ? { sessionId: match[1] } + : null; +}; + +module.exports = { isStripeLicenseKey, issueLicenseKey, parseLicenseKey }; diff --git a/landing/api/_lib/stripe-purchase.js b/landing/api/_lib/stripe-purchase.js new file mode 100644 index 0000000..d2f4868 --- /dev/null +++ b/landing/api/_lib/stripe-purchase.js @@ -0,0 +1,164 @@ +/** + * Dictx Pro purchases through Stripe Managed Payments (Link is the seller of + * record), verified server-side from the Checkout Session. + * + * Fail-closed by construction: a purchase counts only when the session is in + * the expected mode, complete and paid, contains exactly one Dictx Pro price + * at quantity 1, and its charge is neither refunded nor disputed. Anything the + * checker does not recognise is a refusal, never an entitlement. + * + * Test mode exists for non-production only: VERCEL_ENV=production forces live, + * whatever STRIPE_COMMERCE_MODE says, so no production configuration can + * accept a test session. + */ +const STRIPE_API = "https://api.stripe.com/v1"; + +const commerceMode = () => { + if (process.env.VERCEL_ENV === "production") return "live"; + return (process.env.STRIPE_COMMERCE_MODE || "").trim() === "test" + ? "test" + : "live"; +}; + +const sessionIdPattern = (mode = commerceMode()) => + mode === "test" + ? /^cs_(?:live|test)_[A-Za-z0-9]{10,200}$/ + : /^cs_live_[A-Za-z0-9]{10,200}$/; + +const secretKeyPattern = (mode = commerceMode()) => + mode === "test" + ? /^(?:sk|rk)_(?:live|test)_[A-Za-z0-9_]+$/ + : /^(?:sk|rk)_live_[A-Za-z0-9_]+$/; + +/** A public Payment Link must be a canonical live buy.stripe.com link. */ +const isCanonicalPaymentLink = (value) => { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.hostname === "buy.stripe.com" && + /^\/[A-Za-z0-9]+$/.test(url.pathname) && + !url.pathname.startsWith("/test_") && + url.search === "" && + url.hash === "" + ); + } catch (_error) { + return false; + } +}; + +const stripeConfig = () => { + const mode = commerceMode(); + const secretKey = (process.env.STRIPE_SECRET_KEY || "").trim(); + const priceId = (process.env.DICTX_STRIPE_PRICE_ID || "").trim(); + const licenseSecret = (process.env.DICTX_LICENSE_SECRET || "").trim(); + const paymentLink = (process.env.DICTX_STRIPE_PAYMENT_LINK || "").trim(); + const missing = []; + if (!secretKeyPattern(mode).test(secretKey)) + missing.push("STRIPE_SECRET_KEY"); + if (!/^price_[A-Za-z0-9]+$/.test(priceId)) + missing.push("DICTX_STRIPE_PRICE_ID"); + if (licenseSecret.length < 32) missing.push("DICTX_LICENSE_SECRET"); + return { + mode, + secretKey, + priceId, + licenseSecret, + paymentLink: isCanonicalPaymentLink(paymentLink) ? paymentLink : "", + ready: missing.length === 0, + missing, + }; +}; + +const stripeGet = async (path, secretKey) => { + let response; + try { + response = await fetch(`${STRIPE_API}${path}`, { + headers: { Authorization: `Bearer ${secretKey}` }, + }); + } catch (_error) { + return { status: 0 }; + } + if (!response.ok) return { status: response.status }; + try { + return { status: 200, body: await response.json() }; + } catch (_error) { + return { status: 0 }; + } +}; + +const fail = (reason) => ({ ok: false, reason }); + +const classifyPurchase = ({ + session, + lineItems, + priceId, + expectedLivemode, +}) => { + if (!session || typeof session !== "object") return fail("provider_invalid"); + if (session.livemode !== expectedLivemode) return fail("test_session"); + + const paid = + session.payment_status === "paid" || + session.payment_status === "no_payment_required"; + if (session.status !== "complete" || !paid) return fail("unpaid"); + + const items = Array.isArray(lineItems?.data) ? lineItems.data : []; + if (lineItems?.has_more || items.length !== 1) return fail("wrong_order"); + const [item] = items; + if (item?.price?.id !== priceId || item.quantity !== 1) { + return fail("wrong_order"); + } + + const paymentIntent = + session.payment_intent && typeof session.payment_intent === "object" + ? session.payment_intent + : null; + const charge = + paymentIntent && + paymentIntent.latest_charge && + typeof paymentIntent.latest_charge === "object" + ? paymentIntent.latest_charge + : null; + if (charge && (charge.refunded === true || charge.disputed === true)) { + return fail("refunded"); + } + + return { ok: true, sessionId: session.id }; +}; + +const purchaseForSession = async (sessionId, config = stripeConfig()) => { + if (!sessionIdPattern(config.mode).test(sessionId || "")) { + return fail("malformed_session"); + } + const id = encodeURIComponent(sessionId); + const sessionResult = await stripeGet( + `/checkout/sessions/${id}?expand%5B%5D=payment_intent.latest_charge`, + config.secretKey, + ); + if (sessionResult.status === 404) return fail("invalid_session"); + if (sessionResult.status !== 200) return fail("provider_unavailable"); + if (sessionResult.body?.id !== sessionId) return fail("provider_invalid"); + + const itemsResult = await stripeGet( + `/checkout/sessions/${id}/line_items?limit=5&expand%5B%5D=data.price`, + config.secretKey, + ); + if (itemsResult.status !== 200) return fail("provider_unavailable"); + + return classifyPurchase({ + session: sessionResult.body, + lineItems: itemsResult.body, + priceId: config.priceId, + expectedLivemode: config.mode === "live", + }); +}; + +module.exports = { + classifyPurchase, + commerceMode, + isCanonicalPaymentLink, + purchaseForSession, + sessionIdPattern, + stripeConfig, +}; diff --git a/landing/api/buy.js b/landing/api/buy.js new file mode 100644 index 0000000..4673f92 --- /dev/null +++ b/landing/api/buy.js @@ -0,0 +1,27 @@ +/** + * GET /buy (rewritten here by vercel.json) + * + * Sends buyers to the Stripe Managed Payments checkout once Stripe is fully + * configured: a canonical live Payment Link, a valid restricted key, the + * Dictx Pro price, and the license signing secret. Until all four are in + * place it keeps sending buyers to the existing Polar checkout, so deploying + * this change never breaks sales and a half-configured Stripe setup never + * takes money it cannot turn into a license. + */ +const { stripeConfig } = require("./_lib/stripe-purchase"); + +const POLAR_CHECKOUT_URL = + "https://buy.polar.sh/polar_cl_lchYpu4Y5BWTc1AbO05evqEZu3dXBAgvdenEy1PECGt"; + +const checkoutUrl = (config = stripeConfig()) => + config.ready && config.paymentLink ? config.paymentLink : POLAR_CHECKOUT_URL; + +const handler = (_req, res) => { + res.setHeader("cache-control", "no-store"); + res.redirect(307, checkoutUrl()); + return null; +}; + +module.exports = handler; +module.exports.checkoutUrl = checkoutUrl; +module.exports.POLAR_CHECKOUT_URL = POLAR_CHECKOUT_URL; diff --git a/landing/api/pro/license.js b/landing/api/pro/license.js new file mode 100644 index 0000000..18c5883 --- /dev/null +++ b/landing/api/pro/license.js @@ -0,0 +1,57 @@ +/** + * GET /api/pro/license?session_id=cs_live_... + * + * Issues the Dictx Pro license key for a verified Stripe purchase. The success + * page calls this after the Payment Link redirect; opening that page again + * reissues the same key, because the key is derived from the session id. + */ +const { purchaseForSession, stripeConfig } = require("../_lib/stripe-purchase"); +const { issueLicenseKey } = require("../_lib/license"); +const { + createRateLimiter, + getClientId, + getQueryParam, + sendJson, +} = require("../_lib/http"); + +const isRateLimited = createRateLimiter(60000, 20); + +const STATUS_BY_REASON = { + malformed_session: 404, + invalid_session: 404, + test_session: 404, + unpaid: 402, + wrong_order: 409, + refunded: 410, + provider_invalid: 502, + provider_unavailable: 502, +}; + +const handler = async (req, res) => { + if (req.method !== "GET") { + return sendJson(res, 405, { error: "method_not_allowed" }); + } + if (isRateLimited(getClientId(req))) { + return sendJson(res, 429, { error: "rate_limited" }); + } + + const config = stripeConfig(); + if (!config.ready) { + return sendJson(res, 503, { error: "stripe_not_configured" }); + } + + const sessionId = String(getQueryParam(req, "session_id")).trim(); + const purchase = await purchaseForSession(sessionId, config); + if (!purchase.ok) { + console.warn("pro_license_refused", { reason: purchase.reason }); + return sendJson(res, STATUS_BY_REASON[purchase.reason] || 400, { + error: purchase.reason, + }); + } + + return sendJson(res, 200, { + licenseKey: issueLicenseKey(purchase.sessionId, config.licenseSecret), + }); +}; + +module.exports = handler; diff --git a/landing/api/pro/verify.js b/landing/api/pro/verify.js index 38bbdc3..d0d4a59 100644 --- a/landing/api/pro/verify.js +++ b/landing/api/pro/verify.js @@ -1,3 +1,9 @@ +const { + purchaseForSession, + stripeConfig, +} = require("../_lib/stripe-purchase"); +const { isStripeLicenseKey, parseLicenseKey } = require("../_lib/license"); + const POLAR_API_BASE = process.env.POLAR_API_BASE || "https://api.polar.sh/v1"; const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN || ""; const POLAR_ORGANIZATION_ID = process.env.POLAR_ORGANIZATION_ID || ""; @@ -142,17 +148,46 @@ const sendJson = (res, statusCode, payload) => { }); }; -const handler = async (req, res) => { - if (req.method !== "POST") { - return sendJson(res, 405, { error: "method_not_allowed" }); - } +// Positive Stripe verifications are cached briefly so an app refresh storm +// does not become a Stripe request storm. Refusals are never cached. +const STRIPE_ACTIVE_TTL_MS = 10 * 60 * 1000; +const stripeActiveUntil = new Map(); - if (!POLAR_ACCESS_TOKEN) { - return sendJson(res, 500, { error: "missing_polar_access_token" }); +const verifyStripeLicense = async (res, licenseKey) => { + const config = stripeConfig(); + if (!config.ready) { + return sendJson(res, 503, { error: "stripe_not_configured" }); } + const parsed = parseLicenseKey(licenseKey, config.licenseSecret); + if (!parsed) { + return sendJson(res, 200, { active: false, mode: "stripe_license" }); + } + if ((stripeActiveUntil.get(parsed.sessionId) || 0) > Date.now()) { + return sendJson(res, 200, { active: true, mode: "stripe_license" }); + } + const purchase = await purchaseForSession(parsed.sessionId, config); + if (purchase.ok) { + stripeActiveUntil.set(parsed.sessionId, Date.now() + STRIPE_ACTIVE_TTL_MS); + return sendJson(res, 200, { active: true, mode: "stripe_license" }); + } + if ( + purchase.reason === "provider_unavailable" || + purchase.reason === "provider_invalid" + ) { + // An outage must not revoke Pro: the app keeps its entitlement on errors. + return sendJson(res, 502, { error: "stripe_api_error" }); + } + stripeActiveUntil.delete(parsed.sessionId); + return sendJson(res, 200, { + active: false, + mode: "stripe_license", + reason: purchase.reason, + }); +}; - if (!POLAR_ORGANIZATION_ID) { - return sendJson(res, 500, { error: "missing_polar_organization_id" }); +const handler = async (req, res) => { + if (req.method !== "POST") { + return sendJson(res, 405, { error: "method_not_allowed" }); } const clientId = getClientId(req); @@ -167,6 +202,20 @@ const handler = async (req, res) => { return sendJson(res, 400, { error: "licenseKey_required" }); } + // Stripe Managed Payments keys (dxp-...) verify without any Polar config. + // Everything below is the Polar path, kept so earlier purchases still work. + if (isStripeLicenseKey(licenseKey)) { + return verifyStripeLicense(res, licenseKey); + } + + if (!POLAR_ACCESS_TOKEN) { + return sendJson(res, 500, { error: "missing_polar_access_token" }); + } + + if (!POLAR_ORGANIZATION_ID) { + return sendJson(res, 500, { error: "missing_polar_organization_id" }); + } + if (licenseKey.length > MAX_LICENSE_KEY_LENGTH) { console.warn("pro_verify_invalid_key_length", { clientId }); return sendJson(res, 400, { error: "invalid_license_key" }); diff --git a/landing/buy/success/index.html b/landing/buy/success/index.html index fb65b39..90a03d1 100644 --- a/landing/buy/success/index.html +++ b/landing/buy/success/index.html @@ -62,8 +62,8 @@

Your License Key

id="license-key-value" style="word-break: break-all; font-family: monospace" > - If no key is shown here, copy your key from Polar customer portal or - receipt email. + Your license key appears here after checkout. Opening this page + again from the same link shows the same key.