content-grade 1.0.4 → 1.0.6

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.
package/dist/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
  }
15
15
  #root { min-height: 100vh; }
16
16
  </style>
17
- <script type="module" crossorigin src="/assets/index-BUN69TiT.js"></script>
17
+ <script type="module" crossorigin src="/assets/index-Bc3ZrBgH.js"></script>
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
@@ -6,6 +6,8 @@ import Fastify from 'fastify';
6
6
  import cors from '@fastify/cors';
7
7
  import { registerDemoRoutes } from './routes/demos.js';
8
8
  import { registerStripeRoutes } from './routes/stripe.js';
9
+ import { registerAnalyticsRoutes } from './routes/analytics.js';
10
+ import { registerLicenseRoutes } from './routes/license.js';
9
11
  export async function createApp(opts = {}) {
10
12
  const app = Fastify({
11
13
  logger: opts.logger ?? { level: 'warn' },
@@ -28,6 +30,8 @@ export async function createApp(opts = {}) {
28
30
  });
29
31
  registerDemoRoutes(app);
30
32
  registerStripeRoutes(app);
33
+ registerAnalyticsRoutes(app);
34
+ registerLicenseRoutes(app);
31
35
  app.get('/api/health', async () => ({
32
36
  status: 'alive',
33
37
  uptime: process.uptime(),
@@ -75,5 +75,51 @@ function migrate(db) {
75
75
  product_key TEXT NOT NULL,
76
76
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
77
77
  );
78
+
79
+ CREATE TABLE IF NOT EXISTS funnel_events (
80
+ id INTEGER PRIMARY KEY,
81
+ event TEXT NOT NULL,
82
+ ip_hash TEXT,
83
+ tool TEXT,
84
+ email TEXT,
85
+ metadata TEXT,
86
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS cli_telemetry (
90
+ id INTEGER PRIMARY KEY,
91
+ install_id TEXT NOT NULL,
92
+ event TEXT NOT NULL,
93
+ command TEXT,
94
+ duration_ms INTEGER,
95
+ success INTEGER,
96
+ exit_code INTEGER,
97
+ score REAL,
98
+ content_type TEXT,
99
+ version TEXT,
100
+ platform TEXT,
101
+ node_version TEXT,
102
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
103
+ );
104
+
105
+ CREATE INDEX IF NOT EXISTS idx_funnel_events_event ON funnel_events(event);
106
+ CREATE INDEX IF NOT EXISTS idx_funnel_events_created ON funnel_events(created_at);
107
+ CREATE INDEX IF NOT EXISTS idx_cli_telemetry_install ON cli_telemetry(install_id);
108
+ CREATE INDEX IF NOT EXISTS idx_cli_telemetry_command ON cli_telemetry(command);
109
+
110
+ CREATE TABLE IF NOT EXISTS license_keys (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ key TEXT UNIQUE NOT NULL,
113
+ email TEXT NOT NULL,
114
+ stripe_customer_id TEXT,
115
+ product_key TEXT NOT NULL DEFAULT 'contentgrade_pro',
116
+ status TEXT NOT NULL DEFAULT 'active',
117
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
118
+ last_validated_at DATETIME,
119
+ validation_count INTEGER DEFAULT 0
120
+ );
121
+
122
+ CREATE INDEX IF NOT EXISTS idx_license_keys_key ON license_keys(key);
123
+ CREATE INDEX IF NOT EXISTS idx_license_keys_email ON license_keys(email);
78
124
  `);
79
125
  }
@@ -0,0 +1,283 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { getDb } from '../db.js';
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const METRICS_PATH = resolve(__dirname, '../../data/metrics.json');
8
+ const ALLOWED_FUNNEL_EVENTS = new Set([
9
+ 'free_limit_hit',
10
+ 'upgrade_clicked',
11
+ 'checkout_started',
12
+ 'checkout_completed',
13
+ 'pro_unlocked',
14
+ 'email_captured',
15
+ 'tool_used',
16
+ ]);
17
+ function hashIp(ip) {
18
+ return createHash('sha256').update(ip).digest('hex');
19
+ }
20
+ function todayUTC() {
21
+ return new Date().toISOString().slice(0, 10);
22
+ }
23
+ function sevenDaysAgo() {
24
+ const d = new Date();
25
+ d.setDate(d.getDate() - 7);
26
+ return d.toISOString().slice(0, 10);
27
+ }
28
+ function thirtyDaysAgo() {
29
+ const d = new Date();
30
+ d.setDate(d.getDate() - 30);
31
+ return d.toISOString().slice(0, 10);
32
+ }
33
+ export function registerAnalyticsRoutes(app) {
34
+ // ── CLI telemetry receiver ────────────────────────────────────────────────
35
+ // Receives events from CLI users who have opted in to telemetry.
36
+ // Always returns 200 — never interrupt a CLI session for analytics.
37
+ app.post('/api/telemetry', async (req) => {
38
+ try {
39
+ const body = req.body;
40
+ if (!body || typeof body.install_id !== 'string' || !body.install_id) {
41
+ return { ok: true }; // silently accept malformed — never fail callers
42
+ }
43
+ const db = getDb();
44
+ db.prepare(`
45
+ INSERT INTO cli_telemetry
46
+ (install_id, event, command, duration_ms, success, exit_code, score, content_type, version, platform, node_version)
47
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
48
+ `).run(String(body.install_id).slice(0, 64), String(body.event ?? 'unknown').slice(0, 64), body.command ? String(body.command).slice(0, 64) : null, typeof body.duration_ms === 'number' ? Math.round(body.duration_ms) : null, typeof body.success === 'boolean' ? (body.success ? 1 : 0) : null, typeof body.exit_code === 'number' ? body.exit_code : null, typeof body.score === 'number' ? body.score : null, body.content_type ? String(body.content_type).slice(0, 64) : null, body.version ? String(body.version).slice(0, 32) : null, body.platform ? String(body.platform).slice(0, 32) : null, body.nodeVersion ? String(body.nodeVersion).slice(0, 32) : null);
49
+ }
50
+ catch {
51
+ // never fail — telemetry is non-critical
52
+ }
53
+ return { ok: true };
54
+ });
55
+ // ── Frontend funnel event recorder ───────────────────────────────────────
56
+ // Tracks conversion funnel: free_limit_hit → upgrade_clicked → checkout_started → checkout_completed
57
+ app.post('/api/analytics/event', async (req) => {
58
+ const body = req.body;
59
+ const event = (body?.event ?? '').trim();
60
+ if (!ALLOWED_FUNNEL_EVENTS.has(event)) {
61
+ return { ok: false, error: 'Unknown event type' };
62
+ }
63
+ try {
64
+ const db = getDb();
65
+ const ipHash = hashIp(req.ip);
66
+ const email = body?.email ? String(body.email).slice(0, 255).toLowerCase() : null;
67
+ const tool = body?.tool ? String(body.tool).slice(0, 64) : null;
68
+ const metadata = body?.metadata ? JSON.stringify(body.metadata).slice(0, 1000) : null;
69
+ db.prepare(`
70
+ INSERT INTO funnel_events (event, ip_hash, tool, email, metadata)
71
+ VALUES (?, ?, ?, ?, ?)
72
+ `).run(event, ipHash, tool, email, metadata);
73
+ }
74
+ catch {
75
+ // silently accept — never fail the frontend
76
+ }
77
+ return { ok: true };
78
+ });
79
+ // ── Analytics summary ─────────────────────────────────────────────────────
80
+ // Aggregated metrics for the operator dashboard.
81
+ // Returns: funnel, cli commands, tool usage, conversions.
82
+ app.get('/api/analytics/summary', async (_req, reply) => {
83
+ try {
84
+ const db = getDb();
85
+ const since30 = thirtyDaysAgo();
86
+ const since7 = sevenDaysAgo();
87
+ const today = todayUTC();
88
+ // Funnel: last 30 days
89
+ const funnelRows = db.prepare(`
90
+ SELECT event, COUNT(*) as count
91
+ FROM funnel_events
92
+ WHERE date(created_at) >= ?
93
+ GROUP BY event
94
+ `).all(since30);
95
+ const funnel = {};
96
+ for (const r of funnelRows)
97
+ funnel[r.event] = r.count;
98
+ // Conversion rate: upgrade_clicked → checkout_completed
99
+ const upgradeClicked = funnel['upgrade_clicked'] ?? 0;
100
+ const checkoutCompleted = funnel['checkout_completed'] ?? 0;
101
+ const conversionRate = upgradeClicked > 0
102
+ ? Math.round((checkoutCompleted / upgradeClicked) * 100)
103
+ : 0;
104
+ // CLI: unique installs, commands, success rate (last 30 days)
105
+ const cliStats = db.prepare(`
106
+ SELECT
107
+ COUNT(DISTINCT install_id) as unique_installs,
108
+ COUNT(*) as total_commands,
109
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful,
110
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed,
111
+ AVG(duration_ms) as avg_duration_ms
112
+ FROM cli_telemetry
113
+ WHERE date(created_at) >= ?
114
+ `).get(since30);
115
+ // CLI: commands by type
116
+ const commandBreakdown = db.prepare(`
117
+ SELECT command, COUNT(*) as count, AVG(duration_ms) as avg_ms
118
+ FROM cli_telemetry
119
+ WHERE date(created_at) >= ? AND command IS NOT NULL
120
+ GROUP BY command
121
+ ORDER BY count DESC
122
+ `).all(since30);
123
+ // Tool usage from usage_tracking (web dashboard tools)
124
+ const toolUsage7d = db.prepare(`
125
+ SELECT endpoint, SUM(count) as total
126
+ FROM usage_tracking
127
+ WHERE date >= ?
128
+ GROUP BY endpoint
129
+ ORDER BY total DESC
130
+ `).all(since7);
131
+ const toolUsage30d = db.prepare(`
132
+ SELECT endpoint, SUM(count) as total
133
+ FROM usage_tracking
134
+ WHERE date >= ?
135
+ GROUP BY endpoint
136
+ ORDER BY total DESC
137
+ `).all(since30);
138
+ // Daily active unique IPs (last 7 days, web tools)
139
+ const dau7d = db.prepare(`
140
+ SELECT date, COUNT(DISTINCT ip_hash) as unique_ips, SUM(count) as requests
141
+ FROM usage_tracking
142
+ WHERE date >= ?
143
+ GROUP BY date
144
+ ORDER BY date DESC
145
+ `).all(since7);
146
+ // Today's usage
147
+ const todayUsage = db.prepare(`
148
+ SELECT SUM(count) as total, COUNT(DISTINCT ip_hash) as unique_ips
149
+ FROM usage_tracking
150
+ WHERE date = ?
151
+ `).get(today);
152
+ // Email captures (leads)
153
+ const emailCaptures = db.prepare(`
154
+ SELECT COUNT(*) as total, COUNT(DISTINCT email) as unique_emails
155
+ FROM email_captures
156
+ WHERE date(created_at) >= ?
157
+ `).get(since30);
158
+ return {
159
+ period: { from: since30, to: today },
160
+ funnel: {
161
+ events: funnel,
162
+ conversion_rate_pct: conversionRate,
163
+ free_limit_hits_30d: funnel['free_limit_hit'] ?? 0,
164
+ upgrade_clicks_30d: upgradeClicked,
165
+ checkout_completions_30d: checkoutCompleted,
166
+ },
167
+ cli: {
168
+ unique_installs_30d: cliStats?.unique_installs ?? 0,
169
+ total_commands_30d: cliStats?.total_commands ?? 0,
170
+ success_rate_pct: cliStats?.total_commands > 0
171
+ ? Math.round(((cliStats.successful ?? 0) / cliStats.total_commands) * 100)
172
+ : 0,
173
+ avg_duration_ms: cliStats?.avg_duration_ms ? Math.round(cliStats.avg_duration_ms) : null,
174
+ command_breakdown: commandBreakdown,
175
+ },
176
+ web: {
177
+ tool_usage_7d: toolUsage7d,
178
+ tool_usage_30d: toolUsage30d,
179
+ daily_active_users_7d: dau7d,
180
+ today: {
181
+ total_requests: todayUsage?.total ?? 0,
182
+ unique_ips: todayUsage?.unique_ips ?? 0,
183
+ },
184
+ },
185
+ leads: {
186
+ email_captures_30d: emailCaptures?.total ?? 0,
187
+ unique_emails_30d: emailCaptures?.unique_emails ?? 0,
188
+ },
189
+ generated_at: new Date().toISOString(),
190
+ };
191
+ }
192
+ catch (err) {
193
+ reply.status(500);
194
+ return { error: 'Failed to fetch analytics', detail: String(err) };
195
+ }
196
+ });
197
+ // ── npm + GitHub traffic snapshot ─────────────────────────────────────────
198
+ // Reads data/metrics.json written by scripts/metrics.js (npm + GitHub API).
199
+ // Returns the latest snapshot (last entry in the array), or a 204 if no data yet.
200
+ app.get('/api/analytics/npm-stats', async (_req, reply) => {
201
+ try {
202
+ if (!existsSync(METRICS_PATH)) {
203
+ reply.status(204);
204
+ return { message: 'No metrics snapshot yet. Run: npm run metrics' };
205
+ }
206
+ const raw = readFileSync(METRICS_PATH, 'utf8').trim();
207
+ if (!raw) {
208
+ reply.status(204);
209
+ return { message: 'Metrics file is empty.' };
210
+ }
211
+ const snapshots = JSON.parse(raw);
212
+ if (!Array.isArray(snapshots) || snapshots.length === 0) {
213
+ reply.status(204);
214
+ return { message: 'No snapshots in metrics file.' };
215
+ }
216
+ // Return latest snapshot + history summary
217
+ const latest = snapshots[snapshots.length - 1];
218
+ return {
219
+ latest,
220
+ snapshot_count: snapshots.length,
221
+ oldest_snapshot: snapshots[0].timestamp ?? null,
222
+ newest_snapshot: latest.timestamp ?? null,
223
+ };
224
+ }
225
+ catch (err) {
226
+ reply.status(500);
227
+ return { error: 'Failed to read metrics snapshot', detail: String(err) };
228
+ }
229
+ });
230
+ // ── Recent funnel events (for debugging/operator view) ────────────────────
231
+ app.get('/api/analytics/funnel', async (_req, reply) => {
232
+ try {
233
+ const db = getDb();
234
+ const rows = db.prepare(`
235
+ SELECT event, tool, date(created_at) as date, COUNT(*) as count
236
+ FROM funnel_events
237
+ WHERE date(created_at) >= ?
238
+ GROUP BY event, tool, date(created_at)
239
+ ORDER BY date(created_at) DESC, event
240
+ `).all(thirtyDaysAgo());
241
+ return { funnel_by_day: rows };
242
+ }
243
+ catch (err) {
244
+ reply.status(500);
245
+ return { error: String(err) };
246
+ }
247
+ });
248
+ // ── npm download stats (proxied from npm registry) ────────────────────────
249
+ // Fetches public download counts for the content-grade npm package.
250
+ // Cached server-side for 1 hour to avoid hammering the npm API.
251
+ let _npmCache = null;
252
+ const NPM_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
253
+ app.get('/api/analytics/npm-downloads', async (_req, reply) => {
254
+ try {
255
+ const now = Date.now();
256
+ if (_npmCache && (now - _npmCache.fetchedAt) < NPM_CACHE_TTL_MS) {
257
+ return _npmCache.data;
258
+ }
259
+ const [lastWeek, lastMonth, lastYear] = await Promise.all([
260
+ fetch('https://api.npmjs.org/downloads/point/last-week/content-grade').then(r => r.json()).catch(() => null),
261
+ fetch('https://api.npmjs.org/downloads/point/last-month/content-grade').then(r => r.json()).catch(() => null),
262
+ fetch('https://api.npmjs.org/downloads/point/last-year/content-grade').then(r => r.json()).catch(() => null),
263
+ ]);
264
+ // Daily breakdown for the last 30 days
265
+ const daily = await fetch('https://api.npmjs.org/downloads/range/last-month/content-grade')
266
+ .then(r => r.json())
267
+ .catch(() => null);
268
+ const result = {
269
+ last_week: lastWeek?.downloads ?? null,
270
+ last_month: lastMonth?.downloads ?? null,
271
+ last_year: lastYear?.downloads ?? null,
272
+ daily_30d: daily?.downloads ?? [],
273
+ fetched_at: new Date().toISOString(),
274
+ };
275
+ _npmCache = { data: result, fetchedAt: now };
276
+ return result;
277
+ }
278
+ catch (err) {
279
+ reply.status(500);
280
+ return { error: 'Failed to fetch npm stats', detail: String(err) };
281
+ }
282
+ });
283
+ }
@@ -8,7 +8,7 @@ import { hasActiveSubscriptionLive, hasPurchased } from '../services/stripe.js';
8
8
  const FREE_TIER_LIMIT = 50;
9
9
  const PRO_TIER_LIMIT = 100;
10
10
  const HEADLINE_GRADER_ENDPOINT = 'headline-grader';
11
- const UPGRADE_URL = 'https://contentgrade.ai/#pricing';
11
+ const UPGRADE_URL = 'https://content-grade.github.io/Content-Grade/';
12
12
  function freeGateMsg(what) {
13
13
  return `Free daily limit reached (${FREE_TIER_LIMIT}/day). ${what} — upgrade to Pro at ${UPGRADE_URL}`;
14
14
  }
@@ -42,6 +42,13 @@ function resetUsage(ipHash, endpoint) {
42
42
  // Pro users (active subscription) get PRO_TIER_LIMIT instead of FREE_TIER_LIMIT.
43
43
  // AudienceDecoder passes productKey='audiencedecoder_report' to check one-time purchase.
44
44
  // Verifies subscription status against Stripe API directly (5-min cache) instead of a stale DB flag.
45
+ function recordFunnelEvent(event, ipHash, tool, email) {
46
+ try {
47
+ const db = getDb();
48
+ db.prepare('INSERT INTO funnel_events (event, ip_hash, tool, email) VALUES (?, ?, ?, ?)').run(event, ipHash, tool ?? null, email ?? null);
49
+ }
50
+ catch { /* non-critical */ }
51
+ }
45
52
  async function checkRateLimit(ipHash, endpoint, email, productKey) {
46
53
  if (email) {
47
54
  const isPro = productKey
@@ -57,6 +64,7 @@ async function checkRateLimit(ipHash, endpoint, email, productKey) {
57
64
  }
58
65
  const count = getUsageCount(ipHash, endpoint);
59
66
  if (count >= FREE_TIER_LIMIT) {
67
+ recordFunnelEvent('free_limit_hit', ipHash, endpoint, email);
60
68
  return { allowed: false, remaining: 0, limit: FREE_TIER_LIMIT, isPro: false };
61
69
  }
62
70
  return { allowed: true, remaining: FREE_TIER_LIMIT - count, limit: FREE_TIER_LIMIT, isPro: false };
@@ -0,0 +1,53 @@
1
+ import { validateLicenseKey, getLicenseKeysForEmail } from '../services/license.js';
2
+ import { hasActiveSubscription } from '../services/stripe.js';
3
+ // Simple in-memory rate limiter: max 10 calls per IP per minute
4
+ const _validateRateLimiter = new Map();
5
+ function checkRateLimit(ip) {
6
+ const now = Date.now();
7
+ const entry = _validateRateLimiter.get(ip);
8
+ if (!entry || entry.resetAt < now) {
9
+ _validateRateLimiter.set(ip, { count: 1, resetAt: now + 60_000 });
10
+ return true;
11
+ }
12
+ if (entry.count >= 10)
13
+ return false;
14
+ entry.count++;
15
+ return true;
16
+ }
17
+ export function registerLicenseRoutes(app) {
18
+ app.get('/api/license/my-keys', async (req, reply) => {
19
+ const query = req.query;
20
+ const email = (query.email ?? '').trim().toLowerCase();
21
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
22
+ reply.status(400);
23
+ return { error: 'Valid email required' };
24
+ }
25
+ const keys = getLicenseKeysForEmail(email);
26
+ return { keys };
27
+ });
28
+ app.post('/api/license/validate', async (req, reply) => {
29
+ const ip = req.ip ?? 'unknown';
30
+ if (!checkRateLimit(ip)) {
31
+ reply.status(429);
32
+ return { valid: false, message: 'Too many requests. Try again in a minute.' };
33
+ }
34
+ const body = req.body;
35
+ const key = (body?.key ?? '').trim();
36
+ if (!key) {
37
+ reply.status(400);
38
+ return { valid: false, message: 'key required' };
39
+ }
40
+ const result = validateLicenseKey(key);
41
+ if (!result.valid) {
42
+ return { valid: false, message: 'Invalid or expired license key.' };
43
+ }
44
+ // Also verify Stripe subscription is still active for subscription-based keys
45
+ if (result.productKey === 'contentgrade_pro' && result.email) {
46
+ const subActive = hasActiveSubscription(result.email);
47
+ if (!subActive) {
48
+ return { valid: false, message: 'Subscription is no longer active. Please renew at content-grade.github.io/Content-Grade.' };
49
+ }
50
+ }
51
+ return { valid: true, email: result.email, productKey: result.productKey };
52
+ });
53
+ }
@@ -1,4 +1,5 @@
1
1
  import { getStripe, isStripeConfigured, isAudienceDecoderConfigured, hasActiveSubscription, hasPurchased, getCustomerStripeId, upsertCustomer, saveSubscription, updateSubscriptionStatus, updateSubscriptionPeriod, saveOneTimePurchase, } from '../services/stripe.js';
2
+ import { upsertLicenseKey, getLicenseKeysForEmail, validateLicenseKey } from '../services/license.js';
2
3
  export function registerStripeRoutes(app) {
3
4
  app.post('/api/stripe/create-checkout-session', async (req, reply) => {
4
5
  const body = req.body;
@@ -91,6 +92,9 @@ export function registerStripeRoutes(app) {
91
92
  status: 'active',
92
93
  current_period_end: 0,
93
94
  });
95
+ // Generate and store license key for CLI pro access
96
+ const licenseKey = upsertLicenseKey(email, stripeCustomerId, 'contentgrade_pro');
97
+ console.log(`[stripe] License key issued for ${email}: ${licenseKey.slice(0, 10)}...`);
94
98
  }
95
99
  else if (data.mode === 'payment' && email && stripeCustomerId) {
96
100
  saveOneTimePurchase({
@@ -118,6 +122,68 @@ export function registerStripeRoutes(app) {
118
122
  }
119
123
  return { received: true };
120
124
  });
125
+ app.post('/api/stripe/billing-portal', async (req, reply) => {
126
+ const body = req.body;
127
+ const email = (body?.email ?? '').trim().toLowerCase();
128
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
129
+ reply.status(400);
130
+ return { error: 'Valid email required.' };
131
+ }
132
+ const stripe = getStripe();
133
+ if (!stripe) {
134
+ reply.status(503);
135
+ return { error: 'Stripe not configured' };
136
+ }
137
+ const customerId = getCustomerStripeId(email);
138
+ if (!customerId) {
139
+ reply.status(404);
140
+ return { error: 'No Stripe customer found for this email. Please use the email you used to subscribe.' };
141
+ }
142
+ try {
143
+ const publicUrl = process.env.PUBLIC_URL || 'http://localhost:4000';
144
+ const session = await stripe.billingPortal.sessions.create({
145
+ customer: customerId,
146
+ return_url: body?.returnUrl ?? publicUrl,
147
+ });
148
+ return { url: session.url };
149
+ }
150
+ catch (err) {
151
+ reply.status(500);
152
+ return { error: `Billing portal failed: ${err.message}` };
153
+ }
154
+ });
155
+ // Returns the license key for a Pro subscriber — used by CLI activate flow
156
+ app.get('/api/stripe/license-key', async (req, reply) => {
157
+ const query = req.query;
158
+ const email = (query.email ?? '').trim().toLowerCase();
159
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
160
+ reply.status(400);
161
+ return { error: 'Valid email required.' };
162
+ }
163
+ if (!hasActiveSubscription(email)) {
164
+ reply.status(403);
165
+ return { error: 'No active Content-Grade Pro subscription found for this email.' };
166
+ }
167
+ // Retroactively generate a key for subscribers who paid before this feature existed
168
+ const customerId = getCustomerStripeId(email);
169
+ const licenseKey = upsertLicenseKey(email, customerId ?? undefined, 'contentgrade_pro');
170
+ return { licenseKey, email };
171
+ });
172
+ // Validates a license key — used by CLI to confirm key is genuine
173
+ app.post('/api/stripe/validate-license', async (req, reply) => {
174
+ const body = req.body;
175
+ const key = (body?.key ?? '').trim();
176
+ if (!key) {
177
+ reply.status(400);
178
+ return { error: 'key required' };
179
+ }
180
+ const result = validateLicenseKey(key);
181
+ if (!result.valid) {
182
+ reply.status(403);
183
+ return { valid: false, error: 'Invalid or revoked license key.' };
184
+ }
185
+ return { valid: true, email: result.email, productKey: result.productKey };
186
+ });
121
187
  app.get('/api/stripe/subscription-status', async (req, reply) => {
122
188
  const query = req.query;
123
189
  const email = (query.email ?? '').trim().toLowerCase();
@@ -125,10 +191,13 @@ export function registerStripeRoutes(app) {
125
191
  reply.status(400);
126
192
  return { error: 'email required' };
127
193
  }
194
+ const keys = getLicenseKeysForEmail(email);
195
+ const activeLicenseKey = keys.find(k => k.status === 'active')?.key ?? null;
128
196
  return {
129
197
  active: hasActiveSubscription(email),
130
198
  audiencedecoder: hasPurchased(email, 'audiencedecoder_report'),
131
199
  plan: hasActiveSubscription(email) ? 'contentgrade_pro' : null,
200
+ licenseKey: activeLicenseKey,
132
201
  configured: isStripeConfigured(),
133
202
  audienceDecoderConfigured: isAudienceDecoderConfigured(),
134
203
  };
@@ -0,0 +1,38 @@
1
+ import crypto from 'crypto';
2
+ import { getDb } from '../db.js';
3
+ export function generateLicenseKey() {
4
+ const bytes = crypto.randomBytes(8);
5
+ const hex = bytes.toString('hex').toUpperCase();
6
+ return `CG-${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
7
+ }
8
+ export function upsertLicenseKey(email, stripeCustomerId, productKey = 'contentgrade_pro') {
9
+ const db = getDb();
10
+ const existing = db.prepare(`SELECT key FROM license_keys WHERE email = ? AND product_key = ? AND status = 'active' LIMIT 1`).get(email, productKey);
11
+ if (existing)
12
+ return existing.key;
13
+ const key = generateLicenseKey();
14
+ db.prepare(`
15
+ INSERT INTO license_keys (key, email, stripe_customer_id, product_key, status)
16
+ VALUES (?, ?, ?, ?, 'active')
17
+ `).run(key, email, stripeCustomerId ?? null, productKey);
18
+ return key;
19
+ }
20
+ export function validateLicenseKey(key) {
21
+ const db = getDb();
22
+ const row = db.prepare(`SELECT key, email, product_key, status FROM license_keys WHERE key = ? LIMIT 1`).get(key);
23
+ if (!row)
24
+ return { valid: false };
25
+ db.prepare(`
26
+ UPDATE license_keys SET last_validated_at = CURRENT_TIMESTAMP, validation_count = validation_count + 1 WHERE key = ?
27
+ `).run(key);
28
+ if (row.status !== 'active')
29
+ return { valid: false, status: row.status };
30
+ return { valid: true, email: row.email, productKey: row.product_key, status: row.status };
31
+ }
32
+ export function getLicenseKeysForEmail(email) {
33
+ const rows = getDb().prepare(`SELECT key, product_key, status, created_at FROM license_keys WHERE email = ? ORDER BY created_at DESC`).all(email);
34
+ return rows.map(r => ({ key: r.key, productKey: r.product_key, status: r.status, createdAt: r.created_at }));
35
+ }
36
+ export function revokeLicenseKey(key) {
37
+ getDb().prepare(`UPDATE license_keys SET status = 'revoked' WHERE key = ?`).run(key);
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-grade",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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": {
@@ -58,10 +58,10 @@
58
58
  "github-traffic": "node scripts/github-traffic.js"
59
59
  },
60
60
  "dependencies": {
61
- "@fastify/cors": "^9.0.1",
62
- "@fastify/static": "^7.0.4",
61
+ "@fastify/cors": "^11.2.0",
62
+ "@fastify/static": "^9.0.0",
63
63
  "better-sqlite3": "^9.4.3",
64
- "fastify": "^4.28.1",
64
+ "fastify": "^5.8.2",
65
65
  "stripe": "^14.21.0"
66
66
  },
67
67
  "pnpm": {
@@ -76,14 +76,14 @@
76
76
  "@types/react": "^18.3.0",
77
77
  "@types/react-dom": "^18.3.0",
78
78
  "@vitejs/plugin-react": "^4.3.0",
79
- "@vitest/coverage-v8": "^2.1.0",
79
+ "@vitest/coverage-v8": "^3.2.4",
80
80
  "concurrently": "^8.2.2",
81
81
  "react": "^18.3.0",
82
82
  "react-dom": "^18.3.0",
83
83
  "react-router-dom": "^6.23.0",
84
84
  "tsx": "^4.16.0",
85
85
  "typescript": "^5.5.0",
86
- "vite": "^5.4.0",
87
- "vitest": "^2.1.0"
86
+ "vite": "^6.4.1",
87
+ "vitest": "^3.2.4"
88
88
  }
89
89
  }