Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions landing/.vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Tests run locally; they are not site content.
tests
49 changes: 41 additions & 8 deletions landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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
Expand All @@ -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/
```
49 changes: 49 additions & 0 deletions landing/api/_lib/http.js
Original file line number Diff line number Diff line change
@@ -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 };
45 changes: 45 additions & 0 deletions landing/api/_lib/license.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Dictx Pro license keys for Stripe purchases.
*
* A key is the Checkout Session id plus an HMAC over it:
* dxp-<cs_live_...>-<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 };
164 changes: 164 additions & 0 deletions landing/api/_lib/stripe-purchase.js
Original file line number Diff line number Diff line change
@@ -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,
};
27 changes: 27 additions & 0 deletions landing/api/buy.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading