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/README.md +87 -20
- package/bin/content-grade.js +326 -63
- package/bin/telemetry.js +4 -2
- package/dist/assets/index-Bc3ZrBgH.js +78 -0
- package/dist/index.html +1 -1
- package/dist-server/server/app.js +4 -0
- package/dist-server/server/db.js +46 -0
- package/dist-server/server/routes/analytics.js +283 -0
- package/dist-server/server/routes/demos.js +9 -1
- package/dist-server/server/routes/license.js +53 -0
- package/dist-server/server/routes/stripe.js +69 -0
- package/dist-server/server/services/license.js +38 -0
- package/package.json +7 -7
- package/dist/assets/index-BUN69TiT.js +0 -78
package/dist/index.html
CHANGED
|
@@ -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(),
|
package/dist-server/server/db.js
CHANGED
|
@@ -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://
|
|
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.
|
|
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": "^
|
|
62
|
-
"@fastify/static": "^
|
|
61
|
+
"@fastify/cors": "^11.2.0",
|
|
62
|
+
"@fastify/static": "^9.0.0",
|
|
63
63
|
"better-sqlite3": "^9.4.3",
|
|
64
|
-
"fastify": "^
|
|
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.
|
|
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": "^
|
|
87
|
-
"vitest": "^2.
|
|
86
|
+
"vite": "^6.4.1",
|
|
87
|
+
"vitest": "^3.2.4"
|
|
88
88
|
}
|
|
89
89
|
}
|