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
+118 -20
View File
@@ -1281,6 +1281,10 @@
licenseActivating: false,
licenseActivationError: null,
licenseActivationKey: "",
// Free tier: once dismissed, the activation screen no longer
// hard-gates the UI. Persisted so returning unlicensed users land
// straight in the app.
activationSkipped: localStorage.getItem("yt-summarizer-activation-skipped") === "1",
};
const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"];
@@ -1320,23 +1324,54 @@
// ── Process ──────────────────────────────────────────────────────────────
async function handleSubmit() {
const hasKey = state.apiKey.trim() || state.hasServerKey;
if (!state.url.trim() || !hasKey) return;
// Free tier requires BYO Gemini key — bundled key is licensed-only.
const free = !isLicensed();
const hasKey = free ? !!state.apiKey.trim() : (state.apiKey.trim() || state.hasServerKey);
if (!state.url.trim() || !hasKey) {
if (free && !state.apiKey.trim() && state.url.trim()) {
showToast("Free mode needs your own Gemini API key — open Settings to enter one.", "🔑");
}
return;
}
const url = state.url.trim();
state.url = "";
// If already processing, add to queue
// If already processing — free tier blocks, paid tier queues.
if (state.loading) {
if (free) {
showToast("Free mode handles one video at a time. Wait for the current one to finish.", "⏳");
return;
}
state.url = "";
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
render();
return;
}
// Start processing immediately
state.url = "";
await processUrl(url);
}
function handleLibraryClick() {
// Library / history is a paid feature (history entitlement). For
// free-tier users, surface the upgrade prompt instead of opening an
// empty sidebar.
if (!hasEntitlement("history")) {
showToast("Library is a paid feature — keep every summary you process. Tap upgrade to unlock.", "📚");
return;
}
toggleHistory();
}
function handleSubscribeClick() {
// Subscriptions / channel auto-queue is Pro-only.
if (!hasEntitlement("subscriptions")) {
showToast("Channel & podcast subscriptions are a Pro feature. Upgrade to auto-summarize new uploads.", "📡");
return;
}
addSubscriptionFromInput();
}
function extractVideoId(url) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
@@ -1586,11 +1621,11 @@
return `
<div class="activation-screen">
<div class="activation-card">
<h1>Activate YouTube Summarizer</h1>
<h1>YouTube Summarizer</h1>
<p class="activation-sub">
${loading
? "Checking license…"
: "Paste your Keysat license key to unlock this app. Buy a key from the seller, then come back here."
: "Activate a Keysat license to unlock the full app — library, subscriptions, channel auto-queue, and the bundled API key. Or skip to use free mode (one video at a time, bring your own Gemini key)."
}
</p>
${loading ? "" : `
@@ -1606,6 +1641,11 @@
${state.licenseActivating ? "Activating…" : "Activate"}
</button>
<a class="activation-link" href="${escHtml(buyUrl)}" target="_blank" rel="noopener">Buy a key &rarr;</a>
<button class="activation-link"
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
onclick="dismissActivation()">
Skip &mdash; use free mode
</button>
</div>
<div class="activation-meta">
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
@@ -1617,20 +1657,67 @@
`;
}
function dismissActivation() {
state.activationSkipped = true;
try { localStorage.setItem("yt-summarizer-activation-skipped", "1"); } catch {}
render();
}
function renderFreeBanner() {
const buyUrl = upgradeToProUrl();
return `
<div class="free-banner" style="
margin: 8px 0 12px;
padding: 10px 14px;
background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10));
border: 1px solid rgba(168,85,247,0.35);
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: #e2e8f0;
font-size: 13px;
">
<span style="flex:1; min-width: 220px;">
<strong style="color:#c4b5fd;">Free mode</strong>
&middot; one video at a time, bring your own Gemini key &middot;
no library, no subscriptions
</span>
<a href="${escHtml(buyUrl)}" target="_blank" rel="noopener"
style="background:#a855f7;color:#fff;border:none;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600;">
Upgrade
</a>
<button onclick="showActivationScreen()"
style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;">
I have a key
</button>
</div>
`;
}
function showActivationScreen() {
// Take user back to the activation modal (e.g. from an upgrade banner).
state.activationSkipped = false;
try { localStorage.removeItem("yt-summarizer-activation-skipped"); } catch {}
render();
}
function render() {
// Hard-gate the entire UI behind a valid license (matches the server's
// activation-screen flavor). Once licensed + has core, fall through to
// the normal app render below.
if (state.license.loaded && !isLicensed()) {
// Initial paint while license-status is still in-flight: show the
// activation card in its loading skeleton state rather than a flash of
// the underlying app.
if (!state.license.loaded) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
return;
}
// Initial paint while license-status is still in-flight: show the
// activation card in its loading skeleton state rather than a flash of
// the underlying app.
if (!state.license.loaded) {
// Show the activation screen on first launch for unlicensed users so
// they discover the upgrade path. Once they hit "Skip — use free mode"
// (which sets activationSkipped = true) they fall through to the
// normal app, which renders an upgrade banner instead.
if (!isLicensed() && !state.activationSkipped) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
@@ -1646,11 +1733,20 @@
// Preserve library sidebar scroll position across full re-renders
const __prevHistoryListEl = document.querySelector(".history-list");
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
const free = !isLicensed();
const submitNeedsBYO = free; // bundled key is licensed-only
const submitDisabled = !state.url.trim()
|| (!isSubscribeUrl(state.url)
&& (submitNeedsBYO
? !state.apiKey.trim()
: (!state.apiKey.trim() && !state.hasServerKey)));
app.innerHTML = `
<!-- Top bar: title + action icons -->
<div class="top-bar">
<div class="top-left-actions">
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="toggleHistory()" title="Library">
<button class="icon-btn ${state.historyOpen && !free ? "active" : ""}" onclick="handleLibraryClick()"
title="${free ? "Library (upgrade to unlock)" : "Library"}"
style="${free ? "opacity:0.55;" : ""}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
</svg>
@@ -1658,13 +1754,13 @@
</div>
<div class="top-bar-input">
<input type="text" class="url-input"
placeholder="${state.loading ? "Paste another URL to queue it..." : "Paste a YouTube video, channel, or podcast RSS URL..."}"
placeholder="${state.loading ? (free ? "Wait for the current video — free mode is one at a time" : "Paste another URL to queue it...") : "Paste a YouTube video, channel, or podcast RSS URL..."}"
value="${escHtml(state.url)}"
oninput="state.url=this.value; updateInputMode()"
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? addSubscriptionFromInput() : handleSubmit() }" />
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? handleSubscribeClick() : handleSubmit() }" />
<button class="submit-btn"
onclick="${isSubscribeUrl(state.url) ? "addSubscriptionFromInput()" : "handleSubmit()"}"
${!state.url.trim() || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey) ? "disabled" : ""}
onclick="${isSubscribeUrl(state.url) ? "handleSubscribeClick()" : "handleSubmit()"}"
${submitDisabled ? "disabled" : ""}
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}
</button>
@@ -1738,6 +1834,8 @@
<!-- Settings modal -->
${state.settingsOpen ? renderSettingsModal() : ""}
${free ? renderFreeBanner() : ""}
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
${state.queue.length > 0 ? renderQueue() : ""}
+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 });