content-grade 1.0.0

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.
@@ -0,0 +1,136 @@
1
+ import { getStripe, isStripeConfigured, isAudienceDecoderConfigured, hasActiveSubscription, hasPurchased, getCustomerStripeId, upsertCustomer, saveSubscription, updateSubscriptionStatus, updateSubscriptionPeriod, saveOneTimePurchase, } from '../services/stripe.js';
2
+ export function registerStripeRoutes(app) {
3
+ app.post('/api/stripe/create-checkout-session', async (req, reply) => {
4
+ const body = req.body;
5
+ const email = (body?.email ?? '').trim().toLowerCase();
6
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
7
+ reply.status(400);
8
+ return { error: 'Valid email required.' };
9
+ }
10
+ const priceType = body?.priceType ?? 'contentgrade_pro';
11
+ const stripe = getStripe();
12
+ if (!stripe) {
13
+ reply.status(503);
14
+ return { error: 'Stripe not configured' };
15
+ }
16
+ let priceId;
17
+ let mode;
18
+ if (priceType === 'contentgrade_pro') {
19
+ priceId = process.env.STRIPE_PRICE_CONTENTGRADE_PRO;
20
+ mode = 'subscription';
21
+ }
22
+ else {
23
+ priceId = process.env.STRIPE_PRICE_AUDIENCEDECODER;
24
+ mode = 'payment';
25
+ }
26
+ if (!priceId) {
27
+ reply.status(503);
28
+ return { error: 'Stripe not configured — price ID missing' };
29
+ }
30
+ try {
31
+ let stripeCustomerId = getCustomerStripeId(email);
32
+ if (!stripeCustomerId) {
33
+ const stripeCustomer = await stripe.customers.create({ email });
34
+ stripeCustomerId = stripeCustomer.id;
35
+ upsertCustomer(email, stripeCustomerId);
36
+ }
37
+ const publicUrl = process.env.PUBLIC_URL || 'http://localhost:4000';
38
+ const session = await stripe.checkout.sessions.create({
39
+ mode,
40
+ customer: stripeCustomerId,
41
+ client_reference_id: email,
42
+ line_items: [{ price: priceId, quantity: 1 }],
43
+ success_url: body?.successUrl ?? `${publicUrl}?checkout=success`,
44
+ cancel_url: body?.cancelUrl ?? `${publicUrl}?checkout=cancel`,
45
+ });
46
+ return { url: session.url };
47
+ }
48
+ catch (err) {
49
+ reply.status(500);
50
+ return { error: `Checkout failed: ${err.message}` };
51
+ }
52
+ });
53
+ app.post('/api/stripe/webhook', async (req, reply) => {
54
+ const stripe = getStripe();
55
+ if (!stripe) {
56
+ reply.status(503);
57
+ return { error: 'Stripe not configured' };
58
+ }
59
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
60
+ const rawBody = req.rawBody;
61
+ const sig = req.headers['stripe-signature'];
62
+ let event;
63
+ try {
64
+ if (webhookSecret && rawBody && sig) {
65
+ event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
66
+ }
67
+ else {
68
+ if (!webhookSecret)
69
+ console.warn('[Stripe] STRIPE_WEBHOOK_SECRET not set — skipping signature verification (dev mode)');
70
+ event = req.body;
71
+ }
72
+ }
73
+ catch (err) {
74
+ reply.status(400);
75
+ return { error: `Webhook signature failed: ${err.message}` };
76
+ }
77
+ try {
78
+ const data = event.data?.object;
79
+ if (event.type === 'checkout.session.completed') {
80
+ const email = (data.client_reference_id ?? data.customer_email ?? '').toLowerCase();
81
+ const stripeCustomerId = data.customer;
82
+ if (email && stripeCustomerId) {
83
+ upsertCustomer(email, stripeCustomerId);
84
+ }
85
+ if (data.mode === 'subscription' && email && stripeCustomerId) {
86
+ saveSubscription({
87
+ email,
88
+ stripe_customer_id: stripeCustomerId,
89
+ stripe_subscription_id: data.subscription,
90
+ plan_tier: 'pro',
91
+ status: 'active',
92
+ current_period_end: 0,
93
+ });
94
+ }
95
+ else if (data.mode === 'payment' && email && stripeCustomerId) {
96
+ saveOneTimePurchase({
97
+ email,
98
+ stripe_customer_id: stripeCustomerId,
99
+ stripe_payment_intent_id: data.payment_intent,
100
+ product_key: 'audiencedecoder_report',
101
+ });
102
+ }
103
+ }
104
+ else if (event.type === 'customer.subscription.updated') {
105
+ updateSubscriptionPeriod(data.id, data.status, data.current_period_end);
106
+ }
107
+ else if (event.type === 'customer.subscription.deleted') {
108
+ updateSubscriptionStatus(data.id, 'canceled');
109
+ }
110
+ else if (event.type === 'invoice.payment_failed') {
111
+ if (data.subscription) {
112
+ updateSubscriptionStatus(data.subscription, 'past_due');
113
+ }
114
+ }
115
+ }
116
+ catch (err) {
117
+ console.error('[stripe_webhook]', err);
118
+ }
119
+ return { received: true };
120
+ });
121
+ app.get('/api/stripe/subscription-status', async (req, reply) => {
122
+ const query = req.query;
123
+ const email = (query.email ?? '').trim().toLowerCase();
124
+ if (!email) {
125
+ reply.status(400);
126
+ return { error: 'email required' };
127
+ }
128
+ return {
129
+ active: hasActiveSubscription(email),
130
+ audiencedecoder: hasPurchased(email, 'audiencedecoder_report'),
131
+ plan: hasActiveSubscription(email) ? 'contentgrade_pro' : null,
132
+ configured: isStripeConfigured(),
133
+ audienceDecoderConfigured: isAudienceDecoderConfigured(),
134
+ };
135
+ });
136
+ }
@@ -0,0 +1,55 @@
1
+ import { spawn } from 'child_process';
2
+ const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
3
+ const DEFAULT_TIMEOUT_MS = 120_000;
4
+ /**
5
+ * askClaude — runs a Claude CLI session for demo endpoints.
6
+ * Uses `claude -p --no-session-persistence --dangerously-skip-permissions`
7
+ * The prompt is written to stdin; system prompt via --append-system-prompt.
8
+ */
9
+ export async function askClaude(prompt, opts) {
10
+ const model = opts?.model ?? 'haiku';
11
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12
+ const args = ['-p', '--no-session-persistence', '--dangerously-skip-permissions'];
13
+ if (opts?.systemPrompt) {
14
+ args.push('--append-system-prompt', opts.systemPrompt);
15
+ }
16
+ args.push('--model', model);
17
+ return new Promise((resolve, reject) => {
18
+ const proc = spawn(CLAUDE_PATH, args, {
19
+ env: {
20
+ HOME: process.env.HOME,
21
+ PATH: process.env.PATH,
22
+ USER: process.env.USER,
23
+ SHELL: process.env.SHELL,
24
+ TERM: process.env.TERM,
25
+ LANG: process.env.LANG,
26
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
27
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME,
28
+ XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
29
+ },
30
+ });
31
+ let stdout = '';
32
+ let stderr = '';
33
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
34
+ proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
35
+ const timer = setTimeout(() => {
36
+ proc.kill('SIGTERM');
37
+ reject(new Error(`Claude session timed out after ${timeoutMs}ms`));
38
+ }, timeoutMs);
39
+ proc.on('close', (code) => {
40
+ clearTimeout(timer);
41
+ if (code !== 0) {
42
+ reject(new Error(`Claude exited with code ${code}: ${stderr.slice(0, 500)}`));
43
+ }
44
+ else {
45
+ resolve(stdout.trim());
46
+ }
47
+ });
48
+ proc.on('error', (err) => {
49
+ clearTimeout(timer);
50
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is 'claude' CLI installed and authenticated?`));
51
+ });
52
+ proc.stdin.write(prompt);
53
+ proc.stdin.end();
54
+ });
55
+ }
@@ -0,0 +1,109 @@
1
+ import Stripe from 'stripe';
2
+ import { getDb } from '../db.js';
3
+ const stripeKey = process.env.STRIPE_SECRET_KEY;
4
+ let _stripe = null;
5
+ export function getStripe() {
6
+ if (!_stripe && stripeKey) {
7
+ _stripe = new Stripe(stripeKey, { apiVersion: '2024-12-18.acacia' });
8
+ }
9
+ return _stripe;
10
+ }
11
+ export function isStripeConfigured() {
12
+ return !!stripeKey && !!process.env.STRIPE_PRICE_CONTENTGRADE_PRO;
13
+ }
14
+ export function isAudienceDecoderConfigured() {
15
+ return !!stripeKey && !!process.env.STRIPE_PRICE_AUDIENCEDECODER;
16
+ }
17
+ // ── Customer management ──────────────────────────────────────────────────────
18
+ export function upsertCustomer(email, stripeCustomerId) {
19
+ getDb().prepare(`
20
+ INSERT INTO stripe_customers (email, stripe_customer_id)
21
+ VALUES (?, ?)
22
+ ON CONFLICT(email) DO UPDATE SET stripe_customer_id = excluded.stripe_customer_id
23
+ `).run(email, stripeCustomerId);
24
+ }
25
+ export function getCustomerStripeId(email) {
26
+ const row = getDb().prepare('SELECT stripe_customer_id FROM stripe_customers WHERE email = ?').get(email);
27
+ return row?.stripe_customer_id ?? null;
28
+ }
29
+ // ── Subscriptions ────────────────────────────────────────────────────────────
30
+ export function saveSubscription(params) {
31
+ getDb().prepare(`
32
+ INSERT INTO stripe_subscriptions
33
+ (email, stripe_customer_id, stripe_subscription_id, plan_tier, status, current_period_end)
34
+ VALUES (?, ?, ?, ?, ?, ?)
35
+ ON CONFLICT(stripe_subscription_id) DO UPDATE SET
36
+ status = excluded.status,
37
+ current_period_end = excluded.current_period_end,
38
+ updated_at = datetime('now')
39
+ `).run(params.email, params.stripe_customer_id, params.stripe_subscription_id, params.plan_tier, params.status, params.current_period_end);
40
+ }
41
+ export function updateSubscriptionStatus(stripeSubId, status) {
42
+ getDb().prepare("UPDATE stripe_subscriptions SET status = ?, updated_at = datetime('now') WHERE stripe_subscription_id = ?").run(status, stripeSubId);
43
+ }
44
+ export function updateSubscriptionPeriod(stripeSubId, status, currentPeriodEnd) {
45
+ getDb().prepare("UPDATE stripe_subscriptions SET status = ?, current_period_end = ?, updated_at = datetime('now') WHERE stripe_subscription_id = ?").run(status, currentPeriodEnd, stripeSubId);
46
+ }
47
+ export function hasActiveSubscription(email) {
48
+ const row = getDb().prepare(`
49
+ SELECT status FROM stripe_subscriptions
50
+ WHERE email = ? AND status = 'active'
51
+ ORDER BY created_at DESC LIMIT 1
52
+ `).get(email);
53
+ return row?.status === 'active';
54
+ }
55
+ // ── One-time purchases ───────────────────────────────────────────────────────
56
+ export function saveOneTimePurchase(params) {
57
+ getDb().prepare(`
58
+ INSERT OR IGNORE INTO stripe_one_time_purchases
59
+ (email, stripe_customer_id, stripe_payment_intent_id, product_key)
60
+ VALUES (?, ?, ?, ?)
61
+ `).run(params.email, params.stripe_customer_id, params.stripe_payment_intent_id, params.product_key);
62
+ }
63
+ export function hasPurchased(email, productKey) {
64
+ const row = getDb().prepare(`
65
+ SELECT id FROM stripe_one_time_purchases
66
+ WHERE email = ? AND product_key = ?
67
+ LIMIT 1
68
+ `).get(email, productKey);
69
+ return !!row;
70
+ }
71
+ // ── Live subscription verification ───────────────────────────────────────────
72
+ // Checks Stripe API directly on each request. 5-minute in-memory cache per email
73
+ // prevents hammering the Stripe API. Falls back to DB if Stripe is unreachable.
74
+ const _subCache = new Map();
75
+ const SUB_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
76
+ const SUB_CACHE_ERROR_TTL_MS = 60 * 1000; // 1 minute on error
77
+ export async function hasActiveSubscriptionLive(email) {
78
+ const cached = _subCache.get(email);
79
+ if (cached && cached.expiresAt > Date.now()) {
80
+ return cached.isPro;
81
+ }
82
+ const stripe = getStripe();
83
+ if (!stripe) {
84
+ // Stripe not configured — fall back to DB without caching
85
+ return hasActiveSubscription(email);
86
+ }
87
+ const customerId = getCustomerStripeId(email);
88
+ if (!customerId) {
89
+ const result = hasActiveSubscription(email);
90
+ _subCache.set(email, { isPro: result, expiresAt: Date.now() + SUB_CACHE_TTL_MS });
91
+ return result;
92
+ }
93
+ try {
94
+ const subscriptions = await stripe.subscriptions.list({
95
+ customer: customerId,
96
+ status: 'active',
97
+ limit: 1,
98
+ });
99
+ const isPro = subscriptions.data.length > 0;
100
+ _subCache.set(email, { isPro, expiresAt: Date.now() + SUB_CACHE_TTL_MS });
101
+ return isPro;
102
+ }
103
+ catch (err) {
104
+ console.error('[stripe] Live subscription check failed, falling back to DB:', err);
105
+ const result = hasActiveSubscription(email);
106
+ _subCache.set(email, { isPro: result, expiresAt: Date.now() + SUB_CACHE_ERROR_TTL_MS });
107
+ return result;
108
+ }
109
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "content-grade",
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "content-grade": "bin/content-grade.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist",
12
+ "dist-server/server",
13
+ "README.md",
14
+ "CONTRIBUTING.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "content",
19
+ "copywriting",
20
+ "marketing",
21
+ "content-analysis",
22
+ "claude",
23
+ "ai",
24
+ "seo",
25
+ "headline",
26
+ "landing-page",
27
+ "cli"
28
+ ],
29
+ "homepage": "https://github.com/StanislavBG/Content-Grade#readme",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/StanislavBG/Content-Grade.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/StanislavBG/Content-Grade/issues"
36
+ },
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "prepublishOnly": "npm run build",
43
+ "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:client\"",
44
+ "dev:client": "vite",
45
+ "dev:server": "tsx watch server/index.ts",
46
+ "build": "vite build && tsc -p tsconfig.server.json",
47
+ "start": "node dist-server/server/index.js",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "test:coverage": "vitest run --coverage",
52
+ "stats": "node scripts/npm-stats.js",
53
+ "stats:week": "node scripts/npm-stats.js --week"
54
+ },
55
+ "dependencies": {
56
+ "@fastify/cors": "^9.0.1",
57
+ "@fastify/static": "^7.0.4",
58
+ "better-sqlite3": "^9.4.3",
59
+ "fastify": "^4.28.1",
60
+ "stripe": "^14.21.0"
61
+ },
62
+ "pnpm": {
63
+ "onlyBuiltDependencies": [
64
+ "better-sqlite3",
65
+ "esbuild"
66
+ ]
67
+ },
68
+ "devDependencies": {
69
+ "@types/better-sqlite3": "^7.6.8",
70
+ "@types/node": "^22.0.0",
71
+ "@types/react": "^18.3.0",
72
+ "@types/react-dom": "^18.3.0",
73
+ "@vitejs/plugin-react": "^4.3.0",
74
+ "@vitest/coverage-v8": "^2.1.0",
75
+ "concurrently": "^8.2.2",
76
+ "react": "^18.3.0",
77
+ "react-dom": "^18.3.0",
78
+ "react-router-dom": "^6.23.0",
79
+ "tsx": "^4.16.0",
80
+ "typescript": "^5.5.0",
81
+ "vite": "^5.4.0",
82
+ "vitest": "^2.1.0"
83
+ }
84
+ }