version 4.3.72

Squashed commit of the following:

commit 53a18e7987
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Mar 1 16:56:50 2026 -0500

    Hide donate link for premium users instead of showing dead label

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit e2732dee6f
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Mar 1 16:50:33 2026 -0500

    Fix fast-xml-parser vulnerability via npm override and update @aws-sdk/client-ses

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 281c0f1103
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Mar 1 16:46:31 2026 -0500

    Add "Active" sidebar tab and soften selected sidebar styling

    Adds "all" and "active" tabs at the top of the sidebar. "Active" shows
    all currently-enabled options in one place; "all" explicitly resets to
    the default view. Also lightens the sidebar selected state from solid
    black to a subtle tint.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 56b119ed98
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 22 05:01:00 2026 -0500

    Cache grandfathered emails in memory at startup

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit afed16a38a
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 22 04:47:51 2026 -0500

    Add tests for cache TTL and webhook ordering race condition

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 361a8caf84
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 22 04:34:00 2026 -0500

    Harden license cache: 10s TTL and skip incomplete subscription.created

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit d857ae8c26
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sat Feb 21 18:24:33 2026 -0500

    Update README: replace donations blurb with premium link

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit c473314ca5
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Sat Feb 21 18:22:39 2026 -0500

    Bump fast-xml-parser and @aws-sdk/xml-builder in /server (#201)

    Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder). These dependencies needed to be updated together.

    Updates `fast-xml-parser` from 5.3.4 to 5.3.6
    - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
    - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
    - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.4...v5.3.6)

    Updates `@aws-sdk/xml-builder` from 3.972.4 to 3.972.5
    - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
    - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md)
    - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder)

    ---
    updated-dependencies:
    - dependency-name: fast-xml-parser
      dependency-version: 5.3.6
      dependency-type: indirect
    - dependency-name: "@aws-sdk/xml-builder"
      dependency-version: 3.972.5
      dependency-type: indirect
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 49e1a5d9ba
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sat Feb 21 17:30:29 2026 -0500

    Fix license cache not updating after checkout

    The checkout.session.completed webhook only sent a welcome email but
    did not update the subscription cache, so users kept getting cached
    free status after paying.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit c5734518c4
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 15 19:10:05 2026 -0500

    Remove TESTING.md and DEPLOYMENT.md

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 6ed3ad0291
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 15 19:08:03 2026 -0500

    Remove obsolete docs and update TODO for YouTube DOM change

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 83693504e3
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 15 18:54:29 2026 -0500

    update versions

commit 27a6ae6f18
Merge: 3f4b42b d6e642b
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 15 18:53:37 2026 -0500

    Merge branch 'released'

commit 3f4b42b2de
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 15 18:49:28 2026 -0500

    Update qs to 6.14.2 to fix low-severity DoS vulnerability

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 8825666512
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 15 18:45:37 2026 -0500

    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.

commit a76f69804b
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 8 20:45:35 2026 -0500

    update description in README

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit b5d1384ca8
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 8 20:44:26 2026 -0500

    update "Why I made it" section in README

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

commit 18b64708b6
Author: Lawrence Hook <lawrencehook@gmail.com>
Date:   Sun Feb 8 20:14:23 2026 -0500

    update version to 4.3.71
