diff --git a/landing/README.md b/landing/README.md index e798928..09de9bd 100644 --- a/landing/README.md +++ b/landing/README.md @@ -20,7 +20,7 @@ Attach `dictx.splitlabs.io` to this Vercel project. - `/buy/success?session_id=cs_live_...` shows the license key, fetched from `/api/pro/license` - `/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. Keys from the retired Polar checkout (`lk_...`, `polar_cl_...`) answer `410`, so apps that already activated one keep Pro -- `/api/pro/early-access/claim` grants free Pro for the first 100 unique installs +- `/api/pro/early-access/claim` answers `404 early_access_closed`. The "first 100 installs get Pro free" offer is retired: its claim store was never provisioned, so it only ever returned 500. Installed apps read 404 as "no grant" and carry on, so the route stays instead of 404ing as a missing file - `/js/script.cookieless.js` and `/api/events` proxy DataFast first-party; `middleware.ts` reports AI crawler requests (Edge runtime: the Node.js runtime served every page as 500 on this project) ## Stripe Managed Payments @@ -53,15 +53,10 @@ DataFast: - `DATAFAST_WEBSITE_ID`: the public website id; enables crawler tracking in `middleware.ts` -Rate limits and early access: +Rate limits: - `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 -- `UPSTASH_REDIS_REST_TOKEN`: Upstash REST token for early-access claim counter -- `DICTX_PRO_EARLY_ACCESS_LIMIT`: optional free-claim cap (defaults to `100`) -- `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 ## Tests diff --git a/landing/api/pro/early-access/claim.js b/landing/api/pro/early-access/claim.js index cd97b34..bf44b4e 100644 --- a/landing/api/pro/early-access/claim.js +++ b/landing/api/pro/early-access/claim.js @@ -1,131 +1,26 @@ -const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL || ""; -const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN || ""; -const EARLY_ACCESS_LIMIT = Number.parseInt( - process.env.DICTX_PRO_EARLY_ACCESS_LIMIT || "100", - 10, -); -const RATE_LIMIT_WINDOW_MS = Number.parseInt( - process.env.PRO_EARLY_ACCESS_RATE_LIMIT_WINDOW_MS || "60000", - 10, -); -const RATE_LIMIT_MAX = Number.parseInt( - process.env.PRO_EARLY_ACCESS_RATE_LIMIT_MAX || "30", - 10, -); - -const INSTALL_ID_PATTERN = /^[A-Za-z0-9._:-]{8,160}$/; -const MAX_APP_VERSION_LENGTH = 32; -const requestBuckets = new Map(); - -const CLAIMS_SET_KEY = "dictx:pro:early_access:installs"; -const CLAIMS_RANK_KEY = "dictx:pro:early_access:ranks"; - -const CLAIM_SCRIPT = ` -local set_key = KEYS[1] -local rank_key = KEYS[2] -local install_id = ARGV[1] -local limit = tonumber(ARGV[2]) -local now = ARGV[3] - -if redis.call("SISMEMBER", set_key, install_id) == 1 then - local rank = redis.call("ZSCORE", rank_key, install_id) - return {1, tostring(rank or "0")} -end - -local count = redis.call("SCARD", set_key) -if count >= limit then - return {0, tostring(count)} -end - -redis.call("SADD", set_key, install_id) -local claimed = redis.call("SCARD", set_key) -redis.call("ZADD", rank_key, claimed, install_id) -redis.call("HSET", "dictx:pro:early_access:meta:" .. install_id, "claimed_at", now) -return {1, tostring(claimed)} -`; - -const getHeader = (req, name) => { - if (!req || !req.headers) return ""; - if (typeof req.headers.get === "function") { - return req.headers.get(name) || ""; - } - const lower = name.toLowerCase(); - return req.headers[lower] || req.headers[name] || ""; -}; - -const readBody = async (req) => { - if (!req) return {}; - - if (req.body !== undefined) { - if (typeof req.body === "string") { - try { - return JSON.parse(req.body); - } catch (_error) { - return {}; - } - } - - if (typeof req.body === "object" && req.body !== null) { - return req.body; - } - } - - if (typeof req.json === "function") { - try { - return await req.json(); - } catch (_error) { - return {}; - } - } - - return {}; -}; - -const getClientId = (req) => { - const forwarded = getHeader(req, "x-forwarded-for"); - if (Array.isArray(forwarded)) { - return forwarded[0] || "unknown"; - } - if (forwarded.length > 0) { - const [first] = forwarded.split(","); - return first.trim() || "unknown"; - } - return req?.socket?.remoteAddress || "unknown"; -}; - -const isRateLimited = (clientId) => { - const now = Date.now(); - if (requestBuckets.size > 5000) { - for (const [key, bucket] of requestBuckets.entries()) { - if (now > bucket.resetAt) { - requestBuckets.delete(key); - } - } - } - - const existing = requestBuckets.get(clientId); - if (!existing || now > existing.resetAt) { - requestBuckets.set(clientId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return false; - } - - existing.count += 1; - requestBuckets.set(clientId, existing); - return existing.count > RATE_LIMIT_MAX; -}; - +/** + * POST /api/pro/early-access/claim + * + * The early-access offer ("first 100 installs get Pro free") is retired. + * + * It never worked: the claim store (Upstash Redis) was never provisioned in + * production, so every claim answered 500 missing_redis_config. Installed apps + * treat a 5xx as a failure and record a verification error on the Pro check, + * while 404 means "not granted" and lets the app carry on silently + * (src-tauri/src/commands/pro.rs, claim_early_access). So this endpoint stays + * in place and answers 404 rather than disappearing: older installs keep + * calling it, and they must not be shown an error for an offer we withdrew. + */ const sendJson = (res, statusCode, payload) => { + const body = JSON.stringify(payload); + if (res && typeof res.status === "function") { - if (typeof res.setHeader === "function") { - res.setHeader("cache-control", "no-store"); - res.setHeader("pragma", "no-cache"); - res.setHeader("expires", "0"); - } + res.setHeader("cache-control", "no-store"); res.status(statusCode).json(payload); return null; } - return new Response(JSON.stringify(payload), { + return new Response(body, { status: statusCode, headers: { "content-type": "application/json", @@ -134,92 +29,12 @@ const sendJson = (res, statusCode, payload) => { }); }; -const callRedisEval = async (installId) => { - const response = await fetch(`${UPSTASH_REDIS_REST_URL}/eval`, { - method: "POST", - headers: { - Authorization: `Bearer ${UPSTASH_REDIS_REST_TOKEN}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - script: CLAIM_SCRIPT, - keys: [CLAIMS_SET_KEY, CLAIMS_RANK_KEY], - args: [installId, String(EARLY_ACCESS_LIMIT), new Date().toISOString()], - }), - }); - - if (!response.ok) { - const detail = await response.text(); - throw new Error(`redis_eval_failed:${response.status}:${detail}`); - } - - const body = await response.json(); - const result = Array.isArray(body?.result) ? body.result : []; - const isActive = Number.parseInt(String(result[0] ?? "0"), 10) === 1; - const rank = Number.parseInt(String(result[1] ?? "0"), 10); - return { isActive, rank }; -}; - const handler = async (req, res) => { if (req.method !== "POST") { return sendJson(res, 405, { error: "method_not_allowed" }); } - if (!UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN) { - return sendJson(res, 500, { error: "missing_redis_config" }); - } - - const clientId = getClientId(req); - if (isRateLimited(clientId)) { - return sendJson(res, 429, { error: "rate_limited" }); - } - - const body = await readBody(req); - const installId = String(body.installId || "").trim(); - const appVersion = String(body.appVersion || "").trim(); - - if (!installId) { - return sendJson(res, 400, { error: "installId_required" }); - } - - if (!INSTALL_ID_PATTERN.test(installId)) { - return sendJson(res, 400, { error: "invalid_install_id" }); - } - - if (appVersion.length > MAX_APP_VERSION_LENGTH) { - return sendJson(res, 400, { error: "invalid_app_version" }); - } - - try { - const { isActive, rank } = await callRedisEval(installId); - const remainingRaw = EARLY_ACCESS_LIMIT - Math.max(rank, 0); - const remaining = remainingRaw > 0 ? remainingRaw : 0; - - if (!isActive) { - return sendJson(res, 200, { - active: false, - limit: EARLY_ACCESS_LIMIT, - remaining, - }); - } - - return sendJson(res, 200, { - active: true, - licenseKey: `EARLY-${installId}`, - rank, - limit: EARLY_ACCESS_LIMIT, - remaining, - }); - } catch (error) { - console.warn("pro_early_access_claim_error", { - clientId, - detail: error instanceof Error ? error.message : String(error), - }); - return sendJson(res, 500, { - error: "internal_error", - detail: error instanceof Error ? error.message : String(error), - }); - } + return sendJson(res, 404, { error: "early_access_closed" }); }; module.exports = handler; diff --git a/landing/index.html b/landing/index.html index 50dda77..4cb63f0 100644 --- a/landing/index.html +++ b/landing/index.html @@ -96,9 +96,6 @@

Transcription you can trust in real work.

>Download Latest macOS DMG -

- First 100 installs unlock Dictx Pro for free automatically in-app. -

$29 one-time • signed binaries • auto-updates • macOS / Windows / Linux @@ -165,9 +162,6 @@

Free or Pro

Pro

$29 one-time

-

- Early access: first 100 installs get Pro free -