mirror of
https://github.com/lawrencehook/remove-youtube-suggestions.git
synced 2026-07-25 06:54:31 +00:00
Merge the monetization feature branch.
Add RYS Premium subscription system Introduce optional paid tier with sign-in, Stripe billing, and premium feature gating. Core features remain free. Includes Node.js server for auth, payments, and license management. Extension changes: - Sign-in flow with magic link email - Premium/upgrade/account modals with branded UI - Premium feature gating (60+ advanced settings) - Password lock and scheduling (premium) - Fix "hide all but first row" for YouTube's new flat homepage DOM - Hide sidebar ad panels by default - Enable "Hide all Shorts" by default Server: - Magic link auth with rate limiting - Stripe checkout, webhooks, and billing portal - JWT-based license tokens - AWS SES transactional emails (welcome, sign-in, cancellation) - Grandfathered donor support Also adds CI workflow, server test suite, and extension unit tests.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Server
|
||||
PORT=3000
|
||||
BASE_URL=https://yourserver.com
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-random-secret-here-min-32-chars
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PRICE_MONTHLY=price_...
|
||||
STRIPE_PRICE_YEARLY=price_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# AWS SES
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
tests/test-data/
|
||||
@@ -0,0 +1,72 @@
|
||||
# RYS Premium Server
|
||||
|
||||
Backend server for the premium subscription system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy env and fill in values
|
||||
cp .env.example .env
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Start production server
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/health` | GET | No | Health check |
|
||||
| `/auth/send-magic-link` | POST | No | Send magic link email |
|
||||
| `/auth/verify` | GET | No | Magic link target (HTML) |
|
||||
| `/auth/poll` | GET | No | Poll for verification status |
|
||||
| `/license/check` | GET | Yes | Check premium status |
|
||||
| `/checkout/create` | POST | Yes | Create Stripe checkout session |
|
||||
| `/checkout/success` | GET | No | Success page (HTML) |
|
||||
| `/checkout/cancel` | GET | No | Cancel page (HTML) |
|
||||
| `/billing/portal` | POST | Yes | Create billing portal session |
|
||||
| `/billing/return` | GET | No | Return page (HTML) |
|
||||
| `/webhook/stripe` | POST | No* | Stripe webhook |
|
||||
|
||||
*Authenticated via Stripe signature
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/
|
||||
├── src/
|
||||
│ ├── index.js # Entry point
|
||||
│ ├── config.js # Configuration
|
||||
│ ├── routes/ # Express routes
|
||||
│ ├── services/ # Stripe, JWT, email
|
||||
│ └── storage/ # File-based storage
|
||||
├── data/
|
||||
│ ├── grandfathered.json # Donor emails (gitignored)
|
||||
│ ├── auth-requests/ # Pending auth (auto-created)
|
||||
│ └── rate-limits/ # Rate limit counters (auto-created)
|
||||
├── tests/
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Grandfathered Users
|
||||
|
||||
Past donors get lifetime premium. Store emails in `data/grandfathered.json`:
|
||||
|
||||
```json
|
||||
["donor1@example.com", "donor2@example.com"]
|
||||
```
|
||||
|
||||
This file is gitignored. See `data.example/` for format.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See [DEPLOYMENT.md](../DEPLOYMENT.md) for complete setup, deployment, and testing instructions.
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"donor1@example.com",
|
||||
"donor2@example.com"
|
||||
]
|
||||
Generated
+2630
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "rys-premium-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Premium subscription server for Remove YouTube Suggestions extension",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "node --test --test-concurrency=1 tests/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.500.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.1",
|
||||
"stripe": "^14.12.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^6.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Configuration constants
|
||||
// All timing values in milliseconds unless noted
|
||||
|
||||
// Use getters for values that may change in tests
|
||||
module.exports = {
|
||||
// Server
|
||||
get PORT() { return process.env.PORT || 3000; },
|
||||
get BASE_URL() { return process.env.BASE_URL || 'http://localhost:3000'; },
|
||||
|
||||
// JWT
|
||||
get JWT_SECRET() { return process.env.JWT_SECRET; },
|
||||
SESSION_TOKEN_LIFETIME_DAYS: 30,
|
||||
LICENSE_TOKEN_LIFETIME_DAYS: 3,
|
||||
GRANDFATHERED_TOKEN_LIFETIME_DAYS: 730, // 2 years
|
||||
|
||||
// Timing
|
||||
MAGIC_LINK_EXPIRY_MS: 15 * 60 * 1000, // 15 minutes
|
||||
REQUEST_ID_EXPIRY_MS: 20 * 60 * 1000, // 20 minutes
|
||||
RATE_LIMIT_WINDOW_MS: 60 * 60 * 1000, // 1 hour
|
||||
RATE_LIMIT_MAX_REQUESTS: 5, // per email per window
|
||||
|
||||
// Stripe
|
||||
get STRIPE_SECRET_KEY() { return process.env.STRIPE_SECRET_KEY; },
|
||||
get STRIPE_PRICE_MONTHLY() { return process.env.STRIPE_PRICE_MONTHLY; },
|
||||
get STRIPE_PRICE_YEARLY() { return process.env.STRIPE_PRICE_YEARLY; },
|
||||
get STRIPE_WEBHOOK_SECRET() { return process.env.STRIPE_WEBHOOK_SECRET; },
|
||||
|
||||
// AWS SES
|
||||
get AWS_REGION() { return process.env.AWS_REGION || 'us-east-1'; },
|
||||
get EMAIL_FROM() { return process.env.EMAIL_FROM; },
|
||||
|
||||
// File paths
|
||||
get DATA_DIR() { return process.env.DATA_DIR || './data'; },
|
||||
AUTH_REQUESTS_DIR: 'auth-requests',
|
||||
RATE_LIMITS_DIR: 'rate-limits',
|
||||
GRANDFATHERED_FILE: 'grandfathered.txt',
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
require('dotenv').config();
|
||||
|
||||
// Add timestamps to all console output
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const timestamp = () => new Date().toISOString();
|
||||
console.log = (...args) => originalLog(timestamp(), ...args);
|
||||
console.error = (...args) => originalError(timestamp(), ...args);
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const config = require('./config');
|
||||
const storage = require('./storage');
|
||||
|
||||
// Route imports
|
||||
const authRoutes = require('./routes/auth');
|
||||
const licenseRoutes = require('./routes/license');
|
||||
const checkoutRoutes = require('./routes/checkout');
|
||||
const billingRoutes = require('./routes/billing');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
|
||||
const app = express();
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
// Allow requests with no origin (like mobile apps or curl)
|
||||
if (!origin) return true;
|
||||
|
||||
// Allow Chrome and Firefox extension origins
|
||||
if (origin.startsWith('chrome-extension://') ||
|
||||
origin.startsWith('moz-extension://')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow same-origin requests (for testing)
|
||||
try {
|
||||
const { hostname } = new URL(origin);
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// CORS configuration for extension origins
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (isAllowedOrigin(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Stripe webhook needs raw body - must be before express.json()
|
||||
app.use('/webhook/stripe', express.raw({ type: 'application/json' }));
|
||||
|
||||
// JSON parsing for all other routes
|
||||
app.use(express.json());
|
||||
|
||||
// Request logging
|
||||
app.use(morgan(':method :url :status :response-time ms', {
|
||||
stream: { write: msg => console.log(msg.trimEnd()) },
|
||||
}));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Mount routes
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/license', licenseRoutes);
|
||||
app.use('/checkout', checkoutRoutes);
|
||||
app.use('/billing', billingRoutes);
|
||||
app.use('/webhook', webhookRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.message === 'Not allowed by CORS') {
|
||||
return res.status(403).json({ error: 'CORS origin denied' });
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Periodic cleanup of expired data (every 5 minutes)
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
function runCleanup() {
|
||||
try {
|
||||
storage.pruneExpiredAuthRequests();
|
||||
storage.pruneExpiredRateLimits();
|
||||
} catch (err) {
|
||||
console.error('Cleanup error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
function start() {
|
||||
// Validate required config
|
||||
const required = [
|
||||
'JWT_SECRET',
|
||||
'STRIPE_SECRET_KEY',
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
'STRIPE_PRICE_MONTHLY',
|
||||
'STRIPE_PRICE_YEARLY',
|
||||
'EMAIL_FROM',
|
||||
];
|
||||
const missing = required.filter(key => !config[key]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
console.error('See .env.example for required configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate JWT_SECRET strength
|
||||
if (config.JWT_SECRET.length < 32) {
|
||||
console.error('JWT_SECRET must be at least 32 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure data directories exist
|
||||
storage.ensureDirectories();
|
||||
|
||||
// Start cleanup interval
|
||||
setInterval(runCleanup, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Start listening
|
||||
const grandfathered = storage.readGrandfatheredEmails();
|
||||
console.log(`Loaded ${grandfathered.size} grandfathered emails`);
|
||||
|
||||
app.listen(config.PORT, () => {
|
||||
console.log(`RYS Premium Server running on port ${config.PORT}`);
|
||||
console.log(`Base URL: ${config.BASE_URL}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
module.exports = { app, start };
|
||||
|
||||
// Start server if run directly
|
||||
if (require.main === module) {
|
||||
start();
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const config = require('../config');
|
||||
const storage = require('../storage');
|
||||
const { sendMagicLinkEmail } = require('../services/email');
|
||||
const { generateSessionToken } = require('../services/jwt');
|
||||
const { checkPremiumStatus } = require('../services/stripe');
|
||||
const { renderPage } = require('../templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Simple in-memory rate limiter for poll endpoint (by IP)
|
||||
const pollRateLimits = new Map();
|
||||
const POLL_RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const POLL_RATE_LIMIT_MAX = 60; // 60 requests per minute per IP
|
||||
|
||||
function checkPollRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const record = pollRateLimits.get(ip);
|
||||
|
||||
if (!record || now > record.resetTime) {
|
||||
pollRateLimits.set(ip, { count: 1, resetTime: now + POLL_RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (record.count >= POLL_RATE_LIMIT_MAX) {
|
||||
return { allowed: false, retryAfter: Math.ceil((record.resetTime - now) / 1000) };
|
||||
}
|
||||
|
||||
record.count++;
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Clean up old entries periodically (unref so it doesn't prevent process exit)
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, record] of pollRateLimits) {
|
||||
if (now > record.resetTime) {
|
||||
pollRateLimits.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
cleanupInterval.unref();
|
||||
|
||||
// Simple email validation
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// POST /auth/send-magic-link
|
||||
router.post('/send-magic-link', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || !isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimit = storage.checkRateLimit(email);
|
||||
if (!rateLimit.allowed) {
|
||||
const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
|
||||
res.set('Retry-After', retryAfter);
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate request ID and create auth request
|
||||
const requestId = uuidv4();
|
||||
storage.createAuthRequest(requestId, email);
|
||||
|
||||
// Send magic link email
|
||||
const magicLinkUrl = `${config.BASE_URL}/auth/verify?token=${requestId}`;
|
||||
try {
|
||||
const status = await checkPremiumStatus(email);
|
||||
await sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium });
|
||||
} catch (err) {
|
||||
storage.deleteAuthRequest(requestId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.log(`[auth] Magic link sent to ${email}`);
|
||||
res.json({ request_id: requestId });
|
||||
} catch (err) {
|
||||
console.error('Error sending magic link:', err);
|
||||
res.status(500).json({ error: 'Failed to send magic link' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /auth/verify - Magic link target (returns HTML)
|
||||
router.get('/verify', async (req, res) => {
|
||||
const { token: requestId } = req.query;
|
||||
|
||||
if (!requestId) {
|
||||
return res.status(400).send(renderErrorPage('Missing token'));
|
||||
}
|
||||
|
||||
const authRequest = storage.getAuthRequest(requestId);
|
||||
|
||||
if (!authRequest) {
|
||||
return res.status(404).send(renderErrorPage('Link expired or invalid. Please request a new sign-in link.'));
|
||||
}
|
||||
|
||||
// Check if magic link itself is expired (stricter than request expiry)
|
||||
if (Date.now() - authRequest.created_at > config.MAGIC_LINK_EXPIRY_MS) {
|
||||
storage.deleteAuthRequest(requestId);
|
||||
return res.status(410).send(renderErrorPage('Link expired. Please request a new sign-in link.'));
|
||||
}
|
||||
|
||||
if (authRequest.status === 'pending') {
|
||||
// Generate session token and mark as verified
|
||||
const sessionToken = generateSessionToken(authRequest.email);
|
||||
storage.updateAuthRequest(requestId, {
|
||||
status: 'verified',
|
||||
session_token: sessionToken,
|
||||
});
|
||||
storage.decrementRateLimit(authRequest.email);
|
||||
console.log(`[auth] Email verified: ${authRequest.email}`);
|
||||
}
|
||||
|
||||
res.send(renderSuccessPage());
|
||||
});
|
||||
|
||||
// GET /auth/poll - Extension polls this to check verification status
|
||||
router.get('/poll', async (req, res) => {
|
||||
// Rate limit by IP
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const rateLimit = checkPollRateLimit(ip);
|
||||
if (!rateLimit.allowed) {
|
||||
res.set('Retry-After', rateLimit.retryAfter);
|
||||
return res.status(429).json({ error: 'Too many requests', retryAfter: rateLimit.retryAfter });
|
||||
}
|
||||
|
||||
const { request_id: requestId } = req.query;
|
||||
|
||||
if (!requestId) {
|
||||
return res.status(400).json({ error: 'Missing request_id' });
|
||||
}
|
||||
|
||||
const authRequest = storage.getAuthRequest(requestId);
|
||||
|
||||
if (!authRequest) {
|
||||
return res.status(404).json({ error: 'Unknown or expired request_id' });
|
||||
}
|
||||
|
||||
if (authRequest.status === 'pending') {
|
||||
return res.json({ status: 'pending' });
|
||||
}
|
||||
|
||||
if (authRequest.status === 'verified') {
|
||||
// Return token and delete the request
|
||||
const response = {
|
||||
status: 'verified',
|
||||
session_token: authRequest.session_token,
|
||||
email: authRequest.email,
|
||||
};
|
||||
|
||||
res.once('finish', () => {
|
||||
storage.deleteAuthRequest(requestId);
|
||||
});
|
||||
|
||||
return res.json(response);
|
||||
}
|
||||
|
||||
// Unknown status
|
||||
res.status(500).json({ error: 'Unknown auth request status' });
|
||||
});
|
||||
|
||||
// HTML utilities
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, char => map[char]);
|
||||
}
|
||||
|
||||
function renderSuccessPage() {
|
||||
return renderPage({
|
||||
title: 'Signed In',
|
||||
heading: "You're signed in!",
|
||||
message: 'You can close this tab and return to the extension.',
|
||||
icon: 'check',
|
||||
});
|
||||
}
|
||||
|
||||
function renderErrorPage(message) {
|
||||
return renderPage({
|
||||
title: 'Error',
|
||||
heading: 'Something went wrong',
|
||||
message: escapeHtml(message),
|
||||
icon: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,35 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../services/jwt');
|
||||
const { createBillingPortalSession } = require('../services/stripe');
|
||||
const { renderPage } = require('../templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /billing/portal - Create Stripe billing portal session
|
||||
router.post('/portal', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const session = await createBillingPortalSession(req.userEmail);
|
||||
console.log(`[billing] Portal session created for ${req.userEmail}`);
|
||||
res.json({ url: session.url });
|
||||
} catch (err) {
|
||||
console.error('Error creating billing portal session:', err);
|
||||
|
||||
if (err.message === 'No customer found for this email') {
|
||||
return res.status(404).json({ error: 'No subscription found for this account' });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to create billing portal session' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /billing/return - Return page from billing portal
|
||||
router.get('/return', (req, res) => {
|
||||
res.send(renderPage({
|
||||
title: 'Billing Updated',
|
||||
heading: 'Billing updated',
|
||||
message: 'You can close this tab and return to the extension.',
|
||||
icon: 'check',
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,46 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../services/jwt');
|
||||
const { createCheckoutSession, createBillingPortalSession } = require('../services/stripe');
|
||||
const { renderPage } = require('../templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /checkout/create
|
||||
router.post('/create', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { plan } = req.body;
|
||||
|
||||
if (!plan || !['monthly', 'yearly'].includes(plan)) {
|
||||
return res.status(400).json({ error: 'Invalid plan. Must be "monthly" or "yearly"' });
|
||||
}
|
||||
|
||||
const session = await createCheckoutSession(req.userEmail, plan);
|
||||
|
||||
console.log(`[checkout] Session created for ${req.userEmail}, plan: ${plan}`);
|
||||
res.json({ checkout_url: session.url });
|
||||
} catch (err) {
|
||||
console.error('Error creating checkout session:', err);
|
||||
res.status(500).json({ error: 'Failed to create checkout session' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /checkout/success - Success page after payment
|
||||
router.get('/success', (req, res) => {
|
||||
res.send(renderPage({
|
||||
title: 'Payment Successful',
|
||||
heading: 'Payment successful!',
|
||||
message: 'You can close this tab and return to the extension. Your premium features are now active.',
|
||||
icon: 'check',
|
||||
}));
|
||||
});
|
||||
|
||||
// GET /checkout/cancel - Cancel page
|
||||
router.get('/cancel', (req, res) => {
|
||||
res.send(renderPage({
|
||||
title: 'Payment Canceled',
|
||||
heading: 'Payment canceled',
|
||||
message: "You can close this tab and try again from the extension whenever you're ready.",
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,42 @@
|
||||
const express = require('express');
|
||||
const { requireAuth, generateLicenseToken } = require('../services/jwt');
|
||||
const { checkPremiumStatus } = require('../services/stripe');
|
||||
const storage = require('../storage');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /license/check
|
||||
router.get('/check', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const email = req.userEmail;
|
||||
|
||||
// First check if user is grandfathered (past donor)
|
||||
const isGrandfathered = storage.isGrandfathered(email);
|
||||
if (isGrandfathered) {
|
||||
console.log(`[license] ${email} -> premium (grandfathered)`);
|
||||
const licenseToken = generateLicenseToken(email, true, true);
|
||||
return res.json({ license_token: licenseToken });
|
||||
}
|
||||
|
||||
// Check subscription cache first
|
||||
const cached = storage.getSubscriptionStatus(email);
|
||||
if (cached) {
|
||||
console.log(`[license] ${email} -> ${cached.premium ? 'premium' : 'free'} (cached)`);
|
||||
const licenseToken = generateLicenseToken(email, cached.premium, false);
|
||||
return res.json({ license_token: licenseToken });
|
||||
}
|
||||
|
||||
// Cache miss — check Stripe and populate cache
|
||||
const status = await checkPremiumStatus(email);
|
||||
storage.setSubscriptionStatus(email, status.premium, null);
|
||||
|
||||
console.log(`[license] ${email} -> ${status.premium ? 'premium' : 'free'} (stripe)`);
|
||||
const licenseToken = generateLicenseToken(email, status.premium, false);
|
||||
res.json({ license_token: licenseToken });
|
||||
} catch (err) {
|
||||
console.error('Error checking license:', err);
|
||||
res.status(500).json({ error: 'Failed to check license status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,109 @@
|
||||
const express = require('express');
|
||||
const { constructWebhookEvent, stripe } = require('../services/stripe');
|
||||
const { sendWelcomeEmail, sendCancellationEmail } = require('../services/email');
|
||||
const storage = require('../storage');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
async function getCustomerEmail(customerId) {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
return customer.email ? customer.email.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// POST /webhook/stripe
|
||||
// Note: This route needs raw body, configured in index.js
|
||||
router.post('/stripe', async (req, res) => {
|
||||
const signature = req.headers['stripe-signature'];
|
||||
|
||||
if (!signature) {
|
||||
return res.status(400).json({ error: 'Missing stripe-signature header' });
|
||||
}
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
event = constructWebhookEvent(req.body, signature);
|
||||
} catch (err) {
|
||||
console.error('[webhook] Signature verification failed:', err.message);
|
||||
return res.status(400).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
try {
|
||||
const email = session.customer_email || await getCustomerEmail(session.customer);
|
||||
if (email) {
|
||||
await sendWelcomeEmail(email);
|
||||
console.log(`[webhook] Checkout completed: ${email} (welcome email sent)`);
|
||||
} else {
|
||||
console.log(`[webhook] Checkout completed: ${session.customer} (no email found)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[webhook] Checkout completed but welcome email failed:`, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object;
|
||||
const premium = subscription.status === 'active';
|
||||
const action = event.type === 'customer.subscription.created' ? 'created' : 'updated';
|
||||
try {
|
||||
const email = await getCustomerEmail(subscription.customer);
|
||||
if (email) {
|
||||
storage.setSubscriptionStatus(email, premium, subscription.customer);
|
||||
console.log(`[webhook] Subscription ${action}: ${email} -> ${premium ? 'premium' : 'free'}`);
|
||||
|
||||
// Send cancellation email when user cancels (before period ends)
|
||||
if (subscription.cancel_at_period_end && event.data.previous_attributes?.cancel_at_period_end === false) {
|
||||
await sendCancellationEmail(email);
|
||||
console.log(`[webhook] Cancellation scheduled: ${email} (cancellation email sent)`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[webhook] Subscription ${action}: ${subscription.id} (no email found)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[webhook] Subscription ${action} cache error:`, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object;
|
||||
try {
|
||||
const email = await getCustomerEmail(subscription.customer);
|
||||
if (email) {
|
||||
storage.setSubscriptionStatus(email, false, subscription.customer);
|
||||
console.log(`[webhook] Subscription ended: ${email} -> free`);
|
||||
} else {
|
||||
console.log(`[webhook] Subscription ended: ${subscription.id} (no email found)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[webhook] Subscription ended error:', err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object;
|
||||
console.log(`[webhook] Payment failed: ${invoice.id}, customer: ${invoice.customer}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_succeeded': {
|
||||
const invoice = event.data.object;
|
||||
console.log(`[webhook] Payment succeeded: ${invoice.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`[webhook] Unhandled event: ${event.type}`);
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,195 @@
|
||||
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
|
||||
const config = require('../config');
|
||||
|
||||
const sesClient = new SESClient({ region: config.AWS_REGION });
|
||||
|
||||
async function sendMagicLinkEmail(email, magicLinkUrl, { isPremium = false } = {}) {
|
||||
const premiumBadge = isPremium
|
||||
? ' <span style="font-size: 12px; font-weight: 500; color: #0600fb; letter-spacing: 0.02em;">Premium</span>'
|
||||
: '';
|
||||
const params = {
|
||||
Source: config.EMAIL_FROM,
|
||||
Destination: {
|
||||
ToAddresses: [email],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: 'Sign in to RYS',
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Here's your sign-in link for RYS:\n\n${magicLinkUrl}\n\nLink expires in 15 minutes. If you didn't request this, just ignore it.\n\n— Lawrence`,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Html: {
|
||||
Data: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: #333; background: #f5f5f5;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #fff;">
|
||||
<div style="padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||
<a href="https://lawrencehook.com/rys/" style="text-decoration: none; font-size: 18px; font-weight: 600; color: #1a1a1a;">RYS</a>${premiumBadge}
|
||||
</div>
|
||||
<div style="padding: 32px 24px 40px;">
|
||||
<p style="font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
|
||||
Here's your sign-in link for RYS:
|
||||
</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${magicLinkUrl}"
|
||||
style="background-color: #0600FB; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">
|
||||
Sign In
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
Link expires in 15 minutes. If you didn't request this, just ignore it.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 32px;">
|
||||
— Lawrence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const command = new SendEmailCommand(params);
|
||||
return sesClient.send(command);
|
||||
}
|
||||
|
||||
async function sendWelcomeEmail(email) {
|
||||
const params = {
|
||||
Source: config.EMAIL_FROM,
|
||||
Destination: {
|
||||
ToAddresses: [email],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: 'Welcome to RYS Premium',
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Welcome to RYS Premium!\n\nThank you for subscribing. Your premium features are now active.\n\n— Lawrence`,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Html: {
|
||||
Data: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: #333; background: #f5f5f5;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #fff;">
|
||||
<div style="padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||
<a href="https://lawrencehook.com/rys/" style="text-decoration: none; font-size: 18px; font-weight: 600; color: #1a1a1a;">RYS</a> <span style="font-size: 12px; font-weight: 500; color: #0600fb; letter-spacing: 0.02em;">Premium</span>
|
||||
</div>
|
||||
<div style="padding: 32px 24px 40px;">
|
||||
<p style="font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
|
||||
Welcome to RYS Premium!
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 16px 0;">
|
||||
Thank you for subscribing. Your premium features are now active.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 32px;">
|
||||
— Lawrence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const command = new SendEmailCommand(params);
|
||||
return sesClient.send(command);
|
||||
}
|
||||
|
||||
const FEEDBACK_URL = 'https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0';
|
||||
|
||||
async function sendCancellationEmail(email) {
|
||||
const params = {
|
||||
Source: config.EMAIL_FROM,
|
||||
Destination: {
|
||||
ToAddresses: [email],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: 'Your RYS Premium subscription has been canceled',
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Your RYS Premium subscription has been canceled.\n\nThe free version of RYS is still yours to use anytime.\n\nIf you have a moment, we'd love to hear what we could do better: ${FEEDBACK_URL}\n\nThanks for giving Premium a try.\n\n— Lawrence`,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Html: {
|
||||
Data: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: #333; background: #f5f5f5;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #fff;">
|
||||
<div style="padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||
<a href="https://lawrencehook.com/rys/" style="text-decoration: none; font-size: 18px; font-weight: 600; color: #1a1a1a;">RYS</a>
|
||||
</div>
|
||||
<div style="padding: 32px 24px 40px;">
|
||||
<p style="font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
|
||||
Your Premium subscription has been canceled.
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 16px 0;">
|
||||
The free version of RYS is still yours to use anytime.
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 24px 0;">
|
||||
If you have a moment, we'd love to hear what we could do better:
|
||||
</p>
|
||||
<p style="margin: 0 0 24px 0;">
|
||||
<a href="${FEEDBACK_URL}"
|
||||
style="background-color: #0600FB; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 14px; display: inline-block;">
|
||||
Share Feedback
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 16px 0;">
|
||||
Thanks for giving Premium a try.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 32px;">
|
||||
— Lawrence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const command = new SendEmailCommand(params);
|
||||
return sesClient.send(command);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendMagicLinkEmail,
|
||||
sendWelcomeEmail,
|
||||
sendCancellationEmail,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config');
|
||||
|
||||
function generateSessionToken(email) {
|
||||
const payload = {
|
||||
email: email.toLowerCase(),
|
||||
};
|
||||
|
||||
const options = {
|
||||
expiresIn: `${config.SESSION_TOKEN_LIFETIME_DAYS}d`,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.JWT_SECRET, options);
|
||||
}
|
||||
|
||||
function generateLicenseToken(email, premium, grandfathered = false) {
|
||||
const payload = {
|
||||
email: email.toLowerCase(),
|
||||
premium: !!premium,
|
||||
grandfathered: !!grandfathered,
|
||||
};
|
||||
|
||||
const lifetimeDays = grandfathered
|
||||
? config.GRANDFATHERED_TOKEN_LIFETIME_DAYS
|
||||
: config.LICENSE_TOKEN_LIFETIME_DAYS;
|
||||
|
||||
return jwt.sign(payload, config.JWT_SECRET, { expiresIn: `${lifetimeDays}d` });
|
||||
}
|
||||
|
||||
function verifySessionToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
return { valid: true, email: decoded.email };
|
||||
} catch (err) {
|
||||
return { valid: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Express middleware to require authentication
|
||||
function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing or invalid authorization header' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const result = verifySessionToken(token);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
req.userEmail = result.email;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSessionToken,
|
||||
generateLicenseToken,
|
||||
verifySessionToken,
|
||||
requireAuth,
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
const Stripe = require('stripe');
|
||||
const config = require('../config');
|
||||
|
||||
const stripe = new Stripe(config.STRIPE_SECRET_KEY);
|
||||
|
||||
async function findCustomerByEmail(email) {
|
||||
const customers = await stripe.customers.list({
|
||||
email: email.toLowerCase(),
|
||||
limit: 1,
|
||||
});
|
||||
return customers.data[0] || null;
|
||||
}
|
||||
|
||||
async function createCustomer(email) {
|
||||
return stripe.customers.create({
|
||||
email: email.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
async function findOrCreateCustomer(email) {
|
||||
const existing = await findCustomerByEmail(email);
|
||||
if (existing) return existing;
|
||||
return createCustomer(email);
|
||||
}
|
||||
|
||||
async function getActiveSubscription(customerId) {
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
return subscriptions.data[0] || null;
|
||||
}
|
||||
|
||||
async function checkPremiumStatus(email) {
|
||||
const customer = await findCustomerByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
return { premium: false, expiresAt: null };
|
||||
}
|
||||
|
||||
const subscription = await getActiveSubscription(customer.id);
|
||||
|
||||
if (!subscription) {
|
||||
return { premium: false, expiresAt: null };
|
||||
}
|
||||
|
||||
return {
|
||||
premium: true,
|
||||
expiresAt: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function createCheckoutSession(email, plan) {
|
||||
const customer = await findOrCreateCustomer(email);
|
||||
|
||||
const priceId = plan === 'yearly'
|
||||
? config.STRIPE_PRICE_YEARLY
|
||||
: config.STRIPE_PRICE_MONTHLY;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customer.id,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${config.BASE_URL}/checkout/success`,
|
||||
cancel_url: `${config.BASE_URL}/checkout/cancel`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createBillingPortalSession(email) {
|
||||
const customer = await findCustomerByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('No customer found for this email');
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customer.id,
|
||||
return_url: `${config.BASE_URL}/billing/return`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function constructWebhookEvent(payload, signature) {
|
||||
return stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
config.STRIPE_WEBHOOK_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stripe,
|
||||
findCustomerByEmail,
|
||||
createCustomer,
|
||||
findOrCreateCustomer,
|
||||
getActiveSubscription,
|
||||
checkPremiumStatus,
|
||||
createCheckoutSession,
|
||||
createBillingPortalSession,
|
||||
constructWebhookEvent,
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config');
|
||||
|
||||
// Get paths dynamically to support test environment changes
|
||||
function getDataDir() {
|
||||
return config.DATA_DIR;
|
||||
}
|
||||
|
||||
function getAuthRequestsDir() {
|
||||
return path.join(getDataDir(), config.AUTH_REQUESTS_DIR);
|
||||
}
|
||||
|
||||
function getRateLimitsDir() {
|
||||
return path.join(getDataDir(), config.RATE_LIMITS_DIR);
|
||||
}
|
||||
|
||||
function ensureDirectories() {
|
||||
const dirs = [getDataDir(), getAuthRequestsDir(), getRateLimitsDir()];
|
||||
dirs.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
ensureDirectories();
|
||||
|
||||
// --- Grandfathered Emails (file-backed, no in-memory cache) ---
|
||||
|
||||
function readGrandfatheredEmails() {
|
||||
try {
|
||||
const filePath = path.join(getDataDir(), config.GRANDFATHERED_FILE);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
||||
return new Set(lines.map(l => l.trim().toLowerCase()).filter(l => l && !l.startsWith('#')));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read grandfathered emails:', err.message);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function isGrandfathered(email) {
|
||||
return readGrandfatheredEmails().has(email.toLowerCase());
|
||||
}
|
||||
|
||||
// --- Subscription Cache (email -> premium status, file-backed, no in-memory cache) ---
|
||||
|
||||
function getSubscriptionCachePath() {
|
||||
return path.join(getDataDir(), 'subscriptions.json');
|
||||
}
|
||||
|
||||
function readSubscriptionFile() {
|
||||
try {
|
||||
const filePath = getSubscriptionCachePath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read subscription cache:', err.message);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function setSubscriptionStatus(email, premium, customerId) {
|
||||
email = email.toLowerCase();
|
||||
const data = readSubscriptionFile();
|
||||
data[email] = { premium, customerId, updatedAt: Date.now() };
|
||||
try {
|
||||
fs.writeFileSync(getSubscriptionCachePath(), JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error('Failed to save subscription cache:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriptionStatus(email) {
|
||||
const data = readSubscriptionFile();
|
||||
return data[email.toLowerCase()] || null;
|
||||
}
|
||||
|
||||
|
||||
// --- Auth Requests (file per request) ---
|
||||
|
||||
function getAuthRequestFilename(requestId, timestamp) {
|
||||
return `${timestamp}-${requestId}.json`;
|
||||
}
|
||||
|
||||
function parseAuthRequestFilename(filename) {
|
||||
const match = filename.match(/^(\d+)-(.+)\.json$/);
|
||||
if (!match) return null;
|
||||
return { timestamp: parseInt(match[1], 10), requestId: match[2] };
|
||||
}
|
||||
|
||||
function createAuthRequest(requestId, email) {
|
||||
ensureDirectories(); // Ensure directory exists before write
|
||||
const timestamp = Date.now();
|
||||
const data = {
|
||||
request_id: requestId,
|
||||
email: email,
|
||||
status: 'pending',
|
||||
created_at: timestamp,
|
||||
session_token: null,
|
||||
};
|
||||
const filename = getAuthRequestFilename(requestId, timestamp);
|
||||
const filePath = path.join(getAuthRequestsDir(), filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
}
|
||||
|
||||
function findAuthRequestFile(requestId) {
|
||||
const dir = getAuthRequestsDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const parsed = parseAuthRequestFilename(file);
|
||||
if (parsed && parsed.requestId === requestId) {
|
||||
return path.join(dir, file);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAuthRequest(requestId) {
|
||||
const filePath = findAuthRequestFile(requestId);
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() - data.created_at > config.REQUEST_ID_EXPIRY_MS) {
|
||||
// Clean up expired request
|
||||
fs.unlinkSync(filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthRequest(requestId, updates) {
|
||||
const filePath = findAuthRequestFile(requestId);
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const updated = { ...data, ...updates };
|
||||
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAuthRequest(requestId) {
|
||||
const filePath = findAuthRequestFile(requestId);
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pruneExpiredAuthRequests() {
|
||||
const now = Date.now();
|
||||
const dir = getAuthRequestsDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
let pruned = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const parsed = parseAuthRequestFilename(file);
|
||||
if (parsed && now - parsed.timestamp > config.REQUEST_ID_EXPIRY_MS) {
|
||||
fs.unlinkSync(path.join(dir, file));
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0) {
|
||||
console.log(`Pruned ${pruned} expired auth requests`);
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
// --- Rate Limiting (file per email hash) ---
|
||||
|
||||
function getEmailHash(email) {
|
||||
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function getRateLimitFilePath(email) {
|
||||
return path.join(getRateLimitsDir(), `${getEmailHash(email)}.json`);
|
||||
}
|
||||
|
||||
function checkRateLimit(email) {
|
||||
ensureDirectories(); // Ensure directory exists before write
|
||||
const filePath = getRateLimitFilePath(email);
|
||||
const now = Date.now();
|
||||
|
||||
let data = { count: 0, windowStart: now };
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
// Reset if window expired
|
||||
if (now - data.windowStart > config.RATE_LIMIT_WINDOW_MS) {
|
||||
data = { count: 0, windowStart: now };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Start fresh on error
|
||||
data = { count: 0, windowStart: now };
|
||||
}
|
||||
|
||||
// Check if over limit
|
||||
if (data.count >= config.RATE_LIMIT_MAX_REQUESTS) {
|
||||
const resetTime = data.windowStart + config.RATE_LIMIT_WINDOW_MS;
|
||||
return { allowed: false, resetTime };
|
||||
}
|
||||
|
||||
// Increment and save
|
||||
data.count++;
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
return { allowed: true, remaining: config.RATE_LIMIT_MAX_REQUESTS - data.count };
|
||||
}
|
||||
|
||||
function decrementRateLimit(email) {
|
||||
const filePath = getRateLimitFilePath(email);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (data.count > 0) {
|
||||
data.count--;
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredRateLimits() {
|
||||
const now = Date.now();
|
||||
const dir = getRateLimitsDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
let pruned = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (now - data.windowStart > config.RATE_LIMIT_WINDOW_MS) {
|
||||
fs.unlinkSync(filePath);
|
||||
pruned++;
|
||||
}
|
||||
} catch (err) {
|
||||
// Remove invalid files
|
||||
fs.unlinkSync(filePath);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0) {
|
||||
console.log(`Pruned ${pruned} expired rate limit records`);
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Grandfathered
|
||||
readGrandfatheredEmails,
|
||||
isGrandfathered,
|
||||
|
||||
// Auth requests
|
||||
createAuthRequest,
|
||||
getAuthRequest,
|
||||
updateAuthRequest,
|
||||
deleteAuthRequest,
|
||||
pruneExpiredAuthRequests,
|
||||
|
||||
// Rate limiting
|
||||
checkRateLimit,
|
||||
decrementRateLimit,
|
||||
pruneExpiredRateLimits,
|
||||
|
||||
// Subscription cache
|
||||
setSubscriptionStatus,
|
||||
getSubscriptionStatus,
|
||||
|
||||
// Utils
|
||||
ensureDirectories,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
// Shared HTML page templates — styled to match lawrencehook.com/rys
|
||||
|
||||
const RYS_LOGO_URL = 'https://lawrencehook.com/rys/assets/rys.svg';
|
||||
const RYS_HOME_URL = 'https://lawrencehook.com/rys/';
|
||||
|
||||
function renderPage({ title, heading, message, icon }) {
|
||||
const iconHTML = icon === 'check'
|
||||
? `<div class="icon check">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
</div>`
|
||||
: icon === 'error'
|
||||
? `<div class="icon error">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} — RYS</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
header a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
header .title {
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
header .premium-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #0600fb;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.icon.check { background: #4CAF50; }
|
||||
.icon.error { background: #f44336; }
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="${RYS_HOME_URL}">
|
||||
<img src="${RYS_LOGO_URL}" alt="RYS">
|
||||
</a>
|
||||
<span class="title">RYS</span>
|
||||
<span class="premium-badge">Premium</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
${iconHTML}
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0" target="_blank">Feedback</a>
|
||||
<a href="${RYS_HOME_URL}" target="_blank">Homepage</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
module.exports = { renderPage };
|
||||
@@ -0,0 +1,180 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData } = require('./setup');
|
||||
|
||||
// Mock the email service before requiring app
|
||||
const emailService = require('../src/services/email');
|
||||
let lastSentEmail = null;
|
||||
emailService.sendMagicLinkEmail = async (email, url) => {
|
||||
lastSentEmail = { email, url };
|
||||
return { MessageId: 'test-message-id' };
|
||||
};
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
stripeService.checkPremiumStatus = async () => ({ premium: false, expiresAt: null });
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
lastSentEmail = null;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('POST /auth/send-magic-link', () => {
|
||||
it('should send magic link for valid email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'test@example.com' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.request_id);
|
||||
assert.strictEqual(lastSentEmail.email, 'test@example.com');
|
||||
assert.ok(lastSentEmail.url.includes(res.body.request_id));
|
||||
});
|
||||
|
||||
it('should reject invalid email format', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'not-an-email' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.ok(res.body.error.includes('Invalid email'));
|
||||
});
|
||||
|
||||
it('should reject missing email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({});
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
it('should rate limit after too many requests', async () => {
|
||||
const email = 'ratelimit@example.com';
|
||||
|
||||
// Make max allowed requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email });
|
||||
}
|
||||
|
||||
// Next should be rate limited
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email });
|
||||
|
||||
assert.strictEqual(res.status, 429);
|
||||
assert.ok(res.headers['retry-after']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/verify', () => {
|
||||
it('should verify valid magic link and show success page', async () => {
|
||||
// Create auth request first
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'verify@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/auth/verify?token=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes("You're signed in"));
|
||||
|
||||
// Auth request should now be verified
|
||||
const authRequest = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(authRequest.status, 'verified');
|
||||
assert.ok(authRequest.session_token);
|
||||
});
|
||||
|
||||
it('should reject missing token', async () => {
|
||||
const res = await request(app).get('/auth/verify');
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.ok(res.text.includes('Missing token'));
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/verify?token=invalid-token');
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
assert.ok(res.text.includes('expired or invalid'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/poll', () => {
|
||||
it('should return pending for unverified request', async () => {
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'poll@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'pending');
|
||||
});
|
||||
|
||||
it('should return verified with token after magic link clicked', async () => {
|
||||
// Create and verify request
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'poll2@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
// Click magic link
|
||||
await request(app).get(`/auth/verify?token=${requestId}`);
|
||||
|
||||
// Poll should return verified
|
||||
const res = await request(app)
|
||||
.get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'verified');
|
||||
assert.ok(res.body.session_token);
|
||||
assert.strictEqual(res.body.email, 'poll2@example.com');
|
||||
});
|
||||
|
||||
it('should delete auth request after returning verified', async () => {
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'poll3@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
await request(app).get(`/auth/verify?token=${requestId}`);
|
||||
await request(app).get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
// Second poll should return 404
|
||||
const res = await request(app)
|
||||
.get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
|
||||
it('should reject invalid request_id', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/poll?request_id=invalid');
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData } = require('./setup');
|
||||
|
||||
// Mock the email service
|
||||
const emailService = require('../src/services/email');
|
||||
emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
stripeService.createCheckoutSession = async (email, plan) => ({
|
||||
url: `https://checkout.stripe.com/test?plan=${plan}&email=${email}`,
|
||||
});
|
||||
stripeService.createBillingPortalSession = async (email) => {
|
||||
if (email === 'nocustomer@example.com') {
|
||||
throw new Error('No customer found for this email');
|
||||
}
|
||||
return { url: 'https://billing.stripe.com/test' };
|
||||
};
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
const { generateSessionToken } = require('../src/services/jwt');
|
||||
|
||||
describe('Checkout Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('POST /checkout/create', () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.send({ plan: 'monthly' });
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should create checkout session for monthly plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ plan: 'monthly' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.checkout_url);
|
||||
assert.ok(res.body.checkout_url.includes('monthly'));
|
||||
});
|
||||
|
||||
it('should create checkout session for yearly plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ plan: 'yearly' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.checkout_url.includes('yearly'));
|
||||
});
|
||||
|
||||
it('should reject invalid plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ plan: 'invalid' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.ok(res.body.error.includes('Invalid plan'));
|
||||
});
|
||||
|
||||
it('should reject missing plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /checkout/success', () => {
|
||||
it('should return success page', async () => {
|
||||
const res = await request(app).get('/checkout/success');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes('Payment successful'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /checkout/cancel', () => {
|
||||
it('should return cancel page', async () => {
|
||||
const res = await request(app).get('/checkout/cancel');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes('Payment canceled'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
describe('POST /billing/portal', () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await request(app).post('/billing/portal');
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should return billing portal URL', async () => {
|
||||
const token = generateSessionToken('billing@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/billing/portal')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.url);
|
||||
assert.ok(res.body.url.includes('billing.stripe.com'));
|
||||
});
|
||||
|
||||
it('should return 404 for user without subscription', async () => {
|
||||
const token = generateSessionToken('nocustomer@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/billing/portal')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /billing/return', () => {
|
||||
it('should return billing return page', async () => {
|
||||
const res = await request(app).get('/billing/return');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes('Billing updated'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const { describe, it, beforeEach, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData } = require('./setup');
|
||||
|
||||
// Mock email service
|
||||
const emailService = require('../src/services/email');
|
||||
emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
|
||||
describe('Health Check', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('should return health status', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'ok');
|
||||
assert.ok(res.body.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 handling', () => {
|
||||
it('should return 404 for unknown routes', async () => {
|
||||
const res = await request(app).get('/unknown-route');
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
assert.strictEqual(res.body.error, 'Not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Setup must be required first
|
||||
require('./setup');
|
||||
|
||||
const { generateSessionToken, generateLicenseToken, verifySessionToken } = require('../src/services/jwt');
|
||||
|
||||
describe('JWT Service', () => {
|
||||
describe('generateSessionToken', () => {
|
||||
it('should generate a valid JWT token', () => {
|
||||
const token = generateSessionToken('test@example.com');
|
||||
|
||||
assert.ok(token);
|
||||
assert.strictEqual(typeof token, 'string');
|
||||
assert.ok(token.split('.').length === 3); // JWT format
|
||||
});
|
||||
|
||||
it('should lowercase email in token', () => {
|
||||
const token = generateSessionToken('Test@EXAMPLE.com');
|
||||
const result = verifySessionToken(token);
|
||||
|
||||
assert.strictEqual(result.email, 'test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySessionToken', () => {
|
||||
it('should verify a valid token', () => {
|
||||
const token = generateSessionToken('verify@example.com');
|
||||
const result = verifySessionToken(token);
|
||||
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.email, 'verify@example.com');
|
||||
});
|
||||
|
||||
it('should reject an invalid token', () => {
|
||||
const result = verifySessionToken('invalid-token');
|
||||
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.ok(result.error);
|
||||
});
|
||||
|
||||
it('should reject a token with wrong secret', () => {
|
||||
// Create a token with a different secret (simulated by tampering)
|
||||
const validToken = generateSessionToken('test@example.com');
|
||||
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
|
||||
|
||||
const result = verifySessionToken(tamperedToken);
|
||||
|
||||
assert.strictEqual(result.valid, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLicenseToken', () => {
|
||||
it('should generate a valid license token with premium status', () => {
|
||||
const token = generateLicenseToken('test@example.com', true, false);
|
||||
|
||||
assert.ok(token);
|
||||
assert.strictEqual(typeof token, 'string');
|
||||
assert.ok(token.split('.').length === 3);
|
||||
|
||||
const result = verifySessionToken(token);
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.email, 'test@example.com');
|
||||
});
|
||||
|
||||
it('should include premium and grandfathered flags in payload', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../src/config');
|
||||
|
||||
const token = generateLicenseToken('test@example.com', true, true);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
|
||||
assert.strictEqual(decoded.email, 'test@example.com');
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, true);
|
||||
});
|
||||
|
||||
it('should lowercase email in license token', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../src/config');
|
||||
|
||||
const token = generateLicenseToken('Test@EXAMPLE.com', true, false);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
|
||||
assert.strictEqual(decoded.email, 'test@example.com');
|
||||
});
|
||||
|
||||
it('should set non-premium status correctly', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../src/config');
|
||||
|
||||
const token = generateLicenseToken('test@example.com', false, false);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
|
||||
assert.strictEqual(decoded.premium, false);
|
||||
assert.strictEqual(decoded.grandfathered, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData, testDataDir } = require('./setup');
|
||||
|
||||
// Mock the email service
|
||||
const emailService = require('../src/services/email');
|
||||
emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
let mockPremiumStatus = { premium: false, expiresAt: null };
|
||||
stripeService.checkPremiumStatus = async () => mockPremiumStatus;
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
const config = require('../src/config');
|
||||
const { generateSessionToken } = require('../src/services/jwt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Helper to decode license token from response
|
||||
function decodeLicenseToken(response) {
|
||||
const token = response.body.license_token;
|
||||
if (!token) return null;
|
||||
return jwt.verify(token, config.JWT_SECRET);
|
||||
}
|
||||
|
||||
describe('License Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
mockPremiumStatus = { premium: false, expiresAt: null };
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('GET /license/check', () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await request(app).get('/license/check');
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', 'Bearer invalid-token');
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should return license token with non-premium for user without subscription', async () => {
|
||||
const token = generateSessionToken('free@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.license_token);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, false);
|
||||
assert.strictEqual(decoded.grandfathered, false);
|
||||
assert.strictEqual(decoded.email, 'free@example.com');
|
||||
});
|
||||
|
||||
it('should return license token with premium for user with active subscription', async () => {
|
||||
mockPremiumStatus = {
|
||||
premium: true,
|
||||
expiresAt: '2025-12-31T00:00:00Z',
|
||||
};
|
||||
|
||||
const token = generateSessionToken('premium@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.license_token);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, false);
|
||||
assert.strictEqual(decoded.email, 'premium@example.com');
|
||||
});
|
||||
|
||||
it('should return license token with premium and grandfathered for grandfathered user', async () => {
|
||||
// Set up grandfathered file
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'donor@example.com\n');
|
||||
|
||||
|
||||
const token = generateSessionToken('donor@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.license_token);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, true);
|
||||
assert.strictEqual(decoded.email, 'donor@example.com');
|
||||
});
|
||||
|
||||
it('should handle grandfathered check case-insensitively', async () => {
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'Donor@Example.com\n');
|
||||
|
||||
|
||||
// Token with lowercase email
|
||||
const token = generateSessionToken('donor@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Set up test environment variables
|
||||
process.env.JWT_SECRET = 'test-secret-key-for-testing-only-32chars';
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_fake';
|
||||
process.env.STRIPE_PRICE_MONTHLY = 'price_test_monthly';
|
||||
process.env.STRIPE_PRICE_YEARLY = 'price_test_yearly';
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
|
||||
process.env.EMAIL_FROM = 'test@example.com';
|
||||
process.env.BASE_URL = 'http://localhost:3000';
|
||||
process.env.DATA_DIR = './tests/test-data';
|
||||
|
||||
// Clean up test data directory
|
||||
const testDataDir = './tests/test-data';
|
||||
|
||||
function cleanTestData() {
|
||||
try {
|
||||
if (fs.existsSync(testDataDir)) {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore ENOTEMPTY errors from race conditions in parallel tests
|
||||
if (err.code !== 'ENOTEMPTY' && err.code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean before tests
|
||||
cleanTestData();
|
||||
|
||||
// Export for use in tests
|
||||
module.exports = {
|
||||
cleanTestData,
|
||||
testDataDir,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData, testDataDir } = require('./setup');
|
||||
|
||||
// Now require storage (after env vars are set)
|
||||
const storage = require('../src/storage');
|
||||
const config = require('../src/config');
|
||||
|
||||
describe('Storage', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('Auth Requests', () => {
|
||||
it('should create and retrieve an auth request', () => {
|
||||
const requestId = 'test-request-123';
|
||||
const email = 'test@example.com';
|
||||
|
||||
const created = storage.createAuthRequest(requestId, email);
|
||||
|
||||
assert.strictEqual(created.request_id, requestId);
|
||||
assert.strictEqual(created.email, email);
|
||||
assert.strictEqual(created.status, 'pending');
|
||||
assert.strictEqual(created.session_token, null);
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
|
||||
assert.strictEqual(retrieved.request_id, requestId);
|
||||
assert.strictEqual(retrieved.email, email);
|
||||
});
|
||||
|
||||
it('should update an auth request', () => {
|
||||
const requestId = 'test-request-456';
|
||||
storage.createAuthRequest(requestId, 'test@example.com');
|
||||
|
||||
const updated = storage.updateAuthRequest(requestId, {
|
||||
status: 'verified',
|
||||
session_token: 'fake-token',
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.status, 'verified');
|
||||
assert.strictEqual(updated.session_token, 'fake-token');
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(retrieved.status, 'verified');
|
||||
});
|
||||
|
||||
it('should delete an auth request', () => {
|
||||
const requestId = 'test-request-789';
|
||||
storage.createAuthRequest(requestId, 'test@example.com');
|
||||
|
||||
const deleted = storage.deleteAuthRequest(requestId);
|
||||
assert.strictEqual(deleted, true);
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(retrieved, null);
|
||||
});
|
||||
|
||||
it('should return null for non-existent request', () => {
|
||||
const retrieved = storage.getAuthRequest('non-existent');
|
||||
assert.strictEqual(retrieved, null);
|
||||
});
|
||||
|
||||
it('should return null for expired request', () => {
|
||||
const requestId = 'expired-request';
|
||||
const email = 'test@example.com';
|
||||
|
||||
// Create request with old timestamp
|
||||
const oldTimestamp = Date.now() - config.REQUEST_ID_EXPIRY_MS - 1000;
|
||||
const filename = `${oldTimestamp}-${requestId}.json`;
|
||||
const filePath = path.join(testDataDir, config.AUTH_REQUESTS_DIR, filename);
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
request_id: requestId,
|
||||
email: email,
|
||||
status: 'pending',
|
||||
created_at: oldTimestamp,
|
||||
session_token: null,
|
||||
}));
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(retrieved, null);
|
||||
|
||||
// File should be deleted
|
||||
assert.strictEqual(fs.existsSync(filePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should allow requests under limit', () => {
|
||||
const email = 'ratelimit@example.com';
|
||||
|
||||
for (let i = 0; i < config.RATE_LIMIT_MAX_REQUESTS; i++) {
|
||||
const result = storage.checkRateLimit(email);
|
||||
assert.strictEqual(result.allowed, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should block requests over limit', () => {
|
||||
const email = 'overlimit@example.com';
|
||||
|
||||
// Use up all allowed requests
|
||||
for (let i = 0; i < config.RATE_LIMIT_MAX_REQUESTS; i++) {
|
||||
storage.checkRateLimit(email);
|
||||
}
|
||||
|
||||
// Next should be blocked
|
||||
const result = storage.checkRateLimit(email);
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.resetTime > Date.now());
|
||||
});
|
||||
|
||||
it('should track remaining requests', () => {
|
||||
const email = 'remaining@example.com';
|
||||
|
||||
const first = storage.checkRateLimit(email);
|
||||
assert.strictEqual(first.remaining, config.RATE_LIMIT_MAX_REQUESTS - 1);
|
||||
|
||||
const second = storage.checkRateLimit(email);
|
||||
assert.strictEqual(second.remaining, config.RATE_LIMIT_MAX_REQUESTS - 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grandfathered Emails', () => {
|
||||
it('should read grandfathered emails from file', () => {
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'donor1@example.com\nDonor2@Example.com\n');
|
||||
|
||||
assert.strictEqual(storage.isGrandfathered('donor1@example.com'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('DONOR1@EXAMPLE.COM'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('donor2@example.com'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('notadonor@example.com'), false);
|
||||
});
|
||||
|
||||
it('should handle missing grandfathered file gracefully', () => {
|
||||
// File doesn't exist, should not throw
|
||||
assert.strictEqual(storage.isGrandfathered('anyone@example.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pruning', () => {
|
||||
it('should prune expired auth requests', () => {
|
||||
// Create an expired request
|
||||
const oldTimestamp = Date.now() - config.REQUEST_ID_EXPIRY_MS - 1000;
|
||||
const filename = `${oldTimestamp}-old-request.json`;
|
||||
const filePath = path.join(testDataDir, config.AUTH_REQUESTS_DIR, filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify({ created_at: oldTimestamp }));
|
||||
|
||||
// Create a valid request
|
||||
storage.createAuthRequest('valid-request', 'test@example.com');
|
||||
|
||||
const pruned = storage.pruneExpiredAuthRequests();
|
||||
|
||||
assert.strictEqual(pruned, 1);
|
||||
assert.strictEqual(fs.existsSync(filePath), false);
|
||||
assert.notStrictEqual(storage.getAuthRequest('valid-request'), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user