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:
Lawrence Hook
2026-02-15 18:45:37 -05:00
parent a76f69804b
commit 8825666512
63 changed files with 10364 additions and 482 deletions
+18
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
data/
tests/test-data/
+72
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
[
"donor1@example.com",
"donor2@example.com"
]
+2630
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+37
View File
@@ -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',
};
+153
View File
@@ -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();
}
+202
View File
@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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;
+35
View File
@@ -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;
+46
View File
@@ -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;
+42
View File
@@ -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;
+109
View File
@@ -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;
+195
View File
@@ -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,
};
+63
View File
@@ -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,
};
+105
View File
@@ -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,
};
+297
View File
@@ -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,
};
+168
View File
@@ -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 };
+180
View File
@@ -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);
});
});
});
+159
View File
@@ -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'));
});
});
});
+43
View File
@@ -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');
});
});
});
+100
View File
@@ -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);
});
});
});
+133
View File
@@ -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);
});
});
});
+37
View File
@@ -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,
};
+168
View File
@@ -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);
});
});
});