content-grade 1.0.29 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,7 +64,14 @@ function getLicenseKey() {
64
64
 
65
65
  function getLicenseTier() {
66
66
  const cfg = loadConfig();
67
- const tier = cfg.tier || 'free';
67
+ let tier = cfg.tier || 'free';
68
+ // Fallback: if config has no tier but license file exists, read tier from there
69
+ if (tier === 'free') {
70
+ try {
71
+ const lic = JSON.parse(readFileSync(LICENSE_FILE, 'utf8'));
72
+ if (lic.tier) tier = lic.tier;
73
+ } catch {}
74
+ }
68
75
  // Collapse legacy 'starter' tier to 'pro' — Starter was removed
69
76
  return tier === 'starter' ? 'pro' : tier;
70
77
  }
@@ -199,6 +206,18 @@ function showUsageFooter(count) {
199
206
  maybeShowEarlyAdopterCTA(count);
200
207
  }
201
208
 
209
+ // Returns { ok: boolean, count: number, limit: number } for tier-aware daily limit checks.
210
+ // Used by batch mode to stop processing when the daily limit is reached.
211
+ // Pro tier has Infinity limit and always returns ok: true.
212
+ function checkDailyLimit() {
213
+ const tier = getLicenseTier();
214
+ const limit = TIER_LIMITS[tier] ?? TIER_LIMITS.free;
215
+ if (limit === Infinity) return { ok: true, count: 0, limit: Infinity };
216
+ const usage = getUsage();
217
+ const count = usage.count || 0;
218
+ return { ok: count < limit, count, limit };
219
+ }
220
+
202
221
  // Block a run before it starts if the free tier is exhausted.
203
222
  // Returns true (blocked) and prints a visually distinct upgrade prompt.
204
223
  // Returns false if the user may proceed.
@@ -208,6 +227,9 @@ function checkFreeTierLimit() {
208
227
  const limit = TIER_LIMITS.free;
209
228
  if (usage.count < limit) return false;
210
229
 
230
+ // Track funnel event — user hit the limit and saw the upgrade prompt
231
+ recordEvent({ event: 'free_limit_hit', version: _version });
232
+
211
233
  blank();
212
234
  hr();
213
235
  console.log(` ${YL}${B}${limit}/${limit} free analyses used — limit reached.${R}`);
@@ -1013,7 +1035,7 @@ async function cmdActivate() {
1013
1035
  if (/not found/i.test(msg) || /invalid/i.test(msg)) {
1014
1036
  blank();
1015
1037
  console.log(` ${D}If you recently purchased, retrieve your key:${R}`);
1016
- console.log(` ${CY} https://content-grade.onrender.com/api/stripe/license-key?email=YOUR_EMAIL${R}`);
1038
+ console.log(` ${CY} https://content-grade.onrender.com/my-license${R}`);
1017
1039
  }
1018
1040
  blank();
1019
1041
  process.exit(1);
@@ -2084,11 +2106,14 @@ const _showTelemNotice = _telem.isNew;
2084
2106
  contentgrade_team: 'team',
2085
2107
  };
2086
2108
  updated.tier = productKeyToTier[data.productKey] || 'pro';
2087
- } else {
2109
+ } else if (data.reason === 'revoked' || data.reason === 'subscription_expired') {
2110
+ // Only delete the key on confirmed revocation or expired subscription — NOT on 'not_found'
2111
+ // (which can happen after a Render cold start wipes the ephemeral /tmp DB).
2088
2112
  delete updated.licenseKey;
2089
2113
  delete updated.tier;
2090
2114
  try { unlinkSync(LICENSE_FILE); } catch {}
2091
2115
  }
2116
+ // If reason is 'not_found' or 'format_invalid', keep the stored key — server DB may have been wiped.
2092
2117
  saveConfig(updated);
2093
2118
  } catch {
2094
2119
  // Server unreachable — skip silently
package/bin/telemetry.js CHANGED
@@ -124,7 +124,7 @@ export function recordEvent(data) {
124
124
  const event = {
125
125
  ...data,
126
126
  package: 'content-grade',
127
- installId,
127
+ install_id: installId,
128
128
  run_count_bucket: data.run_count_bucket ?? getRunCountBucket(totalRuns),
129
129
  timestamp: new Date().toISOString(),
130
130
  };
@@ -42,16 +42,19 @@ export function registerLicenseRoutes(app) {
42
42
  // Key not in DB — could be Render cold start DB wipe. Guide user to self-serve recovery.
43
43
  const isValidFormat = /^CG-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/i.test(key);
44
44
  if (isValidFormat) {
45
- return { valid: false, message: 'Key not found. If you are a Pro subscriber, retrieve a fresh key at: https://content-grade.onrender.com/api/stripe/license-key?email=YOUR_EMAIL' };
45
+ return { valid: false, reason: 'not_found', message: 'Key not found. If you are a Pro subscriber, retrieve your key at: https://content-grade.onrender.com/my-license' };
46
46
  }
47
- return { valid: false, message: 'Invalid license key format.' };
47
+ return { valid: false, reason: 'format_invalid', message: 'Invalid license key format.' };
48
+ }
49
+ if (result.status !== 'active') {
50
+ return { valid: false, reason: 'revoked', message: 'License key has been revoked.' };
48
51
  }
49
52
  // Also verify Stripe subscription is still active for subscription-based keys.
50
53
  // Uses live Stripe check (with DB fallback) so DB wipes on Render cold start don't invalidate valid subscribers.
51
54
  if (result.productKey === 'contentgrade_pro' && result.email) {
52
55
  const subActive = await hasActiveSubscriptionLive(result.email);
53
56
  if (!subActive) {
54
- return { valid: false, message: 'Subscription is no longer active. Please renew at content-grade.onrender.com.' };
57
+ return { valid: false, reason: 'subscription_expired', message: 'Subscription is no longer active. Please renew at content-grade.onrender.com.' };
55
58
  }
56
59
  }
57
60
  return { valid: true, email: result.email, productKey: result.productKey };
@@ -110,7 +110,7 @@ export function registerStripeRoutes(app) {
110
110
  try {
111
111
  const data = event.data?.object;
112
112
  if (event.type === 'checkout.session.completed') {
113
- const email = (data.client_reference_id ?? data.customer_email ?? '').toLowerCase();
113
+ const email = (data.client_reference_id ?? data.customer_email ?? data.customer_details?.email ?? '').toLowerCase();
114
114
  const stripeCustomerId = data.customer;
115
115
  if (!email) {
116
116
  console.error('[stripe_webhook] checkout.session.completed missing customer email — session:', data.id, '— license key NOT generated');
@@ -250,7 +250,7 @@ export function registerStripeRoutes(app) {
250
250
  reply.type('text/html').status(402);
251
251
  return successHtml('Payment pending', '<p>Your payment has not completed yet. Please wait a moment and refresh.</p>');
252
252
  }
253
- const email = (session.client_reference_id ?? session.customer_email ?? '').toLowerCase();
253
+ const email = (session.client_reference_id ?? session.customer_email ?? session.customer_details?.email ?? '').toLowerCase();
254
254
  if (!email) {
255
255
  reply.type('text/html').status(400);
256
256
  return successHtml('Email not found', '<p>We couldn\'t identify your account. Please contact support with your Stripe receipt.</p>');
@@ -265,8 +265,7 @@ export function registerStripeRoutes(app) {
265
265
  <p>Activate it in your terminal:</p>
266
266
  <pre style="background:#111;color:#ccc;padding:12px;border-radius:6px">content-grade activate ${licenseKey}</pre>
267
267
  <p style="font-size:0.9em;color:#888">
268
- Save this key — it won't be shown again.<br>
269
- Retrieve it any time: <code>GET /api/stripe/license-key?email=${encodeURIComponent(email)}</code>
268
+ Retrieve it any time: <a href="/my-license?email=${encodeURIComponent(email)}">/my-license?email=${encodeURIComponent(email)}</a>
270
269
  </p>
271
270
  `);
272
271
  }
@@ -276,6 +275,72 @@ export function registerStripeRoutes(app) {
276
275
  return successHtml('Error retrieving order', '<p>We could not load your order details. Please contact support with your Stripe receipt.</p>');
277
276
  }
278
277
  });
278
+ // Self-service license key retrieval page — for users who paid via static Stripe link
279
+ // and weren't redirected to /checkout/success, or who lost their key.
280
+ app.get('/my-license', async (req, reply) => {
281
+ const query = req.query;
282
+ const email = (query.email ?? '').trim().toLowerCase();
283
+ if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
284
+ // Email provided — look up their key
285
+ const isActive = await hasActiveSubscriptionLive(email);
286
+ if (!isActive) {
287
+ reply.type('text/html');
288
+ return successHtml('No active subscription found', `
289
+ <p>No active Content-Grade Pro subscription was found for <strong>${email}</strong>.</p>
290
+ <p>If you just paid, it can take up to a minute for the webhook to process. Try again shortly.</p>
291
+ <p>If you believe this is an error, contact support with your Stripe receipt.</p>
292
+ <p><a href="/my-license">Try a different email</a></p>
293
+ `);
294
+ }
295
+ const customerId = getCustomerStripeId(email);
296
+ const licenseKey = upsertLicenseKey(email, customerId ?? undefined, 'contentgrade_pro');
297
+ reply.type('text/html');
298
+ return successHtml('Your Content-Grade Pro License', `
299
+ <p>Active Pro subscription confirmed for <strong>${email}</strong>.</p>
300
+ <p>Your license key:</p>
301
+ <pre style="background:#111;color:#7fff7f;padding:16px;border-radius:6px;font-size:1.1em;letter-spacing:0.05em">${licenseKey}</pre>
302
+ <p>Activate it in your terminal:</p>
303
+ <pre style="background:#111;color:#ccc;padding:12px;border-radius:6px">content-grade activate ${licenseKey}</pre>
304
+ <p style="font-size:0.9em;color:#888">
305
+ Bookmark this page to retrieve your key any time.<br>
306
+ Replace the email in the URL: <code>/my-license?email=${encodeURIComponent(email)}</code>
307
+ </p>
308
+ `);
309
+ }
310
+ // No email — show the form
311
+ reply.type('text/html');
312
+ return `<!DOCTYPE html>
313
+ <html lang="en">
314
+ <head>
315
+ <meta charset="UTF-8"/>
316
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
317
+ <title>Retrieve License Key — Content-Grade</title>
318
+ <style>
319
+ body{font-family:system-ui,sans-serif;background:#0d0d0d;color:#e8e8e8;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
320
+ .card{max-width:480px;width:90%;background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:40px}
321
+ h1{margin-top:0;font-size:1.5em}
322
+ input{width:100%;box-sizing:border-box;padding:10px 14px;background:#111;border:1px solid #444;border-radius:6px;color:#e8e8e8;font-size:1em;margin:8px 0 16px}
323
+ button{width:100%;padding:12px;background:#7fff7f;color:#000;border:none;border-radius:6px;font-size:1em;font-weight:600;cursor:pointer}
324
+ button:hover{background:#5fdf5f}
325
+ p{color:#aaa;font-size:0.9em}
326
+ a{color:#7fc4ff}
327
+ </style>
328
+ </head>
329
+ <body>
330
+ <div class="card">
331
+ <h1>Retrieve Your License Key</h1>
332
+ <p>Enter the email address you used when purchasing Content-Grade Pro.</p>
333
+ <form method="GET" action="/my-license">
334
+ <input type="email" name="email" placeholder="you@example.com" required autofocus/>
335
+ <button type="submit">Get My License Key</button>
336
+ </form>
337
+ <p style="margin-top:24px">
338
+ Don't have Pro yet? <a href="/upgrade">Upgrade for $9/mo →</a>
339
+ </p>
340
+ </div>
341
+ </body>
342
+ </html>`;
343
+ });
279
344
  // Upgrade redirect — CLI rate-limit messages point here for a clean, stable URL
280
345
  app.get('/upgrade', async (_req, reply) => {
281
346
  const proLink = process.env.STRIPE_PAYMENT_LINK_CONTENTGRADE_PRO
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-grade",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "description": "AI-powered content analysis CLI. Score any blog post, landing page, or ad copy in under 30 seconds — runs on Claude CLI, no API key needed.",
5
5
  "type": "module",
6
6
  "bin": {