Add free tier (unlicensed users get one-at-a-time summarization)

Unlicensed users can now summarize a single video at a time using their
own Gemini API key. The result renders in the UI exactly like a paid
summary, but is not persisted — there's no library entry, no history,
and a second submission while one is in flight is rejected.

Server (server/index.js):
  • /api/process is now in LICENSE_OPEN_PATHS. The route handler
    distinguishes free users (state !== "licensed" || no "core") and:
      - rejects USE_SERVER_KEY / empty key with 402 byo_key_required
        (so the bundled Gemini key stays paid-only)
      - rejects a second concurrent job with 409 processing_in_progress
        via a module-level freeJobInFlight flag, released in finally
      - skips saveToHistory so the host's library stays clean
  • Pro feature gates (history/library/subscriptions) unchanged — still
    return 402 feature_not_in_tier for unlicensed callers.

Frontend (public/index.html):
  • New state.activationSkipped flag (persisted to localStorage). The
    activation screen still appears on first launch, but now offers a
    "Skip — use free mode" button alongside Activate / Buy a key.
    Once skipped, the main app renders normally.
  • Free-mode upgrade banner under the top bar with Upgrade and "I have
    a key" buttons (the latter routes back to the activation screen).
  • handleLibraryClick / handleSubscribeClick wrappers — for unlicensed
    users, the library (clock) icon and the channel-URL Subscribe
    submission show a toast explaining the upgrade rather than opening
    an empty sidebar / hitting a 402.
  • Submit button enforces BYO key for unlicensed users (the bundled
    state.hasServerKey doesn't enable submit). handleSubmit shows a
    toast when an unlicensed user tries to queue a second video.
This commit is contained in:
Keysat
2026-05-08 11:16:02 -05:00
parent 7d71150439
commit 25b1c3a366
2 changed files with 160 additions and 25 deletions
+42 -5
View File
@@ -144,6 +144,9 @@ setInterval(() => {
}, VALIDATE_INTERVAL_MS);
// Endpoints reachable without a license — kept intentionally minimal.
// /api/process is open so unlicensed (free-tier) users can summarize one
// video at a time with their own Gemini key. The route handler enforces
// BYO-key + a one-at-a-time concurrency lock for free users.
const LICENSE_OPEN_PATHS = new Set([
"/api/health",
"/api/heartbeat",
@@ -151,6 +154,7 @@ const LICENSE_OPEN_PATHS = new Set([
"/api/license-status",
"/api/license/activate",
"/api/license/deactivate",
"/api/process",
]);
// Activation-screen gate: any /api/* request without a valid license is
@@ -165,7 +169,7 @@ app.use((req, res, next) => {
message:
LIC.state === "licensed"
? "Your license is missing the 'core' entitlement. Contact the seller."
: "This service requires a Keysat license. Activate to continue.",
: "This feature requires a Keysat license. Upgrade to unlock.",
state: LIC.state,
reason: LIC.reason,
activate_url: "/#activate",
@@ -174,6 +178,11 @@ app.use((req, res, next) => {
});
});
// Free-tier concurrency lock. Unlicensed users may process one video at
// a time — second submission while another is in flight returns 409.
// Released in a finally block at the bottom of /api/process.
let freeJobInFlight = false;
// Pro-tier feature gates. Each entry maps URL prefixes → required
// entitlement; first match wins. A licensed user without the right
// entitlement gets a clean 402 feature_not_in_tier (vs. the generic
@@ -2000,14 +2009,36 @@ app.get("/api/processing/log", (req, res) => {
app.post("/api/process", async (req, res) => {
const { url, apiKey: clientKey, model, type: itemType, title: itemTitle, uploadDate: itemUploadDate, episodeId } = req.body;
// BYO Gemini key is a Core-tier feature; the activation gate already
// ensures the caller is licensed, so no further check is needed here.
// Free tier: unlicensed users can summarize, but only with their own
// Gemini key (no riding on the bundled key) and only one job at a time.
const isFreeUser = !(LIC.state === "licensed" && LIC.entitlements.has("core"));
if (isFreeUser) {
if (!clientKey || clientKey === "USE_SERVER_KEY") {
return res.status(402).json({
error: "byo_key_required",
message:
"Free mode requires your own Gemini API key. Open Settings to enter one, or upgrade to use the bundled key.",
});
}
if (freeJobInFlight) {
return res.status(409).json({
error: "processing_in_progress",
message:
"A summary is already being processed. Free mode handles one video at a time — wait for the current one to finish.",
});
}
freeJobInFlight = true;
}
const apiKey = resolveApiKey(clientKey);
if (!url) {
if (isFreeUser) freeJobInFlight = false;
return res.status(400).json({ error: "Missing url" });
}
if (!apiKey) {
if (isFreeUser) freeJobInFlight = false;
return res.status(400).json({ error: "No API key provided. Set GEMINI_API_KEY in .env or enter one in Settings." });
}
@@ -2016,6 +2047,7 @@ app.post("/api/process", async (req, res) => {
const videoId = isPodcast ? (episodeId || url) : extractVideoId(url);
if (!isPodcast && !videoId) {
if (isFreeUser) freeJobInFlight = false;
return res.status(400).json({ error: "Invalid YouTube URL" });
}
@@ -2619,9 +2651,13 @@ Return ONLY the timestamped transcript, nothing else.`;
const totalTokens = (txCost.totalTokens + anaCost.totalTokens).toLocaleString();
log(3, `Pipeline finished in ${totalTime}s — total cost: ${totalCostDisplay} (${totalTokens} tokens)`);
// Save to history
// Save to history — skipped for free-tier users so we don't pollute
// the host's library with anonymous summaries. The result still streams
// back so the UI can render it; it just isn't persisted.
const contentType = isPodcast ? "podcast" : "youtube";
const historyId = await saveToHistory(videoId, url, videoTitle, chunks, entries, logHistory, videoUploadDate, contentType).catch(() => null);
const historyId = isFreeUser
? null
: await saveToHistory(videoId, url, videoTitle, chunks, entries, logHistory, videoUploadDate, contentType).catch(() => null);
sendEvent(res, "result", { videoId, videoTitle, entries, chunks, historyId, type: contentType });
res.end();
@@ -2635,6 +2671,7 @@ Return ONLY the timestamped transcript, nothing else.`;
res.end();
}
} finally {
if (isFreeUser) freeJobInFlight = false;
// Clean up temp files
try {
await fs.rm(tmpDir, { recursive: true, force: true });