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
9 changes: 2 additions & 7 deletions landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
221 changes: 18 additions & 203 deletions landing/api/pro/early-access/claim.js
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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;
13 changes: 0 additions & 13 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,6 @@ <h1>Transcription you can trust in real work.</h1>
>Download Latest macOS DMG</a
>
</div>
<p class="offer-pill">
First 100 installs unlock Dictx Pro for free automatically in-app.
</p>
<p class="meta">
$29 one-time • signed binaries • auto-updates • macOS / Windows /
Linux
Expand Down Expand Up @@ -165,9 +162,6 @@ <h2>Free or Pro</h2>
<article class="plan pro">
<p class="plan-name">Pro</p>
<p class="plan-price">$29 <span>one-time</span></p>
<p class="plan-offer">
Early access: first 100 installs get Pro free
</p>
<ul>
<li>Signed desktop binaries</li>
<li>In-app auto-updater</li>
Expand Down Expand Up @@ -244,13 +238,6 @@ <h2>FAQ</h2>
workflow.
</p>
</details>
<details>
<summary>How does the first-100 free Pro claim work?</summary>
<p>
The app claims it automatically on first activation. If your install
lands in the first 100, Pro is enabled instantly at no cost.
</p>
</details>
<details>
<summary>How do I activate Pro inside the app?</summary>
<p>
Expand Down
17 changes: 0 additions & 17 deletions landing/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,6 @@ h1 {
font-size: 13px;
}

.offer-pill {
margin: 14px 0 0;
display: inline-flex;
align-items: center;
border: 1px solid color-mix(in srgb, var(--color-logo-primary) 45%, transparent);
background: color-mix(in srgb, var(--color-logo-primary) 18%, transparent);
border-radius: 999px;
padding: 7px 11px;
font-size: 12px;
color: color-mix(in srgb, var(--color-text) 88%, transparent);
}

.hero-proof {
margin-top: 16px;
Expand Down Expand Up @@ -335,12 +324,6 @@ h1 {
color-mix(in srgb, var(--color-logo-primary) 18%, transparent);
}

.plan-offer {
margin: 6px 0 0;
color: color-mix(in srgb, var(--color-logo-primary) 84%, white 16%);
font-size: 12px;
font-weight: 600;
}

.plan-name {
margin: 0;
Expand Down
Loading