From 6ed3ad02910ea4d39dc578b8e87de092b76508ff Mon Sep 17 00:00:00 2001 From: Lawrence Hook Date: Sun, 15 Feb 2026 19:08:03 -0500 Subject: [PATCH] Remove obsolete docs and update TODO for YouTube DOM change Co-Authored-By: Claude Opus 4.6 --- ISSUES.md | 8 - LAUNCH_CHECKLIST.md | 40 -- TODO.md | 2 +- premium-subscription-spec.md | 682 ----------------------------------- 4 files changed, 1 insertion(+), 731 deletions(-) delete mode 100644 ISSUES.md delete mode 100644 LAUNCH_CHECKLIST.md delete mode 100644 premium-subscription-spec.md diff --git a/ISSUES.md b/ISSUES.md deleted file mode 100644 index 2f9d126..0000000 --- a/ISSUES.md +++ /dev/null @@ -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 diff --git a/LAUNCH_CHECKLIST.md b/LAUNCH_CHECKLIST.md deleted file mode 100644 index 8fa1e92..0000000 --- a/LAUNCH_CHECKLIST.md +++ /dev/null @@ -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 diff --git a/TODO.md b/TODO.md index 5c3432a..c740cf2 100644 --- a/TODO.md +++ b/TODO.md @@ -21,7 +21,7 @@ Hardcoded English strings that break for non-English YouTube: ## Fragility 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` - `ytd-thumbnail-overlay-time-status-renderer` - `yt-chip-cloud-chip-renderer` diff --git a/premium-subscription-spec.md b/premium-subscription-spec.md deleted file mode 100644 index 83075a4..0000000 --- a/premium-subscription-spec.md +++ /dev/null @@ -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= -STRIPE_SECRET_KEY= -STRIPE_PRICE_MONTHLY= -STRIPE_PRICE_YEARLY= -STRIPE_WEBHOOK_SECRET= -EMAIL_FROM= -BASE_URL= -AWS_REGION= # defaults to us-east-1 -DATA_DIR= # 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 -``` - -**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 -``` - -**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 -``` - -**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` - -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` - -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` - -Returns true if a session token exists in storage. - -#### `getUserEmail(): Promise` - -Returns the stored user email. - -#### `getSessionToken(): Promise` - -Returns the stored session token. - -#### `signOut(): Promise` - -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` - -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` - -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` - -Calls `POST /checkout/create` with plan, returns checkout URL. Auto signs out on 401. - -#### `createBillingPortalSession(): Promise` - -Calls `POST /billing/portal`, returns portal URL. Auto signs out on 401. - ---- - -### Checkout Module - -Handles upgrade flow. - -#### `startCheckout(plan: 'monthly' | 'yearly'): Promise` - -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