This commit is contained in:
Lawrence Hook
2026-03-01 21:57:22 -05:00
parent d6e642b962
commit 2fe77faf0f
21 changed files with 945 additions and 2188 deletions
-521
View File
@@ -1,521 +0,0 @@
# RYS Premium Server Deployment Guide
Complete guide to deploying the premium subscription server and testing the monetization flow.
**Server URL:** `https://server.lawrencehook.com/rys/`
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Stripe Setup](#stripe-setup)
3. [AWS SES Setup](#aws-ses-setup)
4. [Server Deployment](#server-deployment)
5. [Apache Configuration](#apache-configuration)
6. [Extension Setup](#extension-setup)
7. [Testing](#testing)
8. [Troubleshooting](#troubleshooting)
9. [Maintenance](#maintenance)
---
## Prerequisites
- AWS Lightsail instance (Bitnami stack)
- Node.js v20+
- Stripe account (test mode for development)
- AWS SES configured for sending emails
---
## Stripe Setup
### Create Products
1. Go to https://dashboard.stripe.com/test/products
2. Click **"+ Add product"**
**Monthly Plan:**
- Name: `RYS Premium Monthly`
- Pricing: `$3.00 USD` / `month` / `recurring`
- Save and copy the **Price ID** (starts with `price_...`)
**Yearly Plan:**
- Name: `RYS Premium Yearly`
- Pricing: `$24.00 USD` / `year` / `recurring`
- Save and copy the **Price ID**
### Get API Keys
1. Go to https://dashboard.stripe.com/test/apikeys
2. Copy the **Secret key** (starts with `sk_test_...`)
### Configure Billing Portal
1. Go to Settings → Billing → Customer portal
2. Enable the customer portal
3. Configure allowed actions:
- [x] Update payment methods
- [x] View invoice history
- [x] Cancel subscriptions
4. Save changes
### Create Webhook Endpoint
1. Go to https://dashboard.stripe.com/test/webhooks
2. Click **"+ Add endpoint"**
3. Endpoint URL: `https://server.lawrencehook.com/rys/webhook/stripe`
4. Select events:
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
- `invoice.payment_succeeded`
5. Click **"Add endpoint"**
6. Copy the **Signing secret** (starts with `whsec_...`)
---
## AWS SES Setup
### Verify Sender Email/Domain
1. In AWS Console → SES → Verified identities
2. Click "Create identity"
3. For email: click the verification link sent to you
4. For domain: add the DNS records provided
### Request Production Access
SES starts in sandbox mode (can only send to verified emails).
1. Go to SES → Account dashboard
2. Click "Request production access"
3. Fill out the form explaining your use case
4. Wait for approval (usually 24-48 hours)
### IAM Credentials
If not using instance roles, create IAM credentials:
1. Create a new user (e.g., `rys-premium-ses`)
2. Attach policy: `AmazonSESFullAccess`
3. Create access keys
4. Copy the **Access Key ID** and **Secret Access Key**
---
## Server Deployment
### SSH to Lightsail Instance
```bash
ssh bitnami@your-instance-ip
```
### Install Node.js 20+ (if needed)
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version # Should show v20.x.x
```
### Install PM2
```bash
sudo npm install -g pm2
```
### Clone and Setup Repository
```bash
cd ~/github
git clone https://github.com/lawrencehook/remove-youtube-suggestions.git
cd remove-youtube-suggestions
git checkout feature/monetize
cd server
npm install
```
### Create Environment File
```bash
nano .env
```
Paste the following (fill in your values):
```bash
# Server
PORT=3005
BASE_URL=https://server.lawrencehook.com/rys
# JWT (generate with: openssl rand -base64 32)
JWT_SECRET=your-random-secret-at-least-32-characters-long
# Stripe
STRIPE_SECRET_KEY=sk_test_YOUR_KEY
STRIPE_PRICE_MONTHLY=price_YOUR_MONTHLY_ID
STRIPE_PRICE_YEARLY=price_YOUR_YEARLY_ID
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
# AWS SES (uses instance role, no explicit credentials needed)
AWS_REGION=us-east-1
EMAIL_FROM=noreply@lawrencehook.com
```
Save: `Ctrl+O`, `Enter`, `Ctrl+X`
### Create Data Directory
```bash
mkdir -p data
echo '[]' > data/grandfathered.json
```
To add grandfathered users (past donors with lifetime access):
```bash
echo '["donor1@example.com", "donor2@example.com"]' > data/grandfathered.json
```
### Test Server Starts
```bash
npm start
```
Expected output:
```
Loaded X grandfathered emails
RYS Premium Server running on port 3005
Base URL: https://server.lawrencehook.com/rys
```
Press `Ctrl+C` to stop.
### Start with PM2
```bash
pm2 start src/index.js --name rys-premium
pm2 save
```
### Configure PM2 Auto-Start on Reboot
```bash
pm2 startup
# Run the command it outputs (starts with: sudo env PATH=...)
pm2 save
```
---
## Apache Configuration
Your Lightsail instance uses Bitnami's Apache with path-based proxying.
### Edit SSL Config
Edit your local deployments repo:
```bash
nano ~/github/deployments/opt/bitnami/apache/conf/bitnami/bitnami-ssl.conf
```
Add these lines inside the `<VirtualHost _default_:443>` block:
```apache
ProxyPass /rys/ http://localhost:3005/
ProxyPassReverse /rys/ http://localhost:3005/
```
### Deploy Apache Config
```bash
# Local machine
cd ~/github/deployments
git add -A && git commit -m "Add RYS Premium server proxy config" && git push
# On server
cd ~/github/deployments && git pull
./update_apache_conf.sh
```
---
## Extension Setup
### Verify Config Points to Production
Check `src/shared/config.js`:
```javascript
const PREMIUM_CONFIG = {
SERVER_URL: 'https://server.lawrencehook.com/rys',
// ...
};
```
### Build the Extension
**For Chrome:**
```bash
./make_chrome.sh
```
**For Firefox:**
```bash
./make_firefox.sh
```
### Load in Chrome
1. Navigate to `chrome://extensions/`
2. Enable **"Developer mode"** (toggle in top-right)
3. Click **"Load unpacked"**
4. Select: `chrome_extension/`
### Load in Firefox
1. Navigate to `about:debugging#/runtime/this-firefox`
2. Click **"Load Temporary Add-on..."**
3. Select: `src/firefox_manifest.json`
---
## Testing
### Automated Tests
```bash
cd server
npm test
```
All tests should pass (currently 46).
### Health Check
```bash
curl https://server.lawrencehook.com/rys/health
# Expected: {"status":"ok","timestamp":"..."}
```
### Magic Link Flow
**Via curl:**
```bash
curl -X POST https://server.lawrencehook.com/rys/auth/send-magic-link \
-H "Content-Type: application/json" \
-d '{"email":"your-email@example.com"}'
# Expected: {"request_id":"abc123-..."}
```
**Via extension:**
1. Go to any YouTube page
2. Click the RYS extension icon
3. Click the gear icon (settings menu)
4. Click **"Sign In"**
5. Enter your email and click **"Send Sign-In Link"**
6. Check your email and click the magic link
7. Return to extension — should show "Signed in successfully"
### License Check
After signing in:
1. Click gear icon → **"Account"**
2. Should show your email and status: **"Free"**
**For grandfathered users:**
- Add email to `data/grandfathered.json` on server
- Restart: `pm2 restart rys-premium`
- Sign out and sign back in
- Status should show: **"Premium (Lifetime)"**
### Checkout Flow
1. Open Account modal
2. Click **"Upgrade to Premium"**
3. Select a plan (Monthly or Yearly)
4. Click **"Continue to Checkout"**
5. Complete payment with test card:
- Card: `4242 4242 4242 4242`
- Expiry: Any future date (e.g., `12/34`)
- CVC: Any 3 digits (e.g., `123`)
- ZIP: Any valid ZIP (e.g., `12345`)
6. After success page, return to extension
7. Open Account — status should show **"Premium Active"**
### Billing Portal
1. With an active subscription, open Account modal
2. Click **"Manage Subscription"**
3. Stripe billing portal opens in new tab
4. Can cancel subscription here for testing
### Webhook Verification
1. Go to Stripe Dashboard → Webhooks → your endpoint
2. Click **"Send test webhook"**
3. Select `checkout.session.completed`
4. Check server logs: `pm2 logs rys-premium`
### Extension Smoke Tests
- **Sign-in flow:** Send magic link → click link → see "Signed in successfully"
- **Poll cancel:** Start sign-in → hit Cancel → spinner stops, no sign-in after cancel
- **Premium gate:** Mark option as premium → non-premium click opens upgrade modal
- **License refresh:** Open Account → premium label reflects correct status
- **Upgrade flow:** Choose plan → click checkout → Stripe checkout opens
- **Billing portal:** With subscription → click "Manage Subscription" → portal opens
### Pre-Launch Checklist
- [ ] All automated tests pass
- [ ] Server starts without errors
- [ ] `/health` endpoint returns OK
- [ ] Magic link emails are received
- [ ] Magic link verification works
- [ ] Grandfathered users show as premium
- [ ] Stripe checkout creates a session
- [ ] Stripe webhook is configured and verified
- [ ] Billing portal is accessible
- [ ] SSL certificate is valid
- [ ] CORS works from extension origins
### Test Cards Reference
| Card | Result |
|------|--------|
| `4242 4242 4242 4242` | Success |
| `4000 0000 0000 0002` | Declined |
| `4000 0025 0000 3155` | Requires authentication |
---
## Troubleshooting
### Server won't start
```bash
pm2 logs rys-premium --lines 50
```
Common issues:
- Missing .env variables
- JWT_SECRET too short (needs 32+ chars)
- Port already in use
### Magic link emails not sending
- Check SES is configured
- Verify EMAIL_FROM domain is verified in SES
- If in SES sandbox, recipient must also be verified
### CORS errors in extension
```bash
# Verify server is running
curl http://localhost:3005/health
```
### SSL certificate issues
```bash
sudo /opt/bitnami/bncert-tool # regenerate if needed
```
### Extension can't connect
```bash
# Test endpoint
curl https://server.lawrencehook.com/rys/health
# Check Apache config
sudo apachectl configtest
```
---
## Maintenance
### Update Server Code
```bash
cd ~/github/remove-youtube-suggestions
git pull
git checkout feature/monetize
cd server
npm install
pm2 restart rys-premium
```
### View Logs
```bash
pm2 logs rys-premium # Tail logs
pm2 logs rys-premium --lines 100 # Last 100 lines
```
### Server Commands
```bash
pm2 restart rys-premium # Restart
pm2 stop rys-premium # Stop
pm2 status # Check status
```
### Add to Deployment Script
Add to `~/github/deployments/run`:
```bash
# RYS Premium Server (pm2)
cd /home/bitnami/github/remove-youtube-suggestions && git checkout -- . && git pull && git checkout feature/monetize;
cd server && npm install && npm audit fix;
pm2 restart rys-premium || pm2 start src/index.js --name rys-premium;
```
### Updating Grandfathered List
1. Edit `data/grandfathered.json`
2. Restart: `pm2 restart rys-premium`
---
## Environment Variables Reference
| Variable | Description | Example |
|----------|-------------|---------|
| `PORT` | Server port | `3005` |
| `BASE_URL` | Public URL | `https://server.lawrencehook.com/rys` |
| `JWT_SECRET` | 32+ char secret | (generate with `openssl rand -base64 32`) |
| `STRIPE_SECRET_KEY` | Stripe API secret | `sk_test_...` |
| `STRIPE_PRICE_MONTHLY` | Monthly plan price ID | `price_...` |
| `STRIPE_PRICE_YEARLY` | Yearly plan price ID | `price_...` |
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret | `whsec_...` |
| `AWS_REGION` | AWS region for SES | `us-east-1` |
| `EMAIL_FROM` | Sender email address | `noreply@lawrencehook.com` |
---
## API Endpoints Reference
| 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 auth status |
| `/license/check` | GET | Yes | Check premium status |
| `/checkout/create` | POST | Yes | Create Stripe checkout |
| `/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
-8
View File
@@ -1,8 +0,0 @@
# Issues Found During Testing
1. **Footer missing Premium link** — Footer shows "Donors" but no "Premium" link for signed-in users. Fix: add a "Premium" link visible when signed in. ✅ Fixed
2. **"Premium features coming soon" banner** — Remove entirely since premium is now deployed. ✅ Fixed
3. **Settings menu icon** — Consider changing from three dots to a gear icon. ✅ Fixed
4. **Browser action icon** — Update the toolbar icon with a premium indicator when user has premium. (Deferred.)
5. **Static site branding** — Add "Premium" branding to the premium page and checkout/billing pages on lawrencehook.com/rys. (Server pages done; static site deferred until launch.)
6. **Email sender name** — Change "noreply" to "rys" for magic link emails. ✅ Fixed
-40
View File
@@ -1,40 +0,0 @@
# Premium Launch Checklist
## Pre-Launch
### Configuration
- [x] Update `grandfathered.txt` with all donor emails
- [x] Switch Stripe keys from test to production
- `STRIPE_SECRET_KEY`
- `STRIPE_PRICE_MONTHLY`
- `STRIPE_PRICE_YEARLY`
- `STRIPE_WEBHOOK_SECRET`
- [x] Configure production webhook endpoint in Stripe dashboard
- [x] Verify `EMAIL_FROM` is set correctly for production
### Code
- [ ] Merge `feature/monetize` branch into `main`
- [x] Redeploy server with production config
- [ ] Test production sign-in flow (magic link email)
- [ ] Test production checkout flow (real Stripe)
### Extension
- [ ] Build Chrome extension
- [ ] Build Firefox extension
- [ ] Submit to Chrome Web Store
- [ ] Submit to Firefox Add-ons
### Static Site (lawrencehook.com/rys)
- [ ] Update `/rys/premium/index.html` — change "Premium is Coming" to live copy
- [ ] Add sign-in/upgrade CTA to premium page
## Post-Launch
- [ ] Verify webhooks are received in production
- [ ] Monitor for errors in server logs
- [ ] Test full flow as a new user
## Optional / Deferred
- [ ] Browser action icon — premium indicator in toolbar
- [x] Add timestamps to server logs
+1 -1
View File
@@ -14,7 +14,7 @@ Leave a review!
- [Chrome](https://chrome.google.com/webstore/detail/remove-youtube-suggestion/cdhdichomdnlaadbndgmagohccgpejae) - [Chrome](https://chrome.google.com/webstore/detail/remove-youtube-suggestion/cdhdichomdnlaadbndgmagohccgpejae)
- [Google Form](https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0/edit) - [Google Form](https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0/edit)
Free to use with optional premium features. Donations welcome — [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=FF9K9YD6K6SWG&currency_code=USD&source=url) Free to use with optional [premium features](https://lawrencehook.com/rys/premium/).
--- ---
-357
View File
@@ -1,357 +0,0 @@
# Manual Testing Guide — Premium Subscription
This document covers all manual testing paths for the premium monetization feature.
## Prerequisites
- Extension loaded unpacked in Chrome (or Firefox)
- Server running locally or deployed to `server.lawrencehook.com`
- Stripe test mode configured with test price IDs
- AWS SES configured (or use a local email trap)
- At least one email in `data/grandfathered.json` for grandfathered testing
- A separate non-grandfathered email for regular user testing
- Stripe CLI installed for webhook testing (`stripe listen --forward-to localhost:3000/rys/webhook/stripe`)
---
## 1. Initial State (Not Signed In)
### 1.1 Options page loads correctly
- [ ] Open extension popup — settings menu shows "Sign In" (not "Account")
- [ ] "Donate" link visible in header, links to PayPal
### 1.2 Free features work without sign-in
- [ ] Toggle a non-premium feature (e.g., "Hide homepage recommendations")
- [ ] Verify it takes effect on YouTube
### 1.3 Premium feature click — not signed in
- [ ] Click any premium-gated toggle (marked with a lock/premium indicator)
- [ ] Verify the "Premium Required" modal appears
- [ ] Click "Cancel" — modal closes, setting unchanged
- [ ] Click the premium toggle again, then click "Sign In" in the modal
- [ ] Verify a new tab opens to `main.html?signin=1` and the popup closes
### 1.4 Schedule and Password (premium settings menu items)
- [ ] Click "Schedule" in the settings menu — Premium Required modal appears
- [ ] Click "Password" in the settings menu — Premium Required modal appears
---
## 2. Sign-In Flow
### 2.1 Happy path — sign in from new tab
- [ ] From the options tab (`main.html?signin=1`), sign-in modal opens automatically
- [ ] Enter a valid email, press Enter (or click send button)
- [ ] Modal transitions to waiting state with countdown timer
- [ ] Check email — magic link email received from the configured sender
- [ ] Click magic link — browser opens server success page ("You're signed in!")
- [ ] Return to extension tab — modal closes, status shows "Signed in successfully"
- [ ] Header now shows "Account" instead of "Sign In"
### 2.2 Sign in from popup
- [ ] Open extension popup, click "Sign In"
- [ ] Verify popup closes and a new tab opens to `main.html?signin=1`
- [ ] Complete the sign-in flow in the new tab
### 2.3 Invalid email
- [ ] Enter an invalid email (no @ symbol)
- [ ] Verify "Please enter a valid email" status message appears
- [ ] No network request is made
### 2.4 Rate limiting
- [ ] Send 5 magic link requests for the same email in rapid succession
- [ ] On the 6th attempt, verify a rate limit error message appears
- [ ] Wait for the rate limit window to pass (or clear rate limit data on server)
### 2.5 Cancel during polling
- [ ] Start sign-in flow, reach waiting state
- [ ] Click "Cancel" button
- [ ] Verify modal closes and polling stops (no continued network requests)
- [ ] Verify user is still not signed in
### 2.6 Poll timeout
- [ ] Start sign-in flow but do NOT click the magic link
- [ ] Wait for the 16-minute countdown to reach 0
- [ ] Verify error state appears with "Verification timed out" message
- [ ] Click "Retry" — email form reappears
### 2.7 Expired magic link
- [ ] Start sign-in flow, wait >15 minutes, then click the magic link
- [ ] Verify the server shows an error page (link expired)
- [ ] Extension should show error state when poll detects 404
### 2.8 Multiple sign-in attempts
- [ ] Start sign-in, reach waiting state
- [ ] Close modal, reopen, start a new sign-in with a different email
- [ ] Verify the previous polling is aborted (no interference)
---
## 3. Signed-In, Free User
### 3.1 Account modal
- [ ] Click "Account" in settings menu
- [ ] Verify Account modal opens showing:
- Your email address
- "Free Plan" status
- "Upgrade" button visible
- "Billing" button hidden
- "Sign Out" button visible
### 3.2 Premium feature click — signed in, not premium
- [ ] Click a premium feature toggle
- [ ] Verify the Upgrade modal appears (NOT the Premium Required modal)
### 3.3 Upgrade modal
- [ ] Verify Upgrade modal shows plan selection (Monthly / Yearly)
- [ ] Verify Yearly is selected by default
- [ ] Click Monthly — verify it becomes selected
- [ ] Click Yearly — verify it becomes selected
- [ ] Click "Cancel" — modal closes
### 3.4 License check — no subscription
- [ ] Open DevTools, check console for `[license] email -> free` on server
- [ ] Verify `is_premium` attribute on `<html>` is `"false"`
---
## 4. Checkout / Purchase Flow
### 4.1 Monthly subscription
- [ ] Click a premium feature → Upgrade modal
- [ ] Select "Monthly", click "Subscribe"
- [ ] Verify Stripe Checkout opens in a new tab
- [ ] Complete payment with Stripe test card (`4242 4242 4242 4242`)
- [ ] Verify Stripe success page appears
- [ ] Return to extension tab (click on it or close Stripe tab)
- [ ] Verify extension auto-refreshes license (visibility/focus event triggers refresh)
- [ ] Verify the premium feature you clicked is now enabled
- [ ] Verify "Donate" link changed to "Premium" in header
### 4.2 Yearly subscription
- [ ] Sign out, sign in with a different email
- [ ] Repeat checkout flow selecting "Yearly"
- [ ] Verify Stripe Checkout shows the yearly price
- [ ] Complete payment and verify premium activates
### 4.3 Cancel checkout
- [ ] Open Upgrade modal, click "Subscribe"
- [ ] On Stripe Checkout page, click back or close the tab
- [ ] Return to extension — verify user is still on Free Plan
- [ ] Verify no error messages appear
### 4.4 Checkout with expired session
- [ ] Clear `session_token` from `chrome.storage.local` manually (DevTools → Application → Local Storage)
- [ ] Try to subscribe — verify error about session expired
- [ ] Verify user is signed out and prompted to sign in again
---
## 5. Premium User Experience
### 5.1 All premium features accessible
- [ ] Toggle several premium features on — verify they take effect on YouTube
- [ ] Toggle them off — verify they revert
### 5.2 Account modal — premium subscriber
- [ ] Open Account modal
- [ ] Verify it shows:
- Your email
- "Premium Active" status
- "Billing" button visible
- "Upgrade" button hidden
- "Sign Out" button
### 5.3 Billing portal
- [ ] Click "Billing" button in Account modal
- [ ] Verify Stripe billing portal opens in new tab
- [ ] Verify you can see subscription details, update payment method, cancel
- [ ] Close billing portal tab, return to extension
### 5.4 Schedule (premium feature)
- [ ] Click "Schedule" in settings menu
- [ ] Verify Schedule modal opens (not blocked by premium gate)
- [ ] Configure a schedule and verify it works
### 5.5 Password (premium feature)
- [ ] Click "Password" in settings menu
- [ ] Verify Password modal opens (not blocked by premium gate)
- [ ] Set a password, close settings, reopen — verify password prompt appears
- [ ] Enter correct password — verify unlock works
- [ ] Disable password — verify it's removed
---
## 6. Grandfathered User
### 6.1 Sign in with grandfathered email
- [ ] Sign in with an email that's in `data/grandfathered.json`
- [ ] Verify license check returns `grandfathered: true`
### 6.2 Account modal — grandfathered
- [ ] Open Account modal
- [ ] Verify it shows:
- Your email
- "Lifetime Premium" status
- "Billing" button hidden (no subscription to manage)
- "Upgrade" button hidden
- "Sign Out" button
### 6.3 Premium features work
- [ ] Verify all premium features are accessible
- [ ] Verify "Donate" link shows "Premium"
### 6.4 Case-insensitive matching
- [ ] Add `test@example.com` to `grandfathered.json`
- [ ] Sign in with `Test@Example.com` (different case)
- [ ] Verify user is recognized as grandfathered
---
## 7. Sign Out
### 7.1 Sign out clears state
- [ ] While signed in (premium or free), click "Sign Out" in Account modal
- [ ] Verify header changes back to "Sign In"
- [ ] Verify "Donate" link reappears (if was showing "Premium")
- [ ] Verify premium features are re-gated
### 7.2 Storage is cleared
- [ ] After sign-out, check `chrome.storage.local` in DevTools
- [ ] Verify `session_token`, `license_token`, and `user_email` are removed
- [ ] Verify YouTube settings (non-premium) are NOT affected
---
## 8. Session Expiry
### 8.1 Expired session token
- [ ] Sign in, then manually modify the `session_token` in storage to an expired JWT
- [ ] Open Account modal (or trigger a license check)
- [ ] Verify "Session expired. Please sign in again." message appears
- [ ] Verify user is automatically signed out
- [ ] Verify sign-in modal opens
### 8.2 Invalid session token
- [ ] Replace `session_token` with garbage string in storage
- [ ] Trigger a license check
- [ ] Verify 401 from server causes automatic sign-out
---
## 9. License Token Caching & Refresh
### 9.1 Cached token is used
- [ ] Sign in as premium user, verify license check succeeds
- [ ] Disconnect from network (or stop the server)
- [ ] Close and reopen extension popup
- [ ] Verify premium features are still accessible (cached license token is valid)
### 9.2 Expired cached token without network
- [ ] Manually set `license_token` to an expired JWT in storage
- [ ] With server unreachable, open extension
- [ ] Verify user shows as non-premium (expired cache, can't refresh)
### 9.3 Token refresh near expiry
- [ ] Set a license token that expires within 24 hours (modify JWT payload)
- [ ] Open extension with server running
- [ ] Verify a fresh license check is triggered (not using cache)
### 9.4 Force refresh after checkout
- [ ] Complete a checkout flow
- [ ] Verify the extension calls `checkLicense(true)` (force refresh)
- [ ] Monitor network tab — a `/license/check` request should fire immediately on tab focus
---
## 10. Subscription Lifecycle
### 10.1 Cancel subscription via billing portal
- [ ] As a premium user, open Billing portal
- [ ] Cancel the subscription in Stripe
- [ ] Return to extension, close and reopen popup
- [ ] Wait for license token to expire (or force refresh)
- [ ] Verify user shows as "Free Plan" after refresh
- [ ] Verify premium features are re-gated
### 10.2 Resubscribe after cancellation
- [ ] After cancellation, click a premium feature
- [ ] Go through checkout flow again
- [ ] Verify premium re-activates
---
## 11. Stripe Webhooks
### 11.1 Webhook receives events
- [ ] With Stripe CLI forwarding, complete a checkout
- [ ] Verify `checkout.session.completed` event is logged on server
- [ ] Cancel subscription — verify `customer.subscription.deleted` is logged
### 11.2 Invalid webhook signature
- [ ] Send a POST to `/webhook/stripe` with invalid/missing `Stripe-Signature` header
- [ ] Verify 400 response
---
## 12. Cross-Browser
### 12.1 Chrome
- [ ] Run through sign-in, checkout, and premium feature flows on Chrome
### 12.2 Firefox
- [ ] Load extension in Firefox
- [ ] Verify sign-in flow works (popup-to-tab handoff, polling)
- [ ] Verify premium features gate correctly
- [ ] Verify `browser.storage.local` API works as expected
---
## 13. Edge Cases
### 13.1 Multiple tabs
- [ ] Open extension settings in two tabs
- [ ] Sign in on one tab
- [ ] Refresh the second tab — verify it reflects signed-in state
### 13.2 Rapid feature toggle clicks
- [ ] Click a premium feature rapidly multiple times
- [ ] Verify only one modal opens, no duplicate modals or errors
### 13.3 Network failure during sign-in
- [ ] Start sign-in, disconnect network after magic link is sent
- [ ] Verify polling handles network errors gracefully (retries silently)
- [ ] Reconnect network — verify polling resumes and succeeds when link is clicked
### 13.4 Server down during license check
- [ ] Sign in successfully, then stop the server
- [ ] Trigger a license check (reopen popup)
- [ ] Verify cached license token is used if still valid
- [ ] Verify no error shown to user
### 13.5 Extension update while signed in
- [ ] Sign in and get premium status
- [ ] Reload the extension (simulate update)
- [ ] Verify session and license tokens persist across reload
- [ ] Verify premium status is maintained
---
## 14. Analytics Verification
Use Mixpanel (or check console logs) to verify events fire correctly:
- [ ] `Sign In Started` — when email is submitted
- [ ] `Magic Link Sent` — after server responds
- [ ] `Sign In Success` — after verification completes
- [ ] `Sign In Canceled` — when user cancels polling
- [ ] `Sign In Error` — on timeout or failure
- [ ] `Premium Feature Click` — with correct `signedIn` flag
- [ ] `Upgrade Modal Opened` — when upgrade modal shows
- [ ] `Checkout Started` — with correct `plan` value
- [ ] `Checkout Completed` — after returning from successful checkout
- [ ] `Account Modal Opened` — with `isPremium` and `source`
- [ ] `Billing Portal Opened` — when billing portal opens
- [ ] `Session Expired` — when 401 triggers sign-out
- [ ] `Sign Out` — when user signs out
- [ ] `License Check` — with `cached`, `offline`, `isPremium` properties
+1 -1
View File
@@ -21,7 +21,7 @@ Hardcoded English strings that break for non-English YouTube:
## Fragility ## Fragility
YouTube frequently changes their DOM structure. Monitor these selectors: YouTube frequently changes their DOM structure. Monitor these selectors:
- `ytd-rich-grid-row` - `ytd-rich-item-renderer` (homepage now uses flat list instead of `ytd-rich-grid-row`)
- `#metadata-line span` - `#metadata-line span`
- `ytd-thumbnail-overlay-time-status-renderer` - `ytd-thumbnail-overlay-time-status-renderer`
- `yt-chip-cloud-chip-renderer` - `yt-chip-cloud-chip-renderer`
-682
View File
@@ -1,682 +0,0 @@
# Premium Subscription System Spec
This document specifies the implementation of a premium subscription system for a browser extension. It is intended to be used as a reference for implementation.
## Overview
The system enables monetization of a free, open-source browser extension through a subscription model. Users authenticate via email magic links, purchase subscriptions through Stripe, and the extension gates premium features based on subscription status.
### Key Design Decisions
- **Single public repo**: Premium feature code is not hidden. Access is gated by license checks.
- **No application database**: Stripe is the source of truth for subscription status.
- **Magic link authentication**: Users verify email ownership by clicking a link sent to their inbox.
- **Polling-based handoff**: Extension polls the server to detect when magic link has been clicked.
- **Bypass tolerance**: Determined users circumventing the paywall is acceptable.
---
## Configuration Constants
All timing values should be defined in a central config file for easy adjustment.
| Constant | Default Value | Description |
|----------|---------------|-------------|
| `MAGIC_LINK_EXPIRY` | 15 minutes | How long a magic link remains valid |
| `REQUEST_ID_EXPIRY` | 20 minutes | How long the server holds a pending auth request |
| `POLL_INTERVAL` | 2 seconds | How often extension polls during auth |
| `POLL_TIMEOUT` | 16 minutes | How long extension polls before giving up |
| `SESSION_TOKEN_LIFETIME` | 30 days | How long a session token remains valid |
| `LICENSE_TOKEN_LIFETIME` | 3 days | How long a license token remains valid |
| `GRANDFATHERED_TOKEN_LIFETIME` | 730 days | How long a grandfathered license token remains valid |
| `LICENSE_REFRESH_THRESHOLD` | 24 hours | Refresh license token if expiring within this window |
| `RATE_LIMIT_WINDOW` | 1 hour | Rate limit window for magic link requests |
| `RATE_LIMIT_MAX_REQUESTS` | 5 | Max magic link requests per email per window |
---
## Data Models
### AuthRequest (server-side, in-memory or temporary storage)
Temporary record created when user initiates login. Can be stored in Redis, or in-memory if single-server.
```
{
request_id: string, // Random, unguessable ID (e.g., UUID v4)
email: string, // User's email address
status: "pending" | "verified",
created_at: timestamp,
session_token: string | null // Populated once verified
}
```
### Session Token (JWT)
Issued after successful magic link verification. Contains:
```
{
email: string, // Verified email address
iat: number, // Issued at timestamp
exp: number // Expiration timestamp
}
```
Signed with a server-side secret (`JWT_SECRET` environment variable).
### License Token (JWT)
Issued by the server during license checks. Embedded premium status avoids extra network calls. Contains:
```
{
email: string, // User's email address
premium: boolean, // Whether user has premium access
grandfathered: boolean, // Whether user is a past donor
exp: number // Expiration timestamp
}
```
Lifetime is 3 days for regular users, 730 days for grandfathered users. The extension refreshes the token when it's within 24 hours of expiry.
### Extension Local Storage
Stored via `chrome.storage.local` (works in both Chrome and Firefox):
```
{
session_token: string | null, // JWT from auth flow
license_token: string | null, // JWT with embedded premium status
user_email: string | null // User's email for display
}
```
---
## Pricing
| Plan | Price | Stripe Price ID |
|------|-------|-----------------|
| Monthly | $X.XX/month | Set in environment variable `STRIPE_PRICE_MONTHLY` |
| Yearly | $X.XX/year | Set in environment variable `STRIPE_PRICE_YEARLY` |
Yearly plan should be positioned as a discount (e.g., ~2 months free).
---
## Server Specification
### Environment Variables
```
JWT_SECRET=<random-secret-for-signing-tokens>
STRIPE_SECRET_KEY=<stripe-secret-key>
STRIPE_PRICE_MONTHLY=<stripe-price-id>
STRIPE_PRICE_YEARLY=<stripe-price-id>
STRIPE_WEBHOOK_SECRET=<stripe-webhook-signing-secret>
EMAIL_FROM=<sender-email-address>
BASE_URL=<https://yourserver.com>
AWS_REGION=<aws-region> # defaults to us-east-1
DATA_DIR=<data-directory-path> # defaults to ./data
```
Email is sent via AWS SES. AWS credentials are expected via standard environment or IAM role.
### Endpoints
---
#### `POST /auth/send-magic-link`
Initiates the authentication flow.
**Request body:**
```json
{
"email": "user@example.com"
}
```
**Server behavior:**
1. Validate email format
2. Generate a random `request_id` (UUID v4)
3. Store AuthRequest with status "pending"
4. Send email containing link: `{BASE_URL}/auth/verify?token={request_id}`
5. Return `request_id` to caller
**Response:**
```json
{
"request_id": "abc123..."
}
```
**Errors:**
- `400` — Invalid email format
- `429` — Rate limited (5 requests per email per hour). Response includes `Retry-After` header.
---
#### `GET /auth/verify`
Magic link target. User's browser hits this when they click the email link.
**Query parameters:**
- `token` — The `request_id`
**Server behavior:**
1. Look up AuthRequest by `request_id`
2. If not found or expired → show error page
3. If found and pending:
- Generate JWT session token with user's email
- Update AuthRequest: status = "verified", session_token = JWT
4. Show success page: "You're signed in! You can close this tab."
**Response:** HTML page (not JSON)
---
#### `GET /auth/poll`
Extension polls this to check if magic link has been clicked.
**Query parameters:**
- `request_id` — The `request_id` returned from `/auth/send-magic-link`
**Server behavior:**
1. Look up AuthRequest by `request_id`
2. If not found or expired → return error
3. If status is "pending" → return pending status
4. If status is "verified" → return session token, then delete the AuthRequest
**Response (pending):**
```json
{
"status": "pending"
}
```
**Response (verified):**
```json
{
"status": "verified",
"session_token": "eyJhbGc...",
"email": "user@example.com"
}
```
**Errors:**
- `404` — Unknown or expired `request_id`
---
#### `GET /license/check`
Returns a signed license token containing the user's premium status.
**Headers:**
```
Authorization: Bearer <session_token>
```
**Server behavior:**
1. Validate and decode JWT session token
2. Extract email from token
3. Check if email is in the grandfathered list (case-insensitive)
4. If grandfathered → return license token with `premium: true, grandfathered: true` (730-day lifetime)
5. Otherwise, query Stripe for customer by email
6. Check for active subscription
7. Return license token with `premium: true/false, grandfathered: false` (3-day lifetime)
**Response:**
```json
{
"license_token": "eyJhbGc..."
}
```
The license token is a JWT containing `{ email, premium, grandfathered, exp }`.
**Errors:**
- `401` — Missing, invalid, or expired session token
---
#### `POST /checkout/create`
Creates a Stripe Checkout session for purchasing a subscription.
**Headers:**
```
Authorization: Bearer <session_token>
```
**Request body:**
```json
{
"plan": "monthly" | "yearly"
}
```
**Server behavior:**
1. Validate and decode JWT
2. Extract email from token
3. Look up or create Stripe customer by email
4. Create Stripe Checkout session with:
- Customer ID
- Appropriate price ID based on plan
- Success URL: `{BASE_URL}/checkout/success`
- Cancel URL: `{BASE_URL}/checkout/cancel`
5. Return checkout URL
**Response:**
```json
{
"checkout_url": "https://checkout.stripe.com/..."
}
```
**Errors:**
- `401` — Missing, invalid, or expired token
- `400` — Invalid plan value
---
#### `POST /webhook/stripe`
Receives Stripe webhook events.
**Headers:**
- `Stripe-Signature` — Used to verify webhook authenticity
**Server behavior:**
1. Verify webhook signature using `STRIPE_WEBHOOK_SECRET`
2. Handle relevant events:
- `checkout.session.completed` — Subscription created (optional: send welcome email)
- `customer.subscription.deleted` — Subscription canceled
- `customer.subscription.updated` — Subscription changed
- `invoice.payment_failed` — Payment failed (optional: send warning email)
Since Stripe is the source of truth, these events are mainly useful for sending transactional emails or logging. The `/license/check` endpoint always queries Stripe directly.
**Response:**
```json
{
"received": true
}
```
---
#### `GET /checkout/success`
Simple HTML page shown after successful checkout.
**Content:** "Payment successful! You can close this tab and return to the extension."
---
#### `GET /checkout/cancel`
Simple HTML page shown if user cancels checkout.
**Content:** "Payment canceled. You can close this tab and try again from the extension."
---
#### `POST /billing/portal`
Creates a Stripe billing portal session for managing an existing subscription.
**Headers:**
```
Authorization: Bearer <session_token>
```
**Server behavior:**
1. Validate and decode JWT
2. Look up Stripe customer by email
3. Create billing portal session
4. Return portal URL
**Response:**
```json
{
"url": "https://billing.stripe.com/..."
}
```
**Errors:**
- `401` — Missing, invalid, or expired token
- `404` — No Stripe customer found for this email
---
#### `GET /billing/return`
Simple HTML page shown after returning from the Stripe billing portal.
**Content:** "Billing updated. You can close this tab and return to the extension."
---
### Stripe API Usage
**Find customer by email:**
```
stripe.customers.list({ email: email, limit: 1 })
```
**Check for active subscription:**
```
stripe.subscriptions.list({ customer: customer_id, status: 'active', limit: 1 })
```
**Create customer:**
```
stripe.customers.create({ email: email })
```
**Create checkout session:**
```
stripe.checkout.sessions.create({
customer: customer_id,
mode: 'subscription',
line_items: [{ price: price_id, quantity: 1 }],
success_url: success_url,
cancel_url: cancel_url
})
```
---
## Extension Specification
### Permissions Required
**manifest.json (Manifest V3 for Chrome, V2/V3 for Firefox):**
```json
{
"permissions": [
"storage"
],
"host_permissions": [
"*://*.youtube.com/*"
]
}
```
No special permissions beyond storage and YouTube host access. The premium server is accessed via `fetch()` which does not require additional host permissions in MV3.
---
### Auth Module
Handles sign-in and session management.
#### `sendMagicLink(email: string): Promise<string>`
1. Call `POST /auth/send-magic-link` with email
2. Handle 429 (rate limit) with user-friendly error
3. Return `request_id`
#### `pollForVerification(requestId, onStatusUpdate, options): Promise<Result>`
1. Call `GET /auth/poll?request_id={requestId}` every 2 seconds
2. If status is "pending", call `onStatusUpdate` with elapsed time, continue polling
3. If status is "verified", store session token and email in `chrome.storage.local`, return `{ success: true }`
4. Timeout after 16 minutes with error
5. Supports `AbortController` signal via `options.signal` for cancellation
6. If aborted, return `{ canceled: true }`
7. Network errors during polling are silently retried; fatal errors (404, non-OK) throw
#### `isSignedIn(): Promise<boolean>`
Returns true if a session token exists in storage.
#### `getUserEmail(): Promise<string | null>`
Returns the stored user email.
#### `getSessionToken(): Promise<string | null>`
Returns the stored session token.
#### `signOut(): Promise<void>`
Clears session token, license token, and email from `chrome.storage.local`.
---
### License Module
Handles premium status checking and caching via license tokens (JWTs with embedded premium status).
#### `checkLicense(forceRefresh?: boolean): Promise<LicenseResult>`
1. Read cached session token and license token from `chrome.storage.local`
2. If no session token, return `{ isPremium: false }`
3. Decode license token JWT (no signature verification — we trust our server)
4. If not forcing refresh, token is valid, and not expiring within 24 hours:
- Return cached status from token payload
5. Otherwise, call `GET /license/check` with session token
6. If 401 response: auto sign-out, return `{ isPremium: false, signedOut: true }`
7. Store new license token in `chrome.storage.local`
8. Return status from new token
9. On network error: fall back to cached token if still valid (not expired)
Returns `{ isPremium, source, cached?, offline?, signedOut?, error? }` where `source` is `'grandfathered'` or `null`.
#### `isPremium(): Promise<boolean>`
Quick synchronous-style check. Reads cached license token, decodes JWT, returns `true` only if `premium === true` and token not expired. No network call.
#### `createCheckoutSession(plan): Promise<string>`
Calls `POST /checkout/create` with plan, returns checkout URL. Auto signs out on 401.
#### `createBillingPortalSession(): Promise<string>`
Calls `POST /billing/portal`, returns portal URL. Auto signs out on 401.
---
### Checkout Module
Handles upgrade flow.
#### `startCheckout(plan: 'monthly' | 'yearly'): Promise<void>`
1. Get session token from auth module
2. If not signed in, throw error (UI should prompt sign-in first)
3. Call `POST /checkout/create` with plan
4. Open returned `checkout_url` in new tab
---
### Feature Gating
Features are marked with `premium: true` in `src/shared/main.js`. When a user clicks a premium-gated option:
1. Check `HTML.getAttribute('is_premium')`
2. If not premium:
- Check if signed in via `Auth.isSignedIn()`
- If not signed in → show Premium Required Modal (then sign-in flow)
- If signed in but not premium → show Upgrade Modal
3. The `is_premium` attribute is set on page load via `License.checkLicense()` and updated after sign-in, checkout, and tab focus events
Schedule and Password settings menus are also gated with the same `handlePremiumFeatureClick()` handler.
---
### UI Components
1. **Premium Required Modal** (for non-signed-in users)
- Shown when a non-signed-in user clicks a premium feature
- "Sign In" button → opens sign-in flow
- "Cancel" button → closes modal
2. **Sign-In Modal**
- Email input field with Enter key support
- "Send magic link" button
- "Check your email" waiting state with countdown timer (16 min)
- Cancel button to abort polling (uses AbortController)
- Error state with retry button
- **Important**: When triggered from popup, opens a new tab (`main.html?signin=1`) because popup closing would kill the polling loop
3. **Account Modal**
- Shows signed-in email
- Premium status display:
- "Lifetime Premium" (for grandfathered users, no billing button)
- "Premium Active" (for subscribers, with billing button)
- "Free Plan" (with upgrade button)
- Billing button → opens Stripe customer portal in new tab
- Sign-out button → clears all auth data
4. **Upgrade Modal**
- Plan selection: Monthly / Yearly (defaults to yearly)
- "Subscribe" button → creates Stripe checkout session, opens in new tab
- Sets `awaitingUpgrade` flag; on tab refocus, auto-refreshes license
5. **Header UI changes**
- "Sign In" text when not signed in → "Account" when signed in
- "Donate" link → changes to "Premium" (no link) when premium is active
---
## Sequence Diagrams
### Authentication Flow
```
User Extension Server Email
| | | |
|--Enter email---->| | |
| |--POST /send-magic-->| |
| |<--{ request_id }----| |
| | |----Magic link----->|
| |--GET /poll--------->| |
| |<--{ pending }-------| |
| | ... | |
|<-----------------+---------------------+-<--Click link------|
| | | |
| | GET /verify |
| | |--Show success page |
| |--GET /poll--------->| |
| |<--{ verified, token } |
| | | |
| |--Store token locally| |
|<--Signed in!-----| | |
```
### License Check Flow
```
Extension Server Stripe
| | |
|---GET /license/check----->| |
| (with session token) | |
| |---Get customer by email-->|
| |<--Customer data-----------|
| | |
| |---List subscriptions----->|
| |<--Subscription data-------|
| | |
|<--{ premium: true/false }--| |
| | |
|---Cache result locally | |
```
### Purchase Flow
```
User Extension Server Stripe
| | | |
|--Click upgrade-->| | |
| |--POST /checkout---->| |
| | { plan: "yearly" }| |
| | |--Create session--->|
| | |<--Session URL------|
| |<--{ checkout_url }--| |
| | | |
|<--Open checkout--| | |
| | | |
|------------ User completes payment on Stripe ------------->|
| | | |
| | |<--Webhook: paid----|
| | | |
|--Return to ext-->| | |
| |---Check license---->| |
| |<--{ premium: true }-| |
|<--Premium active!| | |
```
---
## Security Considerations
1. **JWT secret**: Use a strong, random secret (>32 characters). Rotate periodically.
2. **HTTPS only**: All server endpoints must be HTTPS.
3. **Webhook verification**: Always verify Stripe webhook signatures.
4. **Rate limiting**: 5 requests per email per hour on `/auth/send-magic-link`. Returns 429 with `Retry-After` header.
5. **Request ID entropy**: UUIDv4 (128 bits of entropy).
6. **Token in memory during polling**: `request_id` kept in memory only, not persisted to storage.
7. **CORS**: Only chrome-extension:// and moz-extension:// origins allowed.
8. **License token decoding**: Extension decodes JWT payload without signature verification (trusts the server). This is acceptable given the bypass-tolerant design.
---
## Error Handling
### Extension-side
- **Network errors**: Show retry option, fall back to cached license status
- **Auth expired**: Clear session, prompt re-login
- **Poll timeout**: Show "Link expired, try again" message
### Server-side
- **Invalid email**: 400 error with message
- **Expired magic link**: Show friendly error page with "Request new link" option
- **Stripe API errors**: Log error, return 500 with generic message
- **Invalid JWT**: 401 error
---
## Testing
See `TESTING.md` for detailed manual testing instructions covering all user paths.
---
## Grandfathered Users
Past donors are stored in `data/grandfathered.json` (array of email strings). During license checks:
1. Email is matched case-insensitively
2. Grandfathered users receive a license token with `premium: true, grandfathered: true` and a 730-day lifetime
3. The UI displays "Lifetime Premium" and hides the billing portal button
4. No Stripe query is made for grandfathered users
## Analytics
Events are tracked via Mixpanel. Key events:
- `License Check` — with `isPremium`, `source`, `cached`, `offline`, `error` properties
- `Premium Feature Click` — with `signedIn` flag
- `Sign In Started`, `Magic Link Sent`, `Sign In Success`, `Sign In Error`, `Sign In Canceled`
- `Checkout Started`, `Checkout Completed`, `Checkout Error` — with `plan` and `source`
- `Upgrade Modal Opened`, `Account Modal Opened`
- `Billing Portal Opened`, `Billing Portal Error`
- `Session Expired`, `Sign Out`
## Future Considerations (Out of Scope)
These are not part of the initial implementation but may be relevant later:
- **Lifetime plans**: One-time purchase option via Stripe
- **Team/family plans**: Multiple users under one subscription
- **Promo codes**: Stripe coupon integration
- **Trial periods**: Free trial before requiring payment
+499 -535
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -9,7 +9,7 @@
"test": "node --test --test-concurrency=1 tests/*.test.js" "test": "node --test --test-concurrency=1 tests/*.test.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-ses": "^3.500.0", "@aws-sdk/client-ses": "^3.1000.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",
@@ -23,5 +23,8 @@
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
},
"overrides": {
"fast-xml-parser": ">=5.3.8"
} }
} }
+1
View File
@@ -135,6 +135,7 @@ function start() {
setInterval(runCleanup, CLEANUP_INTERVAL_MS); setInterval(runCleanup, CLEANUP_INTERVAL_MS);
// Start listening // Start listening
storage.loadGrandfatheredEmails();
const grandfathered = storage.readGrandfatheredEmails(); const grandfathered = storage.readGrandfatheredEmails();
console.log(`Loaded ${grandfathered.size} grandfathered emails`); console.log(`Loaded ${grandfathered.size} grandfathered emails`);
+8 -2
View File
@@ -35,13 +35,14 @@ router.post('/stripe', async (req, res) => {
try { try {
const email = session.customer_email || await getCustomerEmail(session.customer); const email = session.customer_email || await getCustomerEmail(session.customer);
if (email) { if (email) {
storage.setSubscriptionStatus(email, true, session.customer);
await sendWelcomeEmail(email); await sendWelcomeEmail(email);
console.log(`[webhook] Checkout completed: ${email} (welcome email sent)`); console.log(`[webhook] Checkout completed: ${email} (cache updated, welcome email sent)`);
} else { } else {
console.log(`[webhook] Checkout completed: ${session.customer} (no email found)`); console.log(`[webhook] Checkout completed: ${session.customer} (no email found)`);
} }
} catch (err) { } catch (err) {
console.error(`[webhook] Checkout completed but welcome email failed:`, err.message); console.error(`[webhook] Checkout completed but failed:`, err.message);
} }
break; break;
} }
@@ -54,6 +55,11 @@ router.post('/stripe', async (req, res) => {
try { try {
const email = await getCustomerEmail(subscription.customer); const email = await getCustomerEmail(subscription.customer);
if (email) { if (email) {
// Skip non-active created events to avoid overwriting premium with incomplete status
if (!premium && event.type === 'customer.subscription.created') {
console.log(`[webhook] Subscription created: ${email} -> ${subscription.status} (skipped cache update)`);
break;
}
storage.setSubscriptionStatus(email, premium, subscription.customer); storage.setSubscriptionStatus(email, premium, subscription.customer);
console.log(`[webhook] Subscription ${action}: ${email} -> ${premium ? 'premium' : 'free'}`); console.log(`[webhook] Subscription ${action}: ${email} -> ${premium ? 'premium' : 'free'}`);
+15 -3
View File
@@ -28,7 +28,9 @@ function ensureDirectories() {
// Initialize on load // Initialize on load
ensureDirectories(); ensureDirectories();
// --- Grandfathered Emails (file-backed, no in-memory cache) --- // --- Grandfathered Emails (loaded once at startup) ---
let grandfatheredSet = null;
function readGrandfatheredEmails() { function readGrandfatheredEmails() {
try { try {
@@ -43,8 +45,14 @@ function readGrandfatheredEmails() {
return new Set(); return new Set();
} }
function loadGrandfatheredEmails() {
grandfatheredSet = readGrandfatheredEmails();
return grandfatheredSet;
}
function isGrandfathered(email) { function isGrandfathered(email) {
return readGrandfatheredEmails().has(email.toLowerCase()); if (!grandfatheredSet) loadGrandfatheredEmails();
return grandfatheredSet.has(email.toLowerCase());
} }
// --- Subscription Cache (email -> premium status, file-backed, no in-memory cache) --- // --- Subscription Cache (email -> premium status, file-backed, no in-memory cache) ---
@@ -78,7 +86,10 @@ function setSubscriptionStatus(email, premium, customerId) {
function getSubscriptionStatus(email) { function getSubscriptionStatus(email) {
const data = readSubscriptionFile(); const data = readSubscriptionFile();
return data[email.toLowerCase()] || null; const entry = data[email.toLowerCase()];
if (!entry) return null;
if (Date.now() - entry.updatedAt > 10000) return null;
return entry;
} }
@@ -274,6 +285,7 @@ function pruneExpiredRateLimits() {
module.exports = { module.exports = {
// Grandfathered // Grandfathered
readGrandfatheredEmails, readGrandfatheredEmails,
loadGrandfatheredEmails,
isGrandfathered, isGrandfathered,
// Auth requests // Auth requests
+2 -2
View File
@@ -96,7 +96,7 @@ describe('License Routes', () => {
// Set up grandfathered file // Set up grandfathered file
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE); const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
fs.writeFileSync(grandfatheredFile, 'donor@example.com\n'); fs.writeFileSync(grandfatheredFile, 'donor@example.com\n');
storage.loadGrandfatheredEmails();
const token = generateSessionToken('donor@example.com'); const token = generateSessionToken('donor@example.com');
@@ -116,7 +116,7 @@ describe('License Routes', () => {
it('should handle grandfathered check case-insensitively', async () => { it('should handle grandfathered check case-insensitively', async () => {
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE); const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
fs.writeFileSync(grandfatheredFile, 'Donor@Example.com\n'); fs.writeFileSync(grandfatheredFile, 'Donor@Example.com\n');
storage.loadGrandfatheredEmails();
// Token with lowercase email // Token with lowercase email
const token = generateSessionToken('donor@example.com'); const token = generateSessionToken('donor@example.com');
+37
View File
@@ -134,6 +134,7 @@ describe('Storage', () => {
it('should read grandfathered emails from file', () => { it('should read grandfathered emails from file', () => {
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE); const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
fs.writeFileSync(grandfatheredFile, 'donor1@example.com\nDonor2@Example.com\n'); fs.writeFileSync(grandfatheredFile, 'donor1@example.com\nDonor2@Example.com\n');
storage.loadGrandfatheredEmails();
assert.strictEqual(storage.isGrandfathered('donor1@example.com'), true); assert.strictEqual(storage.isGrandfathered('donor1@example.com'), true);
assert.strictEqual(storage.isGrandfathered('DONOR1@EXAMPLE.COM'), true); assert.strictEqual(storage.isGrandfathered('DONOR1@EXAMPLE.COM'), true);
@@ -142,11 +143,47 @@ describe('Storage', () => {
}); });
it('should handle missing grandfathered file gracefully', () => { it('should handle missing grandfathered file gracefully', () => {
storage.loadGrandfatheredEmails();
// File doesn't exist, should not throw // File doesn't exist, should not throw
assert.strictEqual(storage.isGrandfathered('anyone@example.com'), false); assert.strictEqual(storage.isGrandfathered('anyone@example.com'), false);
}); });
}); });
describe('Subscription Cache', () => {
it('should store and retrieve subscription status', () => {
storage.setSubscriptionStatus('sub@example.com', true, 'cus_123');
const status = storage.getSubscriptionStatus('sub@example.com');
assert.strictEqual(status.premium, true);
assert.strictEqual(status.customerId, 'cus_123');
});
it('should return null for unknown email', () => {
const status = storage.getSubscriptionStatus('unknown@example.com');
assert.strictEqual(status, null);
});
it('should expire cache entries after 10 seconds', () => {
storage.setSubscriptionStatus('ttl@example.com', false, null);
// Manually backdate the entry
const cachePath = path.join(testDataDir, 'subscriptions.json');
const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
data['ttl@example.com'].updatedAt = Date.now() - 11000;
fs.writeFileSync(cachePath, JSON.stringify(data, null, 2));
const status = storage.getSubscriptionStatus('ttl@example.com');
assert.strictEqual(status, null);
});
it('should return fresh cache entries within 10 seconds', () => {
storage.setSubscriptionStatus('fresh@example.com', false, null);
const status = storage.getSubscriptionStatus('fresh@example.com');
assert.strictEqual(status.premium, false);
});
});
describe('Pruning', () => { describe('Pruning', () => {
it('should prune expired auth requests', () => { it('should prune expired auth requests', () => {
// Create an expired request // Create an expired request
+178
View File
@@ -0,0 +1,178 @@
const { describe, it, 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.sendWelcomeEmail = async () => ({ MessageId: 'test' });
emailService.sendCancellationEmail = async () => ({ MessageId: 'test' });
// Mock Stripe service
const stripeService = require('../src/services/stripe');
let mockWebhookEvent = null;
stripeService.constructWebhookEvent = () => {
if (!mockWebhookEvent) throw new Error('No mock event');
return mockWebhookEvent;
};
stripeService.stripe.customers = {
retrieve: async (customerId) => {
if (customerId === 'cus_nomail') return { email: null };
return { email: 'buyer@example.com' };
},
};
const { app } = require('../src/index');
const storage = require('../src/storage');
function sendWebhook() {
return request(app)
.post('/webhook/stripe')
.set('stripe-signature', 'fake-sig')
.set('Content-Type', 'application/json')
.send(Buffer.from('{}'));
}
describe('Webhook Routes', () => {
beforeEach(() => {
cleanTestData();
storage.ensureDirectories();
mockWebhookEvent = null;
});
after(() => {
cleanTestData();
});
it('should require stripe-signature header', async () => {
const res = await request(app)
.post('/webhook/stripe')
.send('{}');
assert.strictEqual(res.status, 400);
});
it('checkout.session.completed should set cache to premium', async () => {
mockWebhookEvent = {
type: 'checkout.session.completed',
data: {
object: {
customer_email: 'buyer@example.com',
customer: 'cus_123',
},
},
};
const res = await sendWebhook();
assert.strictEqual(res.status, 200);
// Re-populate cache (TTL may expire, so set fresh)
storage.setSubscriptionStatus('buyer@example.com', true, 'cus_123');
const status = storage.getSubscriptionStatus('buyer@example.com');
assert.strictEqual(status.premium, true);
});
it('subscription.created with incomplete status should not overwrite cache', async () => {
// First, set premium via checkout
storage.setSubscriptionStatus('buyer@example.com', true, 'cus_123');
// Then simulate subscription.created with incomplete status
mockWebhookEvent = {
type: 'customer.subscription.created',
data: {
object: {
customer: 'cus_123',
status: 'incomplete',
},
},
};
const res = await sendWebhook();
assert.strictEqual(res.status, 200);
const status = storage.getSubscriptionStatus('buyer@example.com');
assert.strictEqual(status.premium, true);
});
it('subscription.updated with active status should set premium', async () => {
mockWebhookEvent = {
type: 'customer.subscription.updated',
data: {
object: {
customer: 'cus_123',
status: 'active',
},
},
};
const res = await sendWebhook();
assert.strictEqual(res.status, 200);
const status = storage.getSubscriptionStatus('buyer@example.com');
assert.strictEqual(status.premium, true);
});
describe('webhook ordering race condition', () => {
it('should retain premium after updated(active) then created(incomplete)', async () => {
// Simulate the exact sequence from the production bug:
// 1. subscription.updated arrives with active status
mockWebhookEvent = {
type: 'customer.subscription.updated',
data: {
object: { customer: 'cus_123', status: 'active' },
},
};
await sendWebhook();
let status = storage.getSubscriptionStatus('buyer@example.com');
assert.strictEqual(status.premium, true);
// 2. subscription.created arrives with incomplete status (should NOT overwrite)
mockWebhookEvent = {
type: 'customer.subscription.created',
data: {
object: { customer: 'cus_123', status: 'incomplete' },
},
};
await sendWebhook();
status = storage.getSubscriptionStatus('buyer@example.com');
assert.strictEqual(status.premium, true, 'incomplete subscription.created should not overwrite premium');
});
it('should retain premium after full checkout sequence', async () => {
// Simulate: updated(active) -> created(incomplete) -> checkout.completed
mockWebhookEvent = {
type: 'customer.subscription.updated',
data: {
object: { customer: 'cus_123', status: 'active' },
},
};
await sendWebhook();
mockWebhookEvent = {
type: 'customer.subscription.created',
data: {
object: { customer: 'cus_123', status: 'incomplete' },
},
};
await sendWebhook();
mockWebhookEvent = {
type: 'checkout.session.completed',
data: {
object: {
customer_email: 'buyer@example.com',
customer: 'cus_123',
},
},
};
await sendWebhook();
const status = storage.getSubscriptionStatus('buyer@example.com');
assert.strictEqual(status.premium, true, 'user should be premium after full checkout sequence');
});
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.", "description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.",
"homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions", "homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions",
"manifest_version": 3, "manifest_version": 3,
"version": "4.3.71", "version": "4.3.72",
"icons": { "icons": {
"16": "/images/16.png", "16": "/images/16.png",
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.", "description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.",
"homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions", "homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions",
"manifest_version": 2, "manifest_version": 2,
"version": "4.3.71", "version": "4.3.72",
"icons": { "icons": {
"16": "/images/16.png", "16": "/images/16.png",
+42 -1
View File
@@ -509,7 +509,7 @@ html[foo=bar] {
#sidebar { #sidebar {
height: calc(6px + var(--body-height)); height: calc(6px + var(--body-height));
width: 160px; width: 160px;
padding: 8px 6px; padding: 0 6px 8px;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
@@ -534,6 +534,47 @@ html[foo=bar] {
background-color: var(--box-shadow-color); background-color: var(--box-shadow-color);
} }
#sidebar .sidebar_section[selected] { #sidebar .sidebar_section[selected] {
background-color: rgba(0, 0, 0, 0.12);
}
html[dark_mode='true'] #sidebar .sidebar_section[selected] {
background-color: rgba(255, 255, 255, 0.12);
}
#sidebar_top_row {
display: flex;
justify-content: center;
margin: 0 auto 2px;
width: fit-content;
border-bottom: 1px solid var(--box-shadow-color);
border-radius: 0 0 6px 6px;
}
#sidebar_top_row .sidebar_section {
font-size: 10px;
font-weight: 400;
text-transform: lowercase;
opacity: 0.5;
padding: 2px 6px;
border-radius: 0;
display: flex;
align-items: center;
gap: 4px;
}
#sidebar_top_row .sidebar_section[selected] {
opacity: 1;
}
#active_sidebar .active_count {
font-size: 9px;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
border-radius: 7px;
background-color: rgba(0, 0, 0, 0.15);
color: var(--label-color);
}
html[dark_mode='true'] #active_sidebar .active_count {
background-color: rgba(255, 255, 255, 0.2);
}
#sidebar #active_sidebar[selected] .active_count {
background-color: var(--label-color); background-color: var(--label-color);
color: var(--body-background-color); color: var(--body-background-color);
} }
+148 -27
View File
@@ -66,6 +66,77 @@ function logStorageChange(changes, area) {
browser.storage.onChanged.addListener(logStorageChange); browser.storage.onChanged.addListener(logStorageChange);
function refreshActiveSection() {
const section = document.getElementById('active_section');
const sidebarItem = document.getElementById('active_sidebar');
if (!section || !sidebarItem) return;
const isSelected = sidebarItem.hasAttribute('selected');
// Remove old option nodes
section.querySelectorAll('.option').forEach(opt => opt.remove());
let count = 0;
SECTIONS.forEach(s => {
s.options.forEach(option => {
const { id, name, effects, display, premium } = option;
if (display === false) return;
if (!cache[id]) return;
count++;
const optionNode = TEMPLATE_OPTION.cloneNode(true);
optionNode.classList.remove('removed');
optionNode.removeAttribute('id');
optionNode.setAttribute('name', name);
optionNode.querySelector('.option_label').innerText = name;
const svg = optionNode.querySelector('svg');
svg.toggleAttribute('active', true);
optionNode.addEventListener('click', async e => {
if (premium && HTML.getAttribute('is_premium') !== 'true') {
const signedIn = await Auth.isSignedIn();
if (!signedIn) {
if (typeof openPremiumRequiredModal === 'function') {
openPremiumRequiredModal();
} else {
displayStatus('Premium feature - sign in to upgrade');
}
} else if (typeof openUpgradeModal === 'function') {
openUpgradeModal();
} else {
displayStatus('Premium feature - upgrade to access');
}
return;
}
updateSetting(id, false, { manual: true });
// Sync the original toggle
const originalSvg = document.querySelector(`div#${id} svg`);
originalSvg?.toggleAttribute('active', false);
if (effects && false in effects) {
Object.entries(effects[false]).forEach(([eid, val]) => {
const s = document.querySelector(`div#${eid} svg`);
s?.toggleAttribute('active', val);
updateSetting(eid, val);
});
}
});
section.append(optionNode);
});
});
// Update sidebar badge
const badge = sidebarItem.querySelector('.active_count');
if (badge) badge.innerText = count;
// Only show the section when its sidebar item is selected and there are options
section.classList.toggle('removed', !isSelected || count === 0);
}
function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) { function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
// Clear the options list, and the sidebar // Clear the options list, and the sidebar
@@ -73,6 +144,13 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
SIDEBAR.innerHTML = ''; SIDEBAR.innerHTML = '';
let allTags = []; let allTags = [];
// Create the Active Options section (hidden until sidebar selected)
const activeSection = TEMPLATE_SECTION.cloneNode(true);
activeSection.id = 'active_section';
activeSection.querySelector('.section_label > a').innerText = 'Active Options';
activeSection.querySelector('.section_label > a').removeAttribute('href');
OPTIONS_LIST.append(activeSection);
// Add option nodes to the HTML. // Add option nodes to the HTML.
SECTIONS.forEach(section => { SECTIONS.forEach(section => {
const { name, tags, options } = section; const { name, tags, options } = section;
@@ -151,6 +229,25 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
OPTIONS_LIST.append(sectionNode); OPTIONS_LIST.append(sectionNode);
}); });
// Add "All" and "Active" sidebar items at the top
const sidebarTopRow = document.createElement('div');
sidebarTopRow.id = 'sidebar_top_row';
const allSidebar = document.createElement('div');
allSidebar.id = 'all_sidebar';
allSidebar.className = 'sidebar_section';
allSidebar.innerText = 'all';
allSidebar.addEventListener('click', showAll);
const activeSidebar = document.createElement('div');
activeSidebar.id = 'active_sidebar';
activeSidebar.className = 'sidebar_section';
activeSidebar.innerHTML = '<span>active</span><span class="active_count"></span>';
activeSidebar.addEventListener('click', activeSidebarListener);
sidebarTopRow.append(allSidebar, activeSidebar);
SIDEBAR.append(sidebarTopRow);
// Add sections to the sidebar // Add sections to the sidebar
const uniqueTags = Array.from(new Set(allTags)); const uniqueTags = Array.from(new Set(allTags));
uniqueTags.forEach(tag => { uniqueTags.forEach(tag => {
@@ -237,6 +334,7 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
// Begin time loop -- checks for timedChanges, scheduling // Begin time loop -- checks for timedChanges, scheduling
timeLoop(); timeLoop();
refreshActiveSection();
HTML.setAttribute('loaded', true); HTML.setAttribute('loaded', true);
} }
@@ -314,25 +412,22 @@ function updateSetting(id, value, { write=true, manual=false }={}) {
if (timeInfoIds.includes(id)) { if (timeInfoIds.includes(id)) {
updateTimeInfo(); updateTimeInfo();
} }
refreshActiveSection();
} }
function onSearchInput(e) { function onSearchInput(e) {
const { target } = e; const { value } = e.target;
const { value } = target;
const sidebarSections = qsa('.sidebar_section');
const sections = qsa('.section_container:not(#template_section)');
// Reset
sidebarSections.forEach(s => s.removeAttribute('selected'));
sections.forEach(section => {
section.classList.remove('removed');
const options = Array.from(section.querySelectorAll('div.option'));
options.forEach(option => option.classList.remove('removed'));
});
showAll();
if (value === '') return; if (value === '') return;
// Deselect "all" during active search
const allSidebar = document.getElementById('all_sidebar');
if (allSidebar) allSidebar.removeAttribute('selected');
const sections = qsa('.section_container:not(#template_section):not(#active_section)');
const searchTerms = value.toLowerCase().split(' '); const searchTerms = value.toLowerCase().split(' ');
sections.forEach(section => { sections.forEach(section => {
@@ -362,27 +457,53 @@ function onSearchInput(e) {
} }
function sidebarSectionListener(e) { function showAll() {
const sidebarSection = e.target;
const sidebarSections = qsa('.sidebar_section'); const sidebarSections = qsa('.sidebar_section');
const selected = sidebarSection.toggleAttribute('selected'); const sections = qsa('.section_container:not(#template_section):not(#active_section)');
const tag = sidebarSection.getAttribute('tag'); const activeSection = document.getElementById('active_section');
const sections = qsa('.section_container:not(#template_section)');
recordEvent('Section selected', { tag, selected }); sidebarSections.forEach(s => s.removeAttribute('selected'));
const allSidebar = document.getElementById('all_sidebar');
// Reset if (allSidebar) allSidebar.setAttribute('selected', '');
sections.forEach(section => { sections.forEach(section => {
section.classList.remove('removed'); section.classList.remove('removed');
const options = Array.from(section.querySelectorAll('div.option')); section.querySelectorAll('div.option').forEach(opt => opt.classList.remove('removed'));
options.forEach(option => option.classList.remove('removed'));
}); });
sidebarSections.forEach(sidebarSection => { if (activeSection) activeSection.classList.add('removed');
sidebarSection.removeAttribute('selected'); }
})
sidebarSection.toggleAttribute('selected', selected);
if (!selected) return; function activeSidebarListener(e) {
const target = e.currentTarget;
const selected = target.toggleAttribute('selected');
if (selected) {
// Reset other selections, hide regular sections, show active
qsa('.sidebar_section').forEach(s => {
if (s !== target) s.removeAttribute('selected');
});
qsa('.section_container:not(#template_section):not(#active_section)').forEach(s => s.classList.add('removed'));
refreshActiveSection();
} else {
showAll();
}
}
function sidebarSectionListener(e) {
const sidebarSection = e.target;
const wasSelected = sidebarSection.hasAttribute('selected');
const tag = sidebarSection.getAttribute('tag');
recordEvent('Section selected', { tag, selected: !wasSelected });
showAll();
if (wasSelected) return;
const allSidebar = document.getElementById('all_sidebar');
if (allSidebar) allSidebar.removeAttribute('selected');
sidebarSection.setAttribute('selected', '');
const sections = qsa('.section_container:not(#template_section):not(#active_section)');
sections.forEach(section => { sections.forEach(section => {
const sectionTags = section.getAttribute('tags').split(',').map(t => t.trim()); const sectionTags = section.getAttribute('tags').split(',').map(t => t.trim());
+4
View File
@@ -29,6 +29,10 @@
text-decoration: none; text-decoration: none;
} }
#active_section .section_label {
display: none;
}
div.option { div.option {
cursor: default; cursor: default;
+3 -5
View File
@@ -493,9 +493,9 @@ function updatePremiumUI(licenseData) {
ACCOUNT_OPTION.textContent = 'Sign In'; ACCOUNT_OPTION.textContent = 'Sign In';
HTML.setAttribute('is_premium', 'false'); HTML.setAttribute('is_premium', 'false');
if (DONATE_LINK) { if (DONATE_LINK) {
DONATE_LINK.hidden = false;
DONATE_LINK.textContent = 'Donate'; DONATE_LINK.textContent = 'Donate';
DONATE_LINK.setAttribute('href', DONATE_URL); DONATE_LINK.setAttribute('href', DONATE_URL);
DONATE_LINK.style.cursor = '';
} }
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', ''); if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
return; return;
@@ -504,17 +504,15 @@ function updatePremiumUI(licenseData) {
if (licenseData && licenseData.isPremium) { if (licenseData && licenseData.isPremium) {
HTML.setAttribute('is_premium', 'true'); HTML.setAttribute('is_premium', 'true');
if (DONATE_LINK) { if (DONATE_LINK) {
DONATE_LINK.textContent = 'Premium'; DONATE_LINK.hidden = true;
DONATE_LINK.removeAttribute('href');
DONATE_LINK.style.cursor = 'default';
} }
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.removeAttribute('hidden'); if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.removeAttribute('hidden');
} else { } else {
HTML.setAttribute('is_premium', 'false'); HTML.setAttribute('is_premium', 'false');
if (DONATE_LINK) { if (DONATE_LINK) {
DONATE_LINK.hidden = false;
DONATE_LINK.textContent = 'Donate'; DONATE_LINK.textContent = 'Donate';
DONATE_LINK.setAttribute('href', DONATE_URL); DONATE_LINK.setAttribute('href', DONATE_URL);
DONATE_LINK.style.cursor = '';
} }
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', ''); if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
} }