diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..37b8d44 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Tests + +on: [push, pull_request] + +jobs: + extension-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm test + working-directory: tests + + server-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + working-directory: server + - run: npm test + working-directory: server diff --git a/.gitignore b/.gitignore index f198e24..5bdcd15 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ web-ext-artifacts/ extension.zip manifest.json _scratch/ +node_modules/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..755d519 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,521 @@ +# 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 `` 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 diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..2f9d126 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,8 @@ +# 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 new file mode 100644 index 0000000..8fa1e92 --- /dev/null +++ b/LAUNCH_CHECKLIST.md @@ -0,0 +1,40 @@ +# 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/README.md b/README.md index 3e0e7e4..7b71d24 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ --- ### What it does -This extension aims to make YouTube less engaging and more configurable. It provides options to hide recommended videos and to customize the user interface. +A browser extension that lets you hide recommendations, customize the interface, and take control of your YouTube experience. --- @@ -14,14 +14,12 @@ Leave a review! - [Chrome](https://chrome.google.com/webstore/detail/remove-youtube-suggestion/cdhdichomdnlaadbndgmagohccgpejae) - [Google Form](https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0/edit) -Completely free. Donations welcome — [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=FF9K9YD6K6SWG¤cy_code=USD&source=url) +Free to use with optional premium features. Donations welcome — [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=FF9K9YD6K6SWG¤cy_code=USD&source=url) --- ### Why I made it -Motivated by an attempt to stymie the YouTube rabbit hole. - -The YouTube recommendation algorithm optimizes for the most _engaging_ videos, regardless of whether or not you are interested. Persistent exposure to these suggestions can result in a waste of your time. Download this add-on and remove unwanted suggestions as you please. +The attention economy has gotten out of control. YouTube's recommendation algorithm optimizes for the most _engaging_ videos, not the ones you actually want to watch — and it's easy to lose hours to the rabbit hole. This extension lets you cut the noise and use YouTube on your own terms. Available for download at the links below: - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/remove-youtube-s-suggestions) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..6fb18f3 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,357 @@ +# 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 `` 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 diff --git a/premium-subscription-spec.md b/premium-subscription-spec.md new file mode 100644 index 0000000..83075a4 --- /dev/null +++ b/premium-subscription-spec.md @@ -0,0 +1,682 @@ +# 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 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..bde35d8 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,18 @@ +# Server +PORT=3000 +BASE_URL=https://yourserver.com + +# JWT +JWT_SECRET=your-random-secret-here-min-32-chars + +# Stripe +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PRICE_MONTHLY=price_... +STRIPE_PRICE_YEARLY=price_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# AWS SES +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +EMAIL_FROM=noreply@yourdomain.com diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..098def3 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +data/ +tests/test-data/ diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..b7a796d --- /dev/null +++ b/server/README.md @@ -0,0 +1,72 @@ +# RYS Premium Server + +Backend server for the premium subscription system. + +## Quick Start + +```bash +# Install dependencies +npm install + +# Copy env and fill in values +cp .env.example .env + +# Run tests +npm test + +# Start development server +npm run dev + +# Start production server +npm start +``` + +## API Endpoints + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/health` | GET | No | Health check | +| `/auth/send-magic-link` | POST | No | Send magic link email | +| `/auth/verify` | GET | No | Magic link target (HTML) | +| `/auth/poll` | GET | No | Poll for verification status | +| `/license/check` | GET | Yes | Check premium status | +| `/checkout/create` | POST | Yes | Create Stripe checkout session | +| `/checkout/success` | GET | No | Success page (HTML) | +| `/checkout/cancel` | GET | No | Cancel page (HTML) | +| `/billing/portal` | POST | Yes | Create billing portal session | +| `/billing/return` | GET | No | Return page (HTML) | +| `/webhook/stripe` | POST | No* | Stripe webhook | + +*Authenticated via Stripe signature + +## File Structure + +``` +server/ +├── src/ +│ ├── index.js # Entry point +│ ├── config.js # Configuration +│ ├── routes/ # Express routes +│ ├── services/ # Stripe, JWT, email +│ └── storage/ # File-based storage +├── data/ +│ ├── grandfathered.json # Donor emails (gitignored) +│ ├── auth-requests/ # Pending auth (auto-created) +│ └── rate-limits/ # Rate limit counters (auto-created) +├── tests/ +└── package.json +``` + +## Grandfathered Users + +Past donors get lifetime premium. Store emails in `data/grandfathered.json`: + +```json +["donor1@example.com", "donor2@example.com"] +``` + +This file is gitignored. See `data.example/` for format. + +## Full Documentation + +See [DEPLOYMENT.md](../DEPLOYMENT.md) for complete setup, deployment, and testing instructions. diff --git a/server/data.example/grandfathered.json b/server/data.example/grandfathered.json new file mode 100644 index 0000000..a79ad48 --- /dev/null +++ b/server/data.example/grandfathered.json @@ -0,0 +1,4 @@ +[ + "donor1@example.com", + "donor2@example.com" +] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..63bd833 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2630 @@ +{ + "name": "rys-premium-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rys-premium-server", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-ses": "^3.500.0", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.1", + "stripe": "^14.12.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "supertest": "^6.3.4" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-ses": { + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.982.0.tgz", + "integrity": "sha512-FMfZsrdevWomqEwLWaW5Jfq+8jRbROQe8sbEANVTNPYBfXvnd8TxPs/09h7TgFjtSB7hsjUv8Ja6IjeMV5HHPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.6", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.982.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.982.0.tgz", + "integrity": "sha512-qJrIiivmvujdGqJ0ldSUvhN3k3N7GtPesoOI1BSt0fNXovVnMz4C/JmnkhZihU7hJhDvxJaBROLYTU+lpild4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.6", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.982.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.6.tgz", + "integrity": "sha512-pz4ZOw3BLG0NdF25HoB9ymSYyPbMiIjwQJ2aROXRhAzt+b+EOxStfFv8s5iZyP6Kiw7aYhyWxj5G3NhmkoOTKw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.4", + "@smithy/core": "^3.22.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.4.tgz", + "integrity": "sha512-/8dnc7+XNMmViEom2xsNdArQxQPSgy4Z/lm6qaFPTrMFesT1bV3PsBhb19n09nmxHdrtQskYmViddUIjUQElXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.6.tgz", + "integrity": "sha512-5ERWqRljiZv44AIdvIRQ3k+EAV0Sq2WeJHvXuK7gL7bovSxOf8Al7MLH7Eh3rdovH4KHFnlIty7J71mzvQBl5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.4.tgz", + "integrity": "sha512-eRUg+3HaUKuXWn/lEMirdiA5HOKmEl8hEHVuszIDt2MMBUKgVX5XNGmb3XmbgU17h6DZ+RtjbxQpjhz3SbTjZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-env": "^3.972.4", + "@aws-sdk/credential-provider-http": "^3.972.6", + "@aws-sdk/credential-provider-login": "^3.972.4", + "@aws-sdk/credential-provider-process": "^3.972.4", + "@aws-sdk/credential-provider-sso": "^3.972.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.4", + "@aws-sdk/nested-clients": "3.982.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.4.tgz", + "integrity": "sha512-nLGjXuvWWDlQAp505xIONI7Gam0vw2p7Qu3P6on/W2q7rjJXtYjtpHbcsaOjJ/pAju3eTvEQuSuRedcRHVQIAQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/nested-clients": "3.982.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.5.tgz", + "integrity": "sha512-VWXKgSISQCI2GKN3zakTNHSiZ0+mux7v6YHmmbLQp/o3fvYUQJmKGcLZZzg2GFA+tGGBStplra9VFNf/WwxpYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.4", + "@aws-sdk/credential-provider-http": "^3.972.6", + "@aws-sdk/credential-provider-ini": "^3.972.4", + "@aws-sdk/credential-provider-process": "^3.972.4", + "@aws-sdk/credential-provider-sso": "^3.972.4", + "@aws-sdk/credential-provider-web-identity": "^3.972.4", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.4.tgz", + "integrity": "sha512-TCZpWUnBQN1YPk6grvd5x419OfXjHvhj5Oj44GYb84dOVChpg/+2VoEj+YVA4F4E/6huQPNnX7UYbTtxJqgihw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.4.tgz", + "integrity": "sha512-wzsGwv9mKlwJ3vHLyembBvGE/5nPUIwRR2I51B1cBV4Cb4ql9nIIfpmHzm050XYTY5fqTOKJQnhLj7zj89VG8g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.982.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/token-providers": "3.982.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.4.tgz", + "integrity": "sha512-hIzw2XzrG8jzsUSEatehmpkd5rWzASg5IHUfA+m01k/RtvfAML7ZJVVohuKdhAYx+wV2AThLiQJVzqn7F0khrw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/nested-clients": "3.982.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", + "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", + "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", + "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.6.tgz", + "integrity": "sha512-TehLN8W/kivl0U9HcS+keryElEWORROpghDXZBLfnb40DXM7hx/i+7OOjkogXQOF3QtUraJVRkHQ07bPhrWKlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.982.0", + "@smithy/core": "^3.22.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.982.0.tgz", + "integrity": "sha512-VVkaH27digrJfdVrT64rjkllvOp4oRiZuuJvrylLXAKl18ujToJR7AqpDldL/LS63RVne3QWIpkygIymxFtliQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.6", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.982.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.4", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", + "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.982.0.tgz", + "integrity": "sha512-v3M0KYp2TVHYHNBT7jHD9lLTWAdS9CaWJ2jboRKt0WAB65bA7iUEpR+k4VqKYtpQN4+8kKSc4w+K6kUNZkHKQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.6", + "@aws-sdk/nested-clients": "3.982.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.982.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.982.0.tgz", + "integrity": "sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", + "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", + "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.4.tgz", + "integrity": "sha512-3WFCBLiM8QiHDfosQq3Py+lIMgWlFWwFQliUHUqwEiRqLnKyhgbU3AKa7AWJF7lW2Oc/2kFNY4MlAYVnVc0i8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.6", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", + "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.1.tgz", + "integrity": "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.11", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.13.tgz", + "integrity": "sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.22.1", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.30", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.30.tgz", + "integrity": "sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.9.tgz", + "integrity": "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.2.tgz", + "integrity": "sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.22.1", + "@smithy/middleware-endpoint": "^4.4.13", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.29", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.29.tgz", + "integrity": "sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.32", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.32.tgz", + "integrity": "sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.11", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.11.tgz", + "integrity": "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stripe": { + "version": "14.25.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz", + "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f5e5833 --- /dev/null +++ b/server/package.json @@ -0,0 +1,27 @@ +{ + "name": "rys-premium-server", + "version": "1.0.0", + "description": "Premium subscription server for Remove YouTube Suggestions extension", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test": "node --test --test-concurrency=1 tests/*.test.js" + }, + "dependencies": { + "@aws-sdk/client-ses": "^3.500.0", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.1", + "stripe": "^14.12.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "supertest": "^6.3.4" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..a6d965d --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,37 @@ +// Configuration constants +// All timing values in milliseconds unless noted + +// Use getters for values that may change in tests +module.exports = { + // Server + get PORT() { return process.env.PORT || 3000; }, + get BASE_URL() { return process.env.BASE_URL || 'http://localhost:3000'; }, + + // JWT + get JWT_SECRET() { return process.env.JWT_SECRET; }, + SESSION_TOKEN_LIFETIME_DAYS: 30, + LICENSE_TOKEN_LIFETIME_DAYS: 3, + GRANDFATHERED_TOKEN_LIFETIME_DAYS: 730, // 2 years + + // Timing + MAGIC_LINK_EXPIRY_MS: 15 * 60 * 1000, // 15 minutes + REQUEST_ID_EXPIRY_MS: 20 * 60 * 1000, // 20 minutes + RATE_LIMIT_WINDOW_MS: 60 * 60 * 1000, // 1 hour + RATE_LIMIT_MAX_REQUESTS: 5, // per email per window + + // Stripe + get STRIPE_SECRET_KEY() { return process.env.STRIPE_SECRET_KEY; }, + get STRIPE_PRICE_MONTHLY() { return process.env.STRIPE_PRICE_MONTHLY; }, + get STRIPE_PRICE_YEARLY() { return process.env.STRIPE_PRICE_YEARLY; }, + get STRIPE_WEBHOOK_SECRET() { return process.env.STRIPE_WEBHOOK_SECRET; }, + + // AWS SES + get AWS_REGION() { return process.env.AWS_REGION || 'us-east-1'; }, + get EMAIL_FROM() { return process.env.EMAIL_FROM; }, + + // File paths + get DATA_DIR() { return process.env.DATA_DIR || './data'; }, + AUTH_REQUESTS_DIR: 'auth-requests', + RATE_LIMITS_DIR: 'rate-limits', + GRANDFATHERED_FILE: 'grandfathered.txt', +}; diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..69b8d4f --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,153 @@ +require('dotenv').config(); + +// Add timestamps to all console output +const originalLog = console.log; +const originalError = console.error; +const timestamp = () => new Date().toISOString(); +console.log = (...args) => originalLog(timestamp(), ...args); +console.error = (...args) => originalError(timestamp(), ...args); + +const express = require('express'); +const cors = require('cors'); +const morgan = require('morgan'); +const config = require('./config'); +const storage = require('./storage'); + +// Route imports +const authRoutes = require('./routes/auth'); +const licenseRoutes = require('./routes/license'); +const checkoutRoutes = require('./routes/checkout'); +const billingRoutes = require('./routes/billing'); +const webhookRoutes = require('./routes/webhook'); + +const app = express(); + +function isAllowedOrigin(origin) { + // Allow requests with no origin (like mobile apps or curl) + if (!origin) return true; + + // Allow Chrome and Firefox extension origins + if (origin.startsWith('chrome-extension://') || + origin.startsWith('moz-extension://')) { + return true; + } + + // Allow same-origin requests (for testing) + try { + const { hostname } = new URL(origin); + return hostname === 'localhost' || hostname === '127.0.0.1'; + } catch (err) { + return false; + } +} + +// CORS configuration for extension origins +app.use(cors({ + origin: (origin, callback) => { + if (isAllowedOrigin(origin)) { + return callback(null, true); + } + + return callback(new Error('Not allowed by CORS')); + }, + credentials: true, +})); + +// Stripe webhook needs raw body - must be before express.json() +app.use('/webhook/stripe', express.raw({ type: 'application/json' })); + +// JSON parsing for all other routes +app.use(express.json()); + +// Request logging +app.use(morgan(':method :url :status :response-time ms', { + stream: { write: msg => console.log(msg.trimEnd()) }, +})); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Mount routes +app.use('/auth', authRoutes); +app.use('/license', licenseRoutes); +app.use('/checkout', checkoutRoutes); +app.use('/billing', billingRoutes); +app.use('/webhook', webhookRoutes); + +// Error handling middleware +app.use((err, req, res, next) => { + if (err && err.message === 'Not allowed by CORS') { + return res.status(403).json({ error: 'CORS origin denied' }); + } + + console.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +// Periodic cleanup of expired data (every 5 minutes) +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; + +function runCleanup() { + try { + storage.pruneExpiredAuthRequests(); + storage.pruneExpiredRateLimits(); + } catch (err) { + console.error('Cleanup error:', err); + } +} + +// Start server +function start() { + // Validate required config + const required = [ + 'JWT_SECRET', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'STRIPE_PRICE_MONTHLY', + 'STRIPE_PRICE_YEARLY', + 'EMAIL_FROM', + ]; + const missing = required.filter(key => !config[key]); + + if (missing.length > 0) { + console.error(`Missing required environment variables: ${missing.join(', ')}`); + console.error('See .env.example for required configuration'); + process.exit(1); + } + + // Validate JWT_SECRET strength + if (config.JWT_SECRET.length < 32) { + console.error('JWT_SECRET must be at least 32 characters long'); + process.exit(1); + } + + // Ensure data directories exist + storage.ensureDirectories(); + + // Start cleanup interval + setInterval(runCleanup, CLEANUP_INTERVAL_MS); + + // Start listening + const grandfathered = storage.readGrandfatheredEmails(); + console.log(`Loaded ${grandfathered.size} grandfathered emails`); + + app.listen(config.PORT, () => { + console.log(`RYS Premium Server running on port ${config.PORT}`); + console.log(`Base URL: ${config.BASE_URL}`); + }); +} + +// Export for testing +module.exports = { app, start }; + +// Start server if run directly +if (require.main === module) { + start(); +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..091c5dc --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,202 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const config = require('../config'); +const storage = require('../storage'); +const { sendMagicLinkEmail } = require('../services/email'); +const { generateSessionToken } = require('../services/jwt'); +const { checkPremiumStatus } = require('../services/stripe'); +const { renderPage } = require('../templates'); + +const router = express.Router(); + +// Simple in-memory rate limiter for poll endpoint (by IP) +const pollRateLimits = new Map(); +const POLL_RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute +const POLL_RATE_LIMIT_MAX = 60; // 60 requests per minute per IP + +function checkPollRateLimit(ip) { + const now = Date.now(); + const record = pollRateLimits.get(ip); + + if (!record || now > record.resetTime) { + pollRateLimits.set(ip, { count: 1, resetTime: now + POLL_RATE_LIMIT_WINDOW_MS }); + return { allowed: true }; + } + + if (record.count >= POLL_RATE_LIMIT_MAX) { + return { allowed: false, retryAfter: Math.ceil((record.resetTime - now) / 1000) }; + } + + record.count++; + return { allowed: true }; +} + +// Clean up old entries periodically (unref so it doesn't prevent process exit) +const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [ip, record] of pollRateLimits) { + if (now > record.resetTime) { + pollRateLimits.delete(ip); + } + } +}, 5 * 60 * 1000); // Every 5 minutes +cleanupInterval.unref(); + +// Simple email validation +function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +// POST /auth/send-magic-link +router.post('/send-magic-link', async (req, res) => { + try { + const { email } = req.body; + + if (!email || !isValidEmail(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + // Check rate limit + const rateLimit = storage.checkRateLimit(email); + if (!rateLimit.allowed) { + const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000); + res.set('Retry-After', retryAfter); + return res.status(429).json({ + error: 'Too many requests. Please try again later.', + retryAfter, + }); + } + + // Generate request ID and create auth request + const requestId = uuidv4(); + storage.createAuthRequest(requestId, email); + + // Send magic link email + const magicLinkUrl = `${config.BASE_URL}/auth/verify?token=${requestId}`; + try { + const status = await checkPremiumStatus(email); + await sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium }); + } catch (err) { + storage.deleteAuthRequest(requestId); + throw err; + } + + console.log(`[auth] Magic link sent to ${email}`); + res.json({ request_id: requestId }); + } catch (err) { + console.error('Error sending magic link:', err); + res.status(500).json({ error: 'Failed to send magic link' }); + } +}); + +// GET /auth/verify - Magic link target (returns HTML) +router.get('/verify', async (req, res) => { + const { token: requestId } = req.query; + + if (!requestId) { + return res.status(400).send(renderErrorPage('Missing token')); + } + + const authRequest = storage.getAuthRequest(requestId); + + if (!authRequest) { + return res.status(404).send(renderErrorPage('Link expired or invalid. Please request a new sign-in link.')); + } + + // Check if magic link itself is expired (stricter than request expiry) + if (Date.now() - authRequest.created_at > config.MAGIC_LINK_EXPIRY_MS) { + storage.deleteAuthRequest(requestId); + return res.status(410).send(renderErrorPage('Link expired. Please request a new sign-in link.')); + } + + if (authRequest.status === 'pending') { + // Generate session token and mark as verified + const sessionToken = generateSessionToken(authRequest.email); + storage.updateAuthRequest(requestId, { + status: 'verified', + session_token: sessionToken, + }); + storage.decrementRateLimit(authRequest.email); + console.log(`[auth] Email verified: ${authRequest.email}`); + } + + res.send(renderSuccessPage()); +}); + +// GET /auth/poll - Extension polls this to check verification status +router.get('/poll', async (req, res) => { + // Rate limit by IP + const ip = req.ip || req.connection.remoteAddress; + const rateLimit = checkPollRateLimit(ip); + if (!rateLimit.allowed) { + res.set('Retry-After', rateLimit.retryAfter); + return res.status(429).json({ error: 'Too many requests', retryAfter: rateLimit.retryAfter }); + } + + const { request_id: requestId } = req.query; + + if (!requestId) { + return res.status(400).json({ error: 'Missing request_id' }); + } + + const authRequest = storage.getAuthRequest(requestId); + + if (!authRequest) { + return res.status(404).json({ error: 'Unknown or expired request_id' }); + } + + if (authRequest.status === 'pending') { + return res.json({ status: 'pending' }); + } + + if (authRequest.status === 'verified') { + // Return token and delete the request + const response = { + status: 'verified', + session_token: authRequest.session_token, + email: authRequest.email, + }; + + res.once('finish', () => { + storage.deleteAuthRequest(requestId); + }); + + return res.json(response); + } + + // Unknown status + res.status(500).json({ error: 'Unknown auth request status' }); +}); + +// HTML utilities +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, char => map[char]); +} + +function renderSuccessPage() { + return renderPage({ + title: 'Signed In', + heading: "You're signed in!", + message: 'You can close this tab and return to the extension.', + icon: 'check', + }); +} + +function renderErrorPage(message) { + return renderPage({ + title: 'Error', + heading: 'Something went wrong', + message: escapeHtml(message), + icon: 'error', + }); +} + +module.exports = router; diff --git a/server/src/routes/billing.js b/server/src/routes/billing.js new file mode 100644 index 0000000..e2bed38 --- /dev/null +++ b/server/src/routes/billing.js @@ -0,0 +1,35 @@ +const express = require('express'); +const { requireAuth } = require('../services/jwt'); +const { createBillingPortalSession } = require('../services/stripe'); +const { renderPage } = require('../templates'); + +const router = express.Router(); + +// POST /billing/portal - Create Stripe billing portal session +router.post('/portal', requireAuth, async (req, res) => { + try { + const session = await createBillingPortalSession(req.userEmail); + console.log(`[billing] Portal session created for ${req.userEmail}`); + res.json({ url: session.url }); + } catch (err) { + console.error('Error creating billing portal session:', err); + + if (err.message === 'No customer found for this email') { + return res.status(404).json({ error: 'No subscription found for this account' }); + } + + res.status(500).json({ error: 'Failed to create billing portal session' }); + } +}); + +// GET /billing/return - Return page from billing portal +router.get('/return', (req, res) => { + res.send(renderPage({ + title: 'Billing Updated', + heading: 'Billing updated', + message: 'You can close this tab and return to the extension.', + icon: 'check', + })); +}); + +module.exports = router; diff --git a/server/src/routes/checkout.js b/server/src/routes/checkout.js new file mode 100644 index 0000000..fcdc1dc --- /dev/null +++ b/server/src/routes/checkout.js @@ -0,0 +1,46 @@ +const express = require('express'); +const { requireAuth } = require('../services/jwt'); +const { createCheckoutSession, createBillingPortalSession } = require('../services/stripe'); +const { renderPage } = require('../templates'); + +const router = express.Router(); + +// POST /checkout/create +router.post('/create', requireAuth, async (req, res) => { + try { + const { plan } = req.body; + + if (!plan || !['monthly', 'yearly'].includes(plan)) { + return res.status(400).json({ error: 'Invalid plan. Must be "monthly" or "yearly"' }); + } + + const session = await createCheckoutSession(req.userEmail, plan); + + console.log(`[checkout] Session created for ${req.userEmail}, plan: ${plan}`); + res.json({ checkout_url: session.url }); + } catch (err) { + console.error('Error creating checkout session:', err); + res.status(500).json({ error: 'Failed to create checkout session' }); + } +}); + +// GET /checkout/success - Success page after payment +router.get('/success', (req, res) => { + res.send(renderPage({ + title: 'Payment Successful', + heading: 'Payment successful!', + message: 'You can close this tab and return to the extension. Your premium features are now active.', + icon: 'check', + })); +}); + +// GET /checkout/cancel - Cancel page +router.get('/cancel', (req, res) => { + res.send(renderPage({ + title: 'Payment Canceled', + heading: 'Payment canceled', + message: "You can close this tab and try again from the extension whenever you're ready.", + })); +}); + +module.exports = router; diff --git a/server/src/routes/license.js b/server/src/routes/license.js new file mode 100644 index 0000000..3ebdffd --- /dev/null +++ b/server/src/routes/license.js @@ -0,0 +1,42 @@ +const express = require('express'); +const { requireAuth, generateLicenseToken } = require('../services/jwt'); +const { checkPremiumStatus } = require('../services/stripe'); +const storage = require('../storage'); + +const router = express.Router(); + +// GET /license/check +router.get('/check', requireAuth, async (req, res) => { + try { + const email = req.userEmail; + + // First check if user is grandfathered (past donor) + const isGrandfathered = storage.isGrandfathered(email); + if (isGrandfathered) { + console.log(`[license] ${email} -> premium (grandfathered)`); + const licenseToken = generateLicenseToken(email, true, true); + return res.json({ license_token: licenseToken }); + } + + // Check subscription cache first + const cached = storage.getSubscriptionStatus(email); + if (cached) { + console.log(`[license] ${email} -> ${cached.premium ? 'premium' : 'free'} (cached)`); + const licenseToken = generateLicenseToken(email, cached.premium, false); + return res.json({ license_token: licenseToken }); + } + + // Cache miss — check Stripe and populate cache + const status = await checkPremiumStatus(email); + storage.setSubscriptionStatus(email, status.premium, null); + + console.log(`[license] ${email} -> ${status.premium ? 'premium' : 'free'} (stripe)`); + const licenseToken = generateLicenseToken(email, status.premium, false); + res.json({ license_token: licenseToken }); + } catch (err) { + console.error('Error checking license:', err); + res.status(500).json({ error: 'Failed to check license status' }); + } +}); + +module.exports = router; diff --git a/server/src/routes/webhook.js b/server/src/routes/webhook.js new file mode 100644 index 0000000..44ef71f --- /dev/null +++ b/server/src/routes/webhook.js @@ -0,0 +1,109 @@ +const express = require('express'); +const { constructWebhookEvent, stripe } = require('../services/stripe'); +const { sendWelcomeEmail, sendCancellationEmail } = require('../services/email'); +const storage = require('../storage'); + +const router = express.Router(); + +async function getCustomerEmail(customerId) { + const customer = await stripe.customers.retrieve(customerId); + return customer.email ? customer.email.toLowerCase() : null; +} + +// POST /webhook/stripe +// Note: This route needs raw body, configured in index.js +router.post('/stripe', async (req, res) => { + const signature = req.headers['stripe-signature']; + + if (!signature) { + return res.status(400).json({ error: 'Missing stripe-signature header' }); + } + + let event; + + try { + event = constructWebhookEvent(req.body, signature); + } catch (err) { + console.error('[webhook] Signature verification failed:', err.message); + return res.status(400).json({ error: 'Invalid signature' }); + } + + // Handle the event + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object; + try { + const email = session.customer_email || await getCustomerEmail(session.customer); + if (email) { + await sendWelcomeEmail(email); + console.log(`[webhook] Checkout completed: ${email} (welcome email sent)`); + } else { + console.log(`[webhook] Checkout completed: ${session.customer} (no email found)`); + } + } catch (err) { + console.error(`[webhook] Checkout completed but welcome email failed:`, err.message); + } + break; + } + + case 'customer.subscription.created': + case 'customer.subscription.updated': { + const subscription = event.data.object; + const premium = subscription.status === 'active'; + const action = event.type === 'customer.subscription.created' ? 'created' : 'updated'; + try { + const email = await getCustomerEmail(subscription.customer); + if (email) { + storage.setSubscriptionStatus(email, premium, subscription.customer); + console.log(`[webhook] Subscription ${action}: ${email} -> ${premium ? 'premium' : 'free'}`); + + // Send cancellation email when user cancels (before period ends) + if (subscription.cancel_at_period_end && event.data.previous_attributes?.cancel_at_period_end === false) { + await sendCancellationEmail(email); + console.log(`[webhook] Cancellation scheduled: ${email} (cancellation email sent)`); + } + } else { + console.log(`[webhook] Subscription ${action}: ${subscription.id} (no email found)`); + } + } catch (err) { + console.error(`[webhook] Subscription ${action} cache error:`, err.message); + } + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object; + try { + const email = await getCustomerEmail(subscription.customer); + if (email) { + storage.setSubscriptionStatus(email, false, subscription.customer); + console.log(`[webhook] Subscription ended: ${email} -> free`); + } else { + console.log(`[webhook] Subscription ended: ${subscription.id} (no email found)`); + } + } catch (err) { + console.error('[webhook] Subscription ended error:', err.message); + } + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object; + console.log(`[webhook] Payment failed: ${invoice.id}, customer: ${invoice.customer}`); + break; + } + + case 'invoice.payment_succeeded': { + const invoice = event.data.object; + console.log(`[webhook] Payment succeeded: ${invoice.id}`); + break; + } + + default: + console.log(`[webhook] Unhandled event: ${event.type}`); + } + + res.json({ received: true }); +}); + +module.exports = router; diff --git a/server/src/services/email.js b/server/src/services/email.js new file mode 100644 index 0000000..7ebd49f --- /dev/null +++ b/server/src/services/email.js @@ -0,0 +1,195 @@ +const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); +const config = require('../config'); + +const sesClient = new SESClient({ region: config.AWS_REGION }); + +async function sendMagicLinkEmail(email, magicLinkUrl, { isPremium = false } = {}) { + const premiumBadge = isPremium + ? ' Premium' + : ''; + const params = { + Source: config.EMAIL_FROM, + Destination: { + ToAddresses: [email], + }, + Message: { + Subject: { + Data: 'Sign in to RYS', + Charset: 'UTF-8', + }, + Body: { + Text: { + Data: `Here's your sign-in link for RYS:\n\n${magicLinkUrl}\n\nLink expires in 15 minutes. If you didn't request this, just ignore it.\n\n— Lawrence`, + Charset: 'UTF-8', + }, + Html: { + Data: ` + + + + + + + +
+
+ RYS${premiumBadge} +
+
+

+ Here's your sign-in link for RYS: +

+

+ + Sign In + +

+

+ Link expires in 15 minutes. If you didn't request this, just ignore it. +

+

+ — Lawrence +

+
+
+ + + `.trim(), + Charset: 'UTF-8', + }, + }, + }, + }; + + const command = new SendEmailCommand(params); + return sesClient.send(command); +} + +async function sendWelcomeEmail(email) { + const params = { + Source: config.EMAIL_FROM, + Destination: { + ToAddresses: [email], + }, + Message: { + Subject: { + Data: 'Welcome to RYS Premium', + Charset: 'UTF-8', + }, + Body: { + Text: { + Data: `Welcome to RYS Premium!\n\nThank you for subscribing. Your premium features are now active.\n\n— Lawrence`, + Charset: 'UTF-8', + }, + Html: { + Data: ` + + + + + + + +
+
+ RYS Premium +
+
+

+ Welcome to RYS Premium! +

+

+ Thank you for subscribing. Your premium features are now active. +

+

+ — Lawrence +

+
+
+ + + `.trim(), + Charset: 'UTF-8', + }, + }, + }, + }; + + const command = new SendEmailCommand(params); + return sesClient.send(command); +} + +const FEEDBACK_URL = 'https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0'; + +async function sendCancellationEmail(email) { + const params = { + Source: config.EMAIL_FROM, + Destination: { + ToAddresses: [email], + }, + Message: { + Subject: { + Data: 'Your RYS Premium subscription has been canceled', + Charset: 'UTF-8', + }, + Body: { + Text: { + Data: `Your RYS Premium subscription has been canceled.\n\nThe free version of RYS is still yours to use anytime.\n\nIf you have a moment, we'd love to hear what we could do better: ${FEEDBACK_URL}\n\nThanks for giving Premium a try.\n\n— Lawrence`, + Charset: 'UTF-8', + }, + Html: { + Data: ` + + + + + + + +
+
+ RYS +
+
+

+ Your Premium subscription has been canceled. +

+

+ The free version of RYS is still yours to use anytime. +

+

+ If you have a moment, we'd love to hear what we could do better: +

+

+ + Share Feedback + +

+

+ Thanks for giving Premium a try. +

+

+ — Lawrence +

+
+
+ + + `.trim(), + Charset: 'UTF-8', + }, + }, + }, + }; + + const command = new SendEmailCommand(params); + return sesClient.send(command); +} + +module.exports = { + sendMagicLinkEmail, + sendWelcomeEmail, + sendCancellationEmail, +}; diff --git a/server/src/services/jwt.js b/server/src/services/jwt.js new file mode 100644 index 0000000..8d25986 --- /dev/null +++ b/server/src/services/jwt.js @@ -0,0 +1,63 @@ +const jwt = require('jsonwebtoken'); +const config = require('../config'); + +function generateSessionToken(email) { + const payload = { + email: email.toLowerCase(), + }; + + const options = { + expiresIn: `${config.SESSION_TOKEN_LIFETIME_DAYS}d`, + }; + + return jwt.sign(payload, config.JWT_SECRET, options); +} + +function generateLicenseToken(email, premium, grandfathered = false) { + const payload = { + email: email.toLowerCase(), + premium: !!premium, + grandfathered: !!grandfathered, + }; + + const lifetimeDays = grandfathered + ? config.GRANDFATHERED_TOKEN_LIFETIME_DAYS + : config.LICENSE_TOKEN_LIFETIME_DAYS; + + return jwt.sign(payload, config.JWT_SECRET, { expiresIn: `${lifetimeDays}d` }); +} + +function verifySessionToken(token) { + try { + const decoded = jwt.verify(token, config.JWT_SECRET); + return { valid: true, email: decoded.email }; + } catch (err) { + return { valid: false, error: err.message }; + } +} + +// Express middleware to require authentication +function requireAuth(req, res, next) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing or invalid authorization header' }); + } + + const token = authHeader.slice(7); + const result = verifySessionToken(token); + + if (!result.valid) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + + req.userEmail = result.email; + next(); +} + +module.exports = { + generateSessionToken, + generateLicenseToken, + verifySessionToken, + requireAuth, +}; diff --git a/server/src/services/stripe.js b/server/src/services/stripe.js new file mode 100644 index 0000000..c0fd6ef --- /dev/null +++ b/server/src/services/stripe.js @@ -0,0 +1,105 @@ +const Stripe = require('stripe'); +const config = require('../config'); + +const stripe = new Stripe(config.STRIPE_SECRET_KEY); + +async function findCustomerByEmail(email) { + const customers = await stripe.customers.list({ + email: email.toLowerCase(), + limit: 1, + }); + return customers.data[0] || null; +} + +async function createCustomer(email) { + return stripe.customers.create({ + email: email.toLowerCase(), + }); +} + +async function findOrCreateCustomer(email) { + const existing = await findCustomerByEmail(email); + if (existing) return existing; + return createCustomer(email); +} + +async function getActiveSubscription(customerId) { + const subscriptions = await stripe.subscriptions.list({ + customer: customerId, + status: 'active', + limit: 1, + }); + return subscriptions.data[0] || null; +} + +async function checkPremiumStatus(email) { + const customer = await findCustomerByEmail(email); + + if (!customer) { + return { premium: false, expiresAt: null }; + } + + const subscription = await getActiveSubscription(customer.id); + + if (!subscription) { + return { premium: false, expiresAt: null }; + } + + return { + premium: true, + expiresAt: new Date(subscription.current_period_end * 1000).toISOString(), + }; +} + +async function createCheckoutSession(email, plan) { + const customer = await findOrCreateCustomer(email); + + const priceId = plan === 'yearly' + ? config.STRIPE_PRICE_YEARLY + : config.STRIPE_PRICE_MONTHLY; + + const session = await stripe.checkout.sessions.create({ + customer: customer.id, + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${config.BASE_URL}/checkout/success`, + cancel_url: `${config.BASE_URL}/checkout/cancel`, + }); + + return session; +} + +async function createBillingPortalSession(email) { + const customer = await findCustomerByEmail(email); + + if (!customer) { + throw new Error('No customer found for this email'); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: customer.id, + return_url: `${config.BASE_URL}/billing/return`, + }); + + return session; +} + +function constructWebhookEvent(payload, signature) { + return stripe.webhooks.constructEvent( + payload, + signature, + config.STRIPE_WEBHOOK_SECRET + ); +} + +module.exports = { + stripe, + findCustomerByEmail, + createCustomer, + findOrCreateCustomer, + getActiveSubscription, + checkPremiumStatus, + createCheckoutSession, + createBillingPortalSession, + constructWebhookEvent, +}; diff --git a/server/src/storage/index.js b/server/src/storage/index.js new file mode 100644 index 0000000..646a30e --- /dev/null +++ b/server/src/storage/index.js @@ -0,0 +1,297 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const config = require('../config'); + +// Get paths dynamically to support test environment changes +function getDataDir() { + return config.DATA_DIR; +} + +function getAuthRequestsDir() { + return path.join(getDataDir(), config.AUTH_REQUESTS_DIR); +} + +function getRateLimitsDir() { + return path.join(getDataDir(), config.RATE_LIMITS_DIR); +} + +function ensureDirectories() { + const dirs = [getDataDir(), getAuthRequestsDir(), getRateLimitsDir()]; + dirs.forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); +} + +// Initialize on load +ensureDirectories(); + +// --- Grandfathered Emails (file-backed, no in-memory cache) --- + +function readGrandfatheredEmails() { + try { + const filePath = path.join(getDataDir(), config.GRANDFATHERED_FILE); + if (fs.existsSync(filePath)) { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + return new Set(lines.map(l => l.trim().toLowerCase()).filter(l => l && !l.startsWith('#'))); + } + } catch (err) { + console.error('Failed to read grandfathered emails:', err.message); + } + return new Set(); +} + +function isGrandfathered(email) { + return readGrandfatheredEmails().has(email.toLowerCase()); +} + +// --- Subscription Cache (email -> premium status, file-backed, no in-memory cache) --- + +function getSubscriptionCachePath() { + return path.join(getDataDir(), 'subscriptions.json'); +} + +function readSubscriptionFile() { + try { + const filePath = getSubscriptionCachePath(); + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } + } catch (err) { + console.error('Failed to read subscription cache:', err.message); + } + return {}; +} + +function setSubscriptionStatus(email, premium, customerId) { + email = email.toLowerCase(); + const data = readSubscriptionFile(); + data[email] = { premium, customerId, updatedAt: Date.now() }; + try { + fs.writeFileSync(getSubscriptionCachePath(), JSON.stringify(data, null, 2)); + } catch (err) { + console.error('Failed to save subscription cache:', err.message); + } +} + +function getSubscriptionStatus(email) { + const data = readSubscriptionFile(); + return data[email.toLowerCase()] || null; +} + + +// --- Auth Requests (file per request) --- + +function getAuthRequestFilename(requestId, timestamp) { + return `${timestamp}-${requestId}.json`; +} + +function parseAuthRequestFilename(filename) { + const match = filename.match(/^(\d+)-(.+)\.json$/); + if (!match) return null; + return { timestamp: parseInt(match[1], 10), requestId: match[2] }; +} + +function createAuthRequest(requestId, email) { + ensureDirectories(); // Ensure directory exists before write + const timestamp = Date.now(); + const data = { + request_id: requestId, + email: email, + status: 'pending', + created_at: timestamp, + session_token: null, + }; + const filename = getAuthRequestFilename(requestId, timestamp); + const filePath = path.join(getAuthRequestsDir(), filename); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + return data; +} + +function findAuthRequestFile(requestId) { + const dir = getAuthRequestsDir(); + const files = fs.readdirSync(dir); + for (const file of files) { + const parsed = parseAuthRequestFilename(file); + if (parsed && parsed.requestId === requestId) { + return path.join(dir, file); + } + } + return null; +} + +function getAuthRequest(requestId) { + const filePath = findAuthRequestFile(requestId); + if (!filePath) return null; + + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + // Check if expired + if (Date.now() - data.created_at > config.REQUEST_ID_EXPIRY_MS) { + // Clean up expired request + fs.unlinkSync(filePath); + return null; + } + + return data; + } catch (err) { + return null; + } +} + +function updateAuthRequest(requestId, updates) { + const filePath = findAuthRequestFile(requestId); + if (!filePath) return null; + + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const updated = { ...data, ...updates }; + fs.writeFileSync(filePath, JSON.stringify(updated, null, 2)); + return updated; + } catch (err) { + return null; + } +} + +function deleteAuthRequest(requestId) { + const filePath = findAuthRequestFile(requestId); + if (filePath && fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; +} + +function pruneExpiredAuthRequests() { + const now = Date.now(); + const dir = getAuthRequestsDir(); + const files = fs.readdirSync(dir); + let pruned = 0; + + for (const file of files) { + const parsed = parseAuthRequestFilename(file); + if (parsed && now - parsed.timestamp > config.REQUEST_ID_EXPIRY_MS) { + fs.unlinkSync(path.join(dir, file)); + pruned++; + } + } + + if (pruned > 0) { + console.log(`Pruned ${pruned} expired auth requests`); + } + return pruned; +} + +// --- Rate Limiting (file per email hash) --- + +function getEmailHash(email) { + return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex').slice(0, 16); +} + +function getRateLimitFilePath(email) { + return path.join(getRateLimitsDir(), `${getEmailHash(email)}.json`); +} + +function checkRateLimit(email) { + ensureDirectories(); // Ensure directory exists before write + const filePath = getRateLimitFilePath(email); + const now = Date.now(); + + let data = { count: 0, windowStart: now }; + + try { + if (fs.existsSync(filePath)) { + data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + // Reset if window expired + if (now - data.windowStart > config.RATE_LIMIT_WINDOW_MS) { + data = { count: 0, windowStart: now }; + } + } + } catch (err) { + // Start fresh on error + data = { count: 0, windowStart: now }; + } + + // Check if over limit + if (data.count >= config.RATE_LIMIT_MAX_REQUESTS) { + const resetTime = data.windowStart + config.RATE_LIMIT_WINDOW_MS; + return { allowed: false, resetTime }; + } + + // Increment and save + data.count++; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + + return { allowed: true, remaining: config.RATE_LIMIT_MAX_REQUESTS - data.count }; +} + +function decrementRateLimit(email) { + const filePath = getRateLimitFilePath(email); + try { + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (data.count > 0) { + data.count--; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + } + } + } catch (err) { + // Ignore errors + } +} + +function pruneExpiredRateLimits() { + const now = Date.now(); + const dir = getRateLimitsDir(); + const files = fs.readdirSync(dir); + let pruned = 0; + + for (const file of files) { + const filePath = path.join(dir, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (now - data.windowStart > config.RATE_LIMIT_WINDOW_MS) { + fs.unlinkSync(filePath); + pruned++; + } + } catch (err) { + // Remove invalid files + fs.unlinkSync(filePath); + pruned++; + } + } + + if (pruned > 0) { + console.log(`Pruned ${pruned} expired rate limit records`); + } + return pruned; +} + +module.exports = { + // Grandfathered + readGrandfatheredEmails, + isGrandfathered, + + // Auth requests + createAuthRequest, + getAuthRequest, + updateAuthRequest, + deleteAuthRequest, + pruneExpiredAuthRequests, + + // Rate limiting + checkRateLimit, + decrementRateLimit, + pruneExpiredRateLimits, + + // Subscription cache + setSubscriptionStatus, + getSubscriptionStatus, + + // Utils + ensureDirectories, +}; diff --git a/server/src/templates.js b/server/src/templates.js new file mode 100644 index 0000000..b2eeb5a --- /dev/null +++ b/server/src/templates.js @@ -0,0 +1,168 @@ +// Shared HTML page templates — styled to match lawrencehook.com/rys + +const RYS_LOGO_URL = 'https://lawrencehook.com/rys/assets/rys.svg'; +const RYS_HOME_URL = 'https://lawrencehook.com/rys/'; + +function renderPage({ title, heading, message, icon }) { + const iconHTML = icon === 'check' + ? `
+ +
` + : icon === 'error' + ? `
+ +
` + : ''; + + return ` + + + + + + ${title} — RYS + + + +
+ + RYS + + RYS + Premium +
+ +
+
+ ${iconHTML} +

${heading}

+

${message}

+
+
+ + + + + `.trim(); +} + +module.exports = { renderPage }; diff --git a/server/tests/auth.test.js b/server/tests/auth.test.js new file mode 100644 index 0000000..80c0d3d --- /dev/null +++ b/server/tests/auth.test.js @@ -0,0 +1,180 @@ +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); + +// Setup must be required first +const { cleanTestData } = require('./setup'); + +// Mock the email service before requiring app +const emailService = require('../src/services/email'); +let lastSentEmail = null; +emailService.sendMagicLinkEmail = async (email, url) => { + lastSentEmail = { email, url }; + return { MessageId: 'test-message-id' }; +}; + +// Mock Stripe service +const stripeService = require('../src/services/stripe'); +stripeService.checkPremiumStatus = async () => ({ premium: false, expiresAt: null }); + +const { app } = require('../src/index'); +const storage = require('../src/storage'); + +describe('Auth Routes', () => { + beforeEach(() => { + cleanTestData(); + storage.ensureDirectories(); + lastSentEmail = null; + }); + + after(() => { + cleanTestData(); + }); + + describe('POST /auth/send-magic-link', () => { + it('should send magic link for valid email', async () => { + const res = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'test@example.com' }); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.request_id); + assert.strictEqual(lastSentEmail.email, 'test@example.com'); + assert.ok(lastSentEmail.url.includes(res.body.request_id)); + }); + + it('should reject invalid email format', async () => { + const res = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'not-an-email' }); + + assert.strictEqual(res.status, 400); + assert.ok(res.body.error.includes('Invalid email')); + }); + + it('should reject missing email', async () => { + const res = await request(app) + .post('/auth/send-magic-link') + .send({}); + + assert.strictEqual(res.status, 400); + }); + + it('should rate limit after too many requests', async () => { + const email = 'ratelimit@example.com'; + + // Make max allowed requests + for (let i = 0; i < 5; i++) { + await request(app) + .post('/auth/send-magic-link') + .send({ email }); + } + + // Next should be rate limited + const res = await request(app) + .post('/auth/send-magic-link') + .send({ email }); + + assert.strictEqual(res.status, 429); + assert.ok(res.headers['retry-after']); + }); + }); + + describe('GET /auth/verify', () => { + it('should verify valid magic link and show success page', async () => { + // Create auth request first + const sendRes = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'verify@example.com' }); + + const requestId = sendRes.body.request_id; + + const res = await request(app) + .get(`/auth/verify?token=${requestId}`); + + assert.strictEqual(res.status, 200); + assert.ok(res.text.includes("You're signed in")); + + // Auth request should now be verified + const authRequest = storage.getAuthRequest(requestId); + assert.strictEqual(authRequest.status, 'verified'); + assert.ok(authRequest.session_token); + }); + + it('should reject missing token', async () => { + const res = await request(app).get('/auth/verify'); + + assert.strictEqual(res.status, 400); + assert.ok(res.text.includes('Missing token')); + }); + + it('should reject invalid token', async () => { + const res = await request(app) + .get('/auth/verify?token=invalid-token'); + + assert.strictEqual(res.status, 404); + assert.ok(res.text.includes('expired or invalid')); + }); + }); + + describe('GET /auth/poll', () => { + it('should return pending for unverified request', async () => { + const sendRes = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'poll@example.com' }); + + const requestId = sendRes.body.request_id; + + const res = await request(app) + .get(`/auth/poll?request_id=${requestId}`); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'pending'); + }); + + it('should return verified with token after magic link clicked', async () => { + // Create and verify request + const sendRes = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'poll2@example.com' }); + + const requestId = sendRes.body.request_id; + + // Click magic link + await request(app).get(`/auth/verify?token=${requestId}`); + + // Poll should return verified + const res = await request(app) + .get(`/auth/poll?request_id=${requestId}`); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'verified'); + assert.ok(res.body.session_token); + assert.strictEqual(res.body.email, 'poll2@example.com'); + }); + + it('should delete auth request after returning verified', async () => { + const sendRes = await request(app) + .post('/auth/send-magic-link') + .send({ email: 'poll3@example.com' }); + + const requestId = sendRes.body.request_id; + + await request(app).get(`/auth/verify?token=${requestId}`); + await request(app).get(`/auth/poll?request_id=${requestId}`); + + // Second poll should return 404 + const res = await request(app) + .get(`/auth/poll?request_id=${requestId}`); + + assert.strictEqual(res.status, 404); + }); + + it('should reject invalid request_id', async () => { + const res = await request(app) + .get('/auth/poll?request_id=invalid'); + + assert.strictEqual(res.status, 404); + }); + }); +}); diff --git a/server/tests/checkout.test.js b/server/tests/checkout.test.js new file mode 100644 index 0000000..9b3a28d --- /dev/null +++ b/server/tests/checkout.test.js @@ -0,0 +1,159 @@ +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); + +// Setup must be required first +const { cleanTestData } = require('./setup'); + +// Mock the email service +const emailService = require('../src/services/email'); +emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' }); + +// Mock Stripe service +const stripeService = require('../src/services/stripe'); +stripeService.createCheckoutSession = async (email, plan) => ({ + url: `https://checkout.stripe.com/test?plan=${plan}&email=${email}`, +}); +stripeService.createBillingPortalSession = async (email) => { + if (email === 'nocustomer@example.com') { + throw new Error('No customer found for this email'); + } + return { url: 'https://billing.stripe.com/test' }; +}; + +const { app } = require('../src/index'); +const storage = require('../src/storage'); +const { generateSessionToken } = require('../src/services/jwt'); + +describe('Checkout Routes', () => { + beforeEach(() => { + cleanTestData(); + storage.ensureDirectories(); + }); + + after(() => { + cleanTestData(); + }); + + describe('POST /checkout/create', () => { + it('should require authentication', async () => { + const res = await request(app) + .post('/checkout/create') + .send({ plan: 'monthly' }); + + assert.strictEqual(res.status, 401); + }); + + it('should create checkout session for monthly plan', async () => { + const token = generateSessionToken('checkout@example.com'); + + const res = await request(app) + .post('/checkout/create') + .set('Authorization', `Bearer ${token}`) + .send({ plan: 'monthly' }); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.checkout_url); + assert.ok(res.body.checkout_url.includes('monthly')); + }); + + it('should create checkout session for yearly plan', async () => { + const token = generateSessionToken('checkout@example.com'); + + const res = await request(app) + .post('/checkout/create') + .set('Authorization', `Bearer ${token}`) + .send({ plan: 'yearly' }); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.checkout_url.includes('yearly')); + }); + + it('should reject invalid plan', async () => { + const token = generateSessionToken('checkout@example.com'); + + const res = await request(app) + .post('/checkout/create') + .set('Authorization', `Bearer ${token}`) + .send({ plan: 'invalid' }); + + assert.strictEqual(res.status, 400); + assert.ok(res.body.error.includes('Invalid plan')); + }); + + it('should reject missing plan', async () => { + const token = generateSessionToken('checkout@example.com'); + + const res = await request(app) + .post('/checkout/create') + .set('Authorization', `Bearer ${token}`) + .send({}); + + assert.strictEqual(res.status, 400); + }); + }); + + describe('GET /checkout/success', () => { + it('should return success page', async () => { + const res = await request(app).get('/checkout/success'); + + assert.strictEqual(res.status, 200); + assert.ok(res.text.includes('Payment successful')); + }); + }); + + describe('GET /checkout/cancel', () => { + it('should return cancel page', async () => { + const res = await request(app).get('/checkout/cancel'); + + assert.strictEqual(res.status, 200); + assert.ok(res.text.includes('Payment canceled')); + }); + }); +}); + +describe('Billing Routes', () => { + beforeEach(() => { + cleanTestData(); + storage.ensureDirectories(); + }); + + describe('POST /billing/portal', () => { + it('should require authentication', async () => { + const res = await request(app).post('/billing/portal'); + + assert.strictEqual(res.status, 401); + }); + + it('should return billing portal URL', async () => { + const token = generateSessionToken('billing@example.com'); + + const res = await request(app) + .post('/billing/portal') + .set('Authorization', `Bearer ${token}`); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.url); + assert.ok(res.body.url.includes('billing.stripe.com')); + }); + + it('should return 404 for user without subscription', async () => { + const token = generateSessionToken('nocustomer@example.com'); + + const res = await request(app) + .post('/billing/portal') + .set('Authorization', `Bearer ${token}`); + + assert.strictEqual(res.status, 404); + }); + }); + + describe('GET /billing/return', () => { + it('should return billing return page', async () => { + const res = await request(app).get('/billing/return'); + + assert.strictEqual(res.status, 200); + assert.ok(res.text.includes('Billing updated')); + }); + }); +}); diff --git a/server/tests/health.test.js b/server/tests/health.test.js new file mode 100644 index 0000000..4d63f54 --- /dev/null +++ b/server/tests/health.test.js @@ -0,0 +1,43 @@ +const { describe, it, beforeEach, after } = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); + +// Setup must be required first +const { cleanTestData } = require('./setup'); + +// Mock email service +const emailService = require('../src/services/email'); +emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' }); + +const { app } = require('../src/index'); +const storage = require('../src/storage'); + +describe('Health Check', () => { + beforeEach(() => { + cleanTestData(); + storage.ensureDirectories(); + }); + + after(() => { + cleanTestData(); + }); + + describe('GET /health', () => { + it('should return health status', async () => { + const res = await request(app).get('/health'); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'ok'); + assert.ok(res.body.timestamp); + }); + }); + + describe('404 handling', () => { + it('should return 404 for unknown routes', async () => { + const res = await request(app).get('/unknown-route'); + + assert.strictEqual(res.status, 404); + assert.strictEqual(res.body.error, 'Not found'); + }); + }); +}); diff --git a/server/tests/jwt.test.js b/server/tests/jwt.test.js new file mode 100644 index 0000000..38ec529 --- /dev/null +++ b/server/tests/jwt.test.js @@ -0,0 +1,100 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +// Setup must be required first +require('./setup'); + +const { generateSessionToken, generateLicenseToken, verifySessionToken } = require('../src/services/jwt'); + +describe('JWT Service', () => { + describe('generateSessionToken', () => { + it('should generate a valid JWT token', () => { + const token = generateSessionToken('test@example.com'); + + assert.ok(token); + assert.strictEqual(typeof token, 'string'); + assert.ok(token.split('.').length === 3); // JWT format + }); + + it('should lowercase email in token', () => { + const token = generateSessionToken('Test@EXAMPLE.com'); + const result = verifySessionToken(token); + + assert.strictEqual(result.email, 'test@example.com'); + }); + }); + + describe('verifySessionToken', () => { + it('should verify a valid token', () => { + const token = generateSessionToken('verify@example.com'); + const result = verifySessionToken(token); + + assert.strictEqual(result.valid, true); + assert.strictEqual(result.email, 'verify@example.com'); + }); + + it('should reject an invalid token', () => { + const result = verifySessionToken('invalid-token'); + + assert.strictEqual(result.valid, false); + assert.ok(result.error); + }); + + it('should reject a token with wrong secret', () => { + // Create a token with a different secret (simulated by tampering) + const validToken = generateSessionToken('test@example.com'); + const tamperedToken = validToken.slice(0, -5) + 'xxxxx'; + + const result = verifySessionToken(tamperedToken); + + assert.strictEqual(result.valid, false); + }); + }); + + describe('generateLicenseToken', () => { + it('should generate a valid license token with premium status', () => { + const token = generateLicenseToken('test@example.com', true, false); + + assert.ok(token); + assert.strictEqual(typeof token, 'string'); + assert.ok(token.split('.').length === 3); + + const result = verifySessionToken(token); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.email, 'test@example.com'); + }); + + it('should include premium and grandfathered flags in payload', () => { + const jwt = require('jsonwebtoken'); + const config = require('../src/config'); + + const token = generateLicenseToken('test@example.com', true, true); + const decoded = jwt.verify(token, config.JWT_SECRET); + + assert.strictEqual(decoded.email, 'test@example.com'); + assert.strictEqual(decoded.premium, true); + assert.strictEqual(decoded.grandfathered, true); + }); + + it('should lowercase email in license token', () => { + const jwt = require('jsonwebtoken'); + const config = require('../src/config'); + + const token = generateLicenseToken('Test@EXAMPLE.com', true, false); + const decoded = jwt.verify(token, config.JWT_SECRET); + + assert.strictEqual(decoded.email, 'test@example.com'); + }); + + it('should set non-premium status correctly', () => { + const jwt = require('jsonwebtoken'); + const config = require('../src/config'); + + const token = generateLicenseToken('test@example.com', false, false); + const decoded = jwt.verify(token, config.JWT_SECRET); + + assert.strictEqual(decoded.premium, false); + assert.strictEqual(decoded.grandfathered, false); + }); + }); +}); diff --git a/server/tests/license.test.js b/server/tests/license.test.js new file mode 100644 index 0000000..85a589f --- /dev/null +++ b/server/tests/license.test.js @@ -0,0 +1,133 @@ +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); + +// Setup must be required first +const { cleanTestData, testDataDir } = require('./setup'); + +// Mock the email service +const emailService = require('../src/services/email'); +emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' }); + +// Mock Stripe service +const stripeService = require('../src/services/stripe'); +let mockPremiumStatus = { premium: false, expiresAt: null }; +stripeService.checkPremiumStatus = async () => mockPremiumStatus; + +const { app } = require('../src/index'); +const storage = require('../src/storage'); +const config = require('../src/config'); +const { generateSessionToken } = require('../src/services/jwt'); +const jwt = require('jsonwebtoken'); + +// Helper to decode license token from response +function decodeLicenseToken(response) { + const token = response.body.license_token; + if (!token) return null; + return jwt.verify(token, config.JWT_SECRET); +} + +describe('License Routes', () => { + beforeEach(() => { + cleanTestData(); + storage.ensureDirectories(); + mockPremiumStatus = { premium: false, expiresAt: null }; + }); + + after(() => { + cleanTestData(); + }); + + describe('GET /license/check', () => { + it('should require authentication', async () => { + const res = await request(app).get('/license/check'); + + assert.strictEqual(res.status, 401); + }); + + it('should reject invalid token', async () => { + const res = await request(app) + .get('/license/check') + .set('Authorization', 'Bearer invalid-token'); + + assert.strictEqual(res.status, 401); + }); + + it('should return license token with non-premium for user without subscription', async () => { + const token = generateSessionToken('free@example.com'); + + const res = await request(app) + .get('/license/check') + .set('Authorization', `Bearer ${token}`); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.license_token); + + const decoded = decodeLicenseToken(res); + assert.strictEqual(decoded.premium, false); + assert.strictEqual(decoded.grandfathered, false); + assert.strictEqual(decoded.email, 'free@example.com'); + }); + + it('should return license token with premium for user with active subscription', async () => { + mockPremiumStatus = { + premium: true, + expiresAt: '2025-12-31T00:00:00Z', + }; + + const token = generateSessionToken('premium@example.com'); + + const res = await request(app) + .get('/license/check') + .set('Authorization', `Bearer ${token}`); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.license_token); + + const decoded = decodeLicenseToken(res); + assert.strictEqual(decoded.premium, true); + assert.strictEqual(decoded.grandfathered, false); + assert.strictEqual(decoded.email, 'premium@example.com'); + }); + + it('should return license token with premium and grandfathered for grandfathered user', async () => { + // Set up grandfathered file + const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE); + fs.writeFileSync(grandfatheredFile, 'donor@example.com\n'); + + + const token = generateSessionToken('donor@example.com'); + + const res = await request(app) + .get('/license/check') + .set('Authorization', `Bearer ${token}`); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.license_token); + + const decoded = decodeLicenseToken(res); + assert.strictEqual(decoded.premium, true); + assert.strictEqual(decoded.grandfathered, true); + assert.strictEqual(decoded.email, 'donor@example.com'); + }); + + it('should handle grandfathered check case-insensitively', async () => { + const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE); + fs.writeFileSync(grandfatheredFile, 'Donor@Example.com\n'); + + + // Token with lowercase email + const token = generateSessionToken('donor@example.com'); + + const res = await request(app) + .get('/license/check') + .set('Authorization', `Bearer ${token}`); + + const decoded = decodeLicenseToken(res); + assert.strictEqual(decoded.premium, true); + assert.strictEqual(decoded.grandfathered, true); + }); + }); +}); diff --git a/server/tests/setup.js b/server/tests/setup.js new file mode 100644 index 0000000..b85cb97 --- /dev/null +++ b/server/tests/setup.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); + +// Set up test environment variables +process.env.JWT_SECRET = 'test-secret-key-for-testing-only-32chars'; +process.env.STRIPE_SECRET_KEY = 'sk_test_fake'; +process.env.STRIPE_PRICE_MONTHLY = 'price_test_monthly'; +process.env.STRIPE_PRICE_YEARLY = 'price_test_yearly'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; +process.env.EMAIL_FROM = 'test@example.com'; +process.env.BASE_URL = 'http://localhost:3000'; +process.env.DATA_DIR = './tests/test-data'; + +// Clean up test data directory +const testDataDir = './tests/test-data'; + +function cleanTestData() { + try { + if (fs.existsSync(testDataDir)) { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } + } catch (err) { + // Ignore ENOTEMPTY errors from race conditions in parallel tests + if (err.code !== 'ENOTEMPTY' && err.code !== 'ENOENT') { + throw err; + } + } +} + +// Clean before tests +cleanTestData(); + +// Export for use in tests +module.exports = { + cleanTestData, + testDataDir, +}; diff --git a/server/tests/storage.test.js b/server/tests/storage.test.js new file mode 100644 index 0000000..d2f081d --- /dev/null +++ b/server/tests/storage.test.js @@ -0,0 +1,168 @@ +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +// Setup must be required first +const { cleanTestData, testDataDir } = require('./setup'); + +// Now require storage (after env vars are set) +const storage = require('../src/storage'); +const config = require('../src/config'); + +describe('Storage', () => { + beforeEach(() => { + cleanTestData(); + storage.ensureDirectories(); + }); + + after(() => { + cleanTestData(); + }); + + describe('Auth Requests', () => { + it('should create and retrieve an auth request', () => { + const requestId = 'test-request-123'; + const email = 'test@example.com'; + + const created = storage.createAuthRequest(requestId, email); + + assert.strictEqual(created.request_id, requestId); + assert.strictEqual(created.email, email); + assert.strictEqual(created.status, 'pending'); + assert.strictEqual(created.session_token, null); + + const retrieved = storage.getAuthRequest(requestId); + + assert.strictEqual(retrieved.request_id, requestId); + assert.strictEqual(retrieved.email, email); + }); + + it('should update an auth request', () => { + const requestId = 'test-request-456'; + storage.createAuthRequest(requestId, 'test@example.com'); + + const updated = storage.updateAuthRequest(requestId, { + status: 'verified', + session_token: 'fake-token', + }); + + assert.strictEqual(updated.status, 'verified'); + assert.strictEqual(updated.session_token, 'fake-token'); + + const retrieved = storage.getAuthRequest(requestId); + assert.strictEqual(retrieved.status, 'verified'); + }); + + it('should delete an auth request', () => { + const requestId = 'test-request-789'; + storage.createAuthRequest(requestId, 'test@example.com'); + + const deleted = storage.deleteAuthRequest(requestId); + assert.strictEqual(deleted, true); + + const retrieved = storage.getAuthRequest(requestId); + assert.strictEqual(retrieved, null); + }); + + it('should return null for non-existent request', () => { + const retrieved = storage.getAuthRequest('non-existent'); + assert.strictEqual(retrieved, null); + }); + + it('should return null for expired request', () => { + const requestId = 'expired-request'; + const email = 'test@example.com'; + + // Create request with old timestamp + const oldTimestamp = Date.now() - config.REQUEST_ID_EXPIRY_MS - 1000; + const filename = `${oldTimestamp}-${requestId}.json`; + const filePath = path.join(testDataDir, config.AUTH_REQUESTS_DIR, filename); + + fs.writeFileSync(filePath, JSON.stringify({ + request_id: requestId, + email: email, + status: 'pending', + created_at: oldTimestamp, + session_token: null, + })); + + const retrieved = storage.getAuthRequest(requestId); + assert.strictEqual(retrieved, null); + + // File should be deleted + assert.strictEqual(fs.existsSync(filePath), false); + }); + }); + + describe('Rate Limiting', () => { + it('should allow requests under limit', () => { + const email = 'ratelimit@example.com'; + + for (let i = 0; i < config.RATE_LIMIT_MAX_REQUESTS; i++) { + const result = storage.checkRateLimit(email); + assert.strictEqual(result.allowed, true); + } + }); + + it('should block requests over limit', () => { + const email = 'overlimit@example.com'; + + // Use up all allowed requests + for (let i = 0; i < config.RATE_LIMIT_MAX_REQUESTS; i++) { + storage.checkRateLimit(email); + } + + // Next should be blocked + const result = storage.checkRateLimit(email); + assert.strictEqual(result.allowed, false); + assert.ok(result.resetTime > Date.now()); + }); + + it('should track remaining requests', () => { + const email = 'remaining@example.com'; + + const first = storage.checkRateLimit(email); + assert.strictEqual(first.remaining, config.RATE_LIMIT_MAX_REQUESTS - 1); + + const second = storage.checkRateLimit(email); + assert.strictEqual(second.remaining, config.RATE_LIMIT_MAX_REQUESTS - 2); + }); + }); + + describe('Grandfathered Emails', () => { + it('should read grandfathered emails from file', () => { + const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE); + fs.writeFileSync(grandfatheredFile, 'donor1@example.com\nDonor2@Example.com\n'); + + assert.strictEqual(storage.isGrandfathered('donor1@example.com'), true); + assert.strictEqual(storage.isGrandfathered('DONOR1@EXAMPLE.COM'), true); + assert.strictEqual(storage.isGrandfathered('donor2@example.com'), true); + assert.strictEqual(storage.isGrandfathered('notadonor@example.com'), false); + }); + + it('should handle missing grandfathered file gracefully', () => { + // File doesn't exist, should not throw + assert.strictEqual(storage.isGrandfathered('anyone@example.com'), false); + }); + }); + + describe('Pruning', () => { + it('should prune expired auth requests', () => { + // Create an expired request + const oldTimestamp = Date.now() - config.REQUEST_ID_EXPIRY_MS - 1000; + const filename = `${oldTimestamp}-old-request.json`; + const filePath = path.join(testDataDir, config.AUTH_REQUESTS_DIR, filename); + fs.writeFileSync(filePath, JSON.stringify({ created_at: oldTimestamp })); + + // Create a valid request + storage.createAuthRequest('valid-request', 'test@example.com'); + + const pruned = storage.pruneExpiredAuthRequests(); + + assert.strictEqual(pruned, 1); + assert.strictEqual(fs.existsSync(filePath), false); + assert.notStrictEqual(storage.getAuthRequest('valid-request'), null); + }); + }); +}); diff --git a/src/chrome_manifest.json b/src/chrome_manifest.json index 1b0e2c8..b323784 100644 --- a/src/chrome_manifest.json +++ b/src/chrome_manifest.json @@ -3,7 +3,7 @@ "description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.", "homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions", "manifest_version": 3, - "version": "4.3.70", + "version": "4.3.71", "icons": { "16": "/images/16.png", @@ -54,7 +54,8 @@ ], "host_permissions": [ "*://www.youtube.com/*", - "*://m.youtube.com/*" + "*://m.youtube.com/*", + "https://server.lawrencehook.com/*" ], "web_accessible_resources": [{ "resources": ["/images/rys.svg"], diff --git a/src/content-script/main.css b/src/content-script/main.css index 399074f..56500e2 100644 --- a/src/content-script/main.css +++ b/src/content-script/main.css @@ -5,6 +5,7 @@ html[global_enable="true"] ytd-carousel-ad-renderer, html[global_enable="true"] .ytd-display-ad-renderer, html[global_enable="true"] ytd-ad-slot-renderer, html[global_enable="true"] ytd-action-companion-ad-renderer, +html[global_enable="true"] ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"], /* General */ html[global_enable="true"][remove_all_shorts="true"] a[title="Shorts"], @@ -53,7 +54,7 @@ html[global_enable="true"][remove_footer_section="true"] #guide-renderer > /* Homepage */ html[global_enable="true"][remove_header="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > div#header, -html[global_enable="true"][remove_all_but_one="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-rich-grid-row:nth-child(n+2), +html[global_enable="true"][remove_all_but_one="true"] ytd-rich-item-renderer[rys-hidden-row], html[global_enable="true"][remove_all_but_one="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-continuation-item-renderer, html[global_enable="true"][remove_extra_rows="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-rich-section-renderer, html[global_enable="true"][remove_infinite_scroll="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-continuation-item-renderer, @@ -206,95 +207,54 @@ html[global_enable="true"] ytd-rich-grid-row[empty="true"] #contents { /* Reveal suggestions box */ .rys_reveal_box { + --rys-reveal-text: #606060; display: flex; flex-direction: column; align-items: flex-start; - gap: 10px; - padding: 14px 18px; + gap: 6px; + padding: 10px 16px; margin: auto; margin-top: 15%; - background: var(--rys-primary); border-radius: 12px; font-size: 14px; font-family: var(--rys-font); - z-index: 1000000; + z-index: 1; position: relative; width: fit-content; - box-shadow: 0 4px 16px var(--rys-shadow); +} + +html[dark] .rys_reveal_box { + --rys-reveal-text: #aaa; } .rys_reveal_box * { - color: var(--rys-text); -} - -.rys_reveal_header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding-bottom: 8px; - border-bottom: 1px solid var(--rys-separator); -} - -.rys_reveal_branding { - display: flex; - align-items: center; - gap: 5px; - opacity: 0.8; -} - -.rys_reveal_logo { - width: 14px; - height: 14px; -} - -.rys_reveal_brand { - font-weight: 600; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; + color: var(--rys-reveal-text); } .rys_reveal_actions { - display: flex; - align-items: center; - gap: 10px; -} - -.rys_reveal_content { display: flex; align-items: center; justify-content: space-between; width: 100%; - gap: 24px; - white-space: nowrap; -} - -.rys_reveal_message { - font-weight: 500; } .rys_reveal_show { - background: var(--rys-text); - color: var(--rys-primary) !important; - padding: 6px 16px; - border-radius: 20px; - font-size: 13px; - font-weight: 600; + padding: 6px 0; + font-size: 14px; + font-weight: 500; cursor: pointer; text-decoration: none; - box-shadow: 0 2px 6px var(--rys-shadow); } .rys_reveal_show:hover { - background: #f5f5f5; + text-decoration: underline; } .rys_reveal_dismiss { cursor: pointer; font-size: 11px; white-space: nowrap; - opacity: 0.75; + opacity: 0.5; text-decoration: none; transition: opacity 0.15s ease; } @@ -307,12 +267,12 @@ html[global_enable="true"] ytd-rich-grid-row[empty="true"] #contents { .rys_reveal_close { background: none; border: none; - font-size: 11px; + font-size: 14px; cursor: pointer; - padding: 0; + padding: 2px 4px; opacity: 0.6; line-height: 1; - color: var(--rys-text); + color: var(--rys-reveal-text); transition: opacity 0.15s ease; display: flex; align-items: center; @@ -496,8 +456,6 @@ html[global_enable='true'][grayscale_mode='true'] ytd-app { :root { --rys-primary: #065fd4; --rys-text: #fff; - --rys-separator: rgba(255, 255, 255, 0.15); - --rys-shadow: rgba(0, 0, 0, 0.15); --rys-font: "Roboto", "Arial", sans-serif; --rys-banner-height: 40px; } diff --git a/src/content-script/main.js b/src/content-script/main.js index 8cf92fd..7f9c380 100644 --- a/src/content-script/main.js +++ b/src/content-script/main.js @@ -14,26 +14,22 @@ const REDIRECT_URLS = { // Dynamic settings variables const cache = {}; const closedRevealBoxes = new Set(); -const rysLogoUrl = browser.runtime.getURL('images/rys.svg'); const REVEAL_BOX_CONFIGS = [ { containerSelector: 'ytd-page-manager', boxId: 'rys_homepage_reveal_box', - message: 'Homepage suggestions are hidden.', showAction: () => HTML.setAttribute('remove_homepage', false), revealSetting: 'add_reveal_homepage', }, { containerSelector: '#secondary-inner', boxId: 'rys_sidebar_reveal_box', - message: 'Sidebar suggestions are hidden.', showAction: () => HTML.setAttribute('remove_sidebar', false), revealSetting: 'add_reveal_sidebar', }, { containerSelector: '#movie_player', boxId: 'rys_end_of_video_reveal_box', - message: 'End-of-video suggestions are hidden.', showAction: () => HTML.setAttribute('remove_end_of_video', false), revealSetting: 'add_reveal_end_of_video', }, @@ -57,13 +53,47 @@ let lastRedirect; const redirectInterval = 1_000; // 1 second +// Get list of premium feature IDs +const PREMIUM_FEATURE_IDS = SECTIONS.flatMap(section => + section.options.filter(opt => opt.premium).map(opt => opt.id) +); + +// Decode license token to check premium status (no signature verification - we trust our server) +function decodeLicenseToken(token) { + if (!token) return null; + try { + const payload = token.split('.')[1]; + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(base64)); + } catch { + return null; + } +} + +function isPremiumFromToken(token) { + const decoded = decodeLicenseToken(token); + if (!decoded) return false; + const now = Math.floor(Date.now() / 1000); + return decoded.premium === true && decoded.exp > now; +} + // Respond to changes in settings function logStorageChange(changes, area) { if (area !== 'local') return; + // Update premium status in cache if license token changed + if ('license_token' in changes) { + cache['_isPremium'] = isPremiumFromToken(changes['license_token'].newValue); + } + Object.entries(changes).forEach(([id, { oldValue, newValue }]) => { if (oldValue === newValue) return; + // Enforce premium features are false for non-premium users + if (PREMIUM_FEATURE_IDS.includes(id) && cache['_isPremium'] !== true) { + newValue = false; + } + HTML.setAttribute(id, newValue); cache[id] = newValue; @@ -74,7 +104,6 @@ function logStorageChange(changes, area) { } browser.storage.onChanged.addListener(logStorageChange); - // Get settings browser.storage.local.get(settings => { if (!settings) return; @@ -84,6 +113,15 @@ browser.storage.local.get(settings => { browser.storage.local.set(revealUpdates); } + // Enforce premium features are false for non-premium users + const isPremium = isPremiumFromToken(settings['license_token']); + cache['_isPremium'] = isPremium; + if (!isPremium) { + PREMIUM_FEATURE_IDS.forEach(id => { + settings[id] = false; + }); + } + Object.entries({ ...DEFAULT_SETTINGS, ...settings}).forEach(([ id, value ]) => { HTML.setAttribute(id, value); cache[id] = value; @@ -149,9 +187,11 @@ function runDynamicSettings() { handleNewPage(); } - // Double check for redirects. + // Double check for redirects. Also reset dynamicIters so the + // reveal box creation code keeps running on the homepage. if (onHomepage && !cache['redirect_off']) { - handleNewPage(); + dynamicIters = 0; + checkRedirects(); } // Dynamic settings @@ -192,6 +232,23 @@ function runDynamicSettings() { }); } + // Hide all but the first row of homepage suggestions + if (onHomepage) { + const grid = qs('ytd-browse[page-subtype="home"] ytd-rich-grid-renderer'); + if (grid) { + const items = qsa(':scope > #contents > ytd-rich-item-renderer', grid); + if (cache['remove_all_but_one']) { + const perRow = parseInt(getComputedStyle(grid).getPropertyValue('--ytd-rich-grid-items-per-row')) || 4; + items.forEach((item, i) => { + if (i >= perRow) item.setAttribute('rys-hidden-row', ''); + else item.removeAttribute('rys-hidden-row'); + }); + } else { + items.forEach(item => item.removeAttribute('rys-hidden-row')); + } + } + } + // Channel page option if (onChannel) { if (cache['remove_channel_for_you']) { @@ -485,7 +542,7 @@ function runDynamicSettings() { } // Reveal suggestions boxes (only check on early iterations) - if (dynamicIters <= 30) REVEAL_BOX_CONFIGS.forEach(({ containerSelector, boxId, message, showAction, revealSetting }) => { + if (dynamicIters <= 30) REVEAL_BOX_CONFIGS.forEach(({ containerSelector, boxId, showAction, revealSetting }) => { if (closedRevealBoxes.has(boxId)) return; if (qs(`#${boxId}`)) return; @@ -497,20 +554,11 @@ function runDynamicSettings() { box.id = boxId; box.className = 'rys_reveal_box'; box.innerHTML = ` -
-
- - RYS -
-
- Don't show again - -
-
-
- ${message} - Reveal + + Reveal suggestions `; box.querySelector('.rys_reveal_show')?.addEventListener('click', showAction); @@ -587,9 +635,33 @@ function injectAnnouncementBanners() { +function checkRedirects() { + const on = cache['global_enable'] === true; + if ( + on && + onHomepage && + !cache['redirect_off'] && + (!lastRedirect || Date.now() - lastRedirect > redirectInterval) + ) { + if (cache['redirect_to_subs']) { + const button = qs('a#endpoint[href="/feed/subscriptions"]'); + button?.click(); + lastRedirect = Date.now(); + } + if (cache['redirect_to_wl']) { + location.replace(REDIRECT_URLS['redirect_to_wl']); + lastRedirect = Date.now(); + } + if (cache['redirect_to_library']) { + const button = qs('a#endpoint[href="/feed/library"]'); + button?.click(); + lastRedirect = Date.now(); + } + } +} + function handleNewPage() { const on = cache['global_enable'] === true; - dynamicIters = 0; url = location.href; theaterClicked = false; @@ -614,28 +686,7 @@ function handleNewPage() { // Refresh HTML attributes Object.entries(cache).forEach(([key, value]) => HTML.setAttribute(key, value)); - // Homepage redirects - if ( - on && - onHomepage && - !cache['redirect_off'] && - (!lastRedirect || Date.now() - lastRedirect > redirectInterval) - ) { - if (cache['redirect_to_subs']) { - const button = qs('a#endpoint[href="/feed/subscriptions"]'); - button?.click(); - lastRedirect = Date.now(); - } - if (cache['redirect_to_wl']) { - location.replace(REDIRECT_URLS['redirect_to_wl']); - lastRedirect = Date.now(); - } - if (cache['redirect_to_library']) { - const button = qs('a#endpoint[href="/feed/library"]'); - button?.click(); - lastRedirect = Date.now(); - } - } + checkRedirects(); // Redirect the shorts player if (on && onShorts && cache['normalize_shorts']) { diff --git a/src/firefox_manifest.json b/src/firefox_manifest.json index 5e266aa..89dfc12 100644 --- a/src/firefox_manifest.json +++ b/src/firefox_manifest.json @@ -3,7 +3,7 @@ "description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.", "homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions", "manifest_version": 2, - "version": "4.3.70", + "version": "4.3.71", "icons": { "16": "/images/16.png", @@ -55,9 +55,10 @@ "permissions": [ "storage", "*://www.youtube.com/*", - "*://m.youtube.com/*" + "*://m.youtube.com/*", + "https://server.lawrencehook.com/*" ], "web_accessible_resources": [ "/images/rys.svg" ] -} \ No newline at end of file +} diff --git a/src/options/body.css b/src/options/body.css index 2986753..0b5c8db 100644 --- a/src/options/body.css +++ b/src/options/body.css @@ -550,3 +550,483 @@ html[foo=bar] { flex-direction: column; gap: 1px; } + + +/************************************* + * SIGN-IN / ACCOUNT / UPGRADE MODALS + * (Premium/Monetization Feature) + *************************************/ + +#signin_container_background, +#account_container_background, +#upgrade_container_background, +#premium_required_container_background { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 100; + + background-color: rgba(0, 0, 0, 0.6); + + display: flex; +} + +#signin_container, +#account_container, +#upgrade_container, +#premium_required_container { + height: fit-content; + max-height: calc(100vh - 20px); + width: min(90vw, 380px); + margin-top: calc(1.2 * var(--header-height)); + margin-right: auto; + margin-left: auto; + padding: 20px; + overflow-y: auto; + + text-align: center; + + background-color: var(--body-background-color); + color: var(--body-color); + box-shadow: 0px 0px .5px .5px var(--box-shadow-color); + box-sizing: border-box; + + border: 1px solid var(--box-shadow-color); + border-radius: 2px; + + display: flex; + gap: 15px; + flex-direction: column; + align-items: center; +} + +#account_header { + font-size: 1.1rem; + font-weight: bold; +} + +#signin_header, +#upgrade_header, +#premium_required_header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +#signin_header_brand, +#upgrade_header_brand, +#premium_required_header_brand { + display: flex; + align-items: center; + gap: 6px; +} + +#signin_header_logo, +#upgrade_header_logo, +#premium_required_header_logo { + width: 20px; + height: 20px; +} + +#signin_header_rys, +#upgrade_header_rys, +#premium_required_header_rys { + font-size: 1.3rem; + font-weight: normal; +} + +#signin_header_premium, +#upgrade_header_premium, +#premium_required_header_premium { + font-size: 0.7rem; + font-weight: 500; + color: var(--blue-color, #4a6cf7); + letter-spacing: 0.02em; +} + +#signin_email_form, +#signin_error { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} + +#signin_waiting { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; +} + +#signin_email_form p, +#signin_waiting p { + font-size: 0.85rem; + margin: 0; +} + +#signin-email-input { + width: 100%; + padding: 8px 10px; + font-size: 0.85rem; + box-sizing: border-box; + background-color: var(--body-background-color); + color: var(--body-color); +} + +#signin-email-input::placeholder { + color: var(--body-color); + opacity: 0.4; +} + +#signin_container button, +#account_container button, +#upgrade_container button, +#premium_required_container button { + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 500; + border: none; + border-radius: var(--border-radius); + cursor: pointer; + background-color: var(--blue-color); + color: white; +} + +#signin_container button:hover, +#account_container button:hover, +#upgrade_container button:hover, +#premium_required_container button:hover { + opacity: 0.9; +} + +#signin_container button:disabled, +#account_container button:disabled, +#upgrade_container button:disabled, +#premium_required_container button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#signin-send-button, +#upgrade-checkout-button, +#premium-required-signin-button { + background-color: transparent !important; + color: var(--blue-color) !important; + border: 1px solid var(--blue-color) !important; +} + +#signin_email_row { + display: flex; + width: 100%; +} + +#signin_email_row #signin-email-input { + flex: 1; + border-right: none; + border-radius: var(--border-radius) 0 0 var(--border-radius); +} + +#signin-send-button { + padding: 8px 10px; + border-radius: 0 var(--border-radius) var(--border-radius) 0 !important; + display: flex; + align-items: center; + justify-content: center; +} + +#signin-send-button svg { + width: 16px; + height: 16px; +} + +#signin-send-button:hover, +#premium-required-signin-button:hover { + background-color: rgba(6, 0, 251, 0.05) !important; +} + +#signin-cancel-button, +#upgrade-cancel-button, +#premium-required-cancel-button, +#account-signout-button { + background-color: transparent !important; + color: var(--body-color) !important; + border: 1px solid var(--box-shadow-color) !important; +} + +#signin-cancel-button:hover, +#upgrade-cancel-button:hover, +#premium-required-cancel-button:hover, +#account-signout-button:hover { + background-color: var(--box-shadow-color) !important; +} + +#signin-status-row { + display: flex; + align-items: center; + gap: 8px; +} + +#signin-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--box-shadow-color); + border-top-color: var(--blue-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +#signin-waiting-email { + font-weight: bold; + word-break: break-all; + font-size: 0.85rem; +} + +#signin-status { + font-size: 0.8rem; + opacity: 0.7; +} + +#signin-error-message { + color: var(--red-color); +} + +/* Account Modal */ +#account_container { + width: auto; + padding: 16px 20px; + gap: 16px; +} + +#account_row_1 { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; +} + +#account-email-container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +#account-email-label { + font-size: 0.65rem; + opacity: 0.6; +} + +#account-email { + font-size: 0.85rem; +} + +#account-premium-label { + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.02em; + white-space: nowrap; +} + +#account-premium-label[data-status="free"] { + color: var(--body-color); + opacity: 0.6; +} + +#account-premium-label[data-status="premium"] { + color: var(--blue-color); +} + +#account-premium-label[data-status="grandfathered"] { + color: #b8860b; +} + +#account_row_2 { + display: flex; + justify-content: space-between; + width: 100%; +} + +#account_row_2 button { + padding: 6px 12px; + font-size: 0.75rem; + white-space: nowrap; +} + +#account-billing-button { + background-color: transparent !important; + color: var(--blue-color) !important; + border: 1px solid var(--blue-color) !important; +} + +#account-billing-button:hover { + background-color: rgba(6, 0, 251, 0.05) !important; +} + +#account-signout-button { + background-color: transparent !important; + color: var(--body-color) !important; + border: 1px solid var(--box-shadow-color) !important; +} + +#account-signout-button:hover { + background-color: var(--box-shadow-color) !important; +} + +/* Upgrade Modal */ +#upgrade_container { + width: auto; + padding: 24px; + gap: 18px; + align-items: flex-start; +} + + +#upgrade_header_action { + font-size: 0.75rem; + font-weight: 600; + color: var(--body-color); + opacity: 0.5; +} + +#premium_required_description { + font-size: 0.85rem; + margin: 0; +} + +#upgrade_plans { + display: flex; + gap: 12px; + width: 100%; +} + +.upgrade-plan { + width: 120px; + padding: 12px; + border: 1px solid var(--box-shadow-color); + border-radius: var(--border-radius); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; +} + +.upgrade-plan:hover { + border-color: var(--light-blue); +} + +.upgrade-plan[selected] { + border-color: var(--blue-color); + background-color: rgba(6, 0, 251, 0.08); +} +html[dark_mode='true'] .upgrade-plan[selected] { + background-color: rgba(74, 108, 247, 0.15); +} + +.plan-name { + font-weight: 600; + font-size: 0.75rem; + color: var(--body-color); +} + +.plan-price { + font-size: 0.9rem; + font-weight: bold; + color: var(--label-color); +} + +.plan-price-period { + font-size: 0.7rem; + font-weight: normal; + opacity: 0.7; +} + + +#upgrade_buttons, +#premium_required_buttons { + display: flex; + gap: 10px; + justify-content: flex-end; + width: 100%; +} + +#upgrade_buttons button, +#premium_required_buttons button { + padding: 6px 12px; + font-size: 0.75rem; + white-space: nowrap; +} + +#upgrade-checkout-button { + width: fit-content; +} + +#upgrade-checkout-button:hover { + background-color: rgba(6, 0, 251, 0.05) !important; +} + +#upgrade-cancel-button { + width: fit-content; +} + +/* Premium lock icon for options */ +html:not([is_premium='true']) .option[data-premium='true'] { + opacity: 0.25; + position: relative; +} + +html:not([is_premium='true']) .option[data-premium='true']::after { + content: ''; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; +} + +/* Premium options unlocked */ +html[is_premium='true'] .option[data-premium='true'] { + opacity: 1; +} + +/* Premium lock icon for settings menu items */ +html:not([is_premium='true']) #settings-menu div[data-premium='true'], +html:not([is_premium='true']) #power-options div[data-premium='true'] { + opacity: 0.25; + position: relative; +} + +html:not([is_premium='true']) #settings-menu div[data-premium='true']::after, +html:not([is_premium='true']) #power-options div[data-premium='true']::after { + content: ''; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; +} + +html[is_premium='true'] #settings-menu div[data-premium='true'], +html[is_premium='true'] #power-options div[data-premium='true'] { + opacity: 1; +} + + + diff --git a/src/options/donors/donors.css b/src/options/donors/donors.css index 56bcca2..2509eee 100644 --- a/src/options/donors/donors.css +++ b/src/options/donors/donors.css @@ -4,153 +4,25 @@ } html[dark_mode='true'] { - --body-color: #E0E0E0; - --body-background-color: #1a1a2e; - --card-background: #16213e; - --card-border: #0f3460; - --label-color: #E9E9E9; - --box-shadow-color: rgba(0, 0, 0, 0.4); --accent-light: rgba(233, 30, 99, 0.15); } -html[dark_mode='false'], html:not([dark_mode]) { - --body-color: #333; - --body-background-color: #f8f9fa; - --card-background: #fff; - --card-border: #e9ecef; - --label-color: #212529; - --box-shadow-color: rgba(0, 0, 0, 0.08); -} - -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; - height: 600px; - overflow: hidden; -} - -body { - background-color: var(--body-background-color); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - color: var(--body-color); - line-height: 1.4; -} - -.page-container { - height: 600px; - max-width: 640px; - margin: 0 auto; - padding: 12px 16px; - display: flex; - flex-direction: column; -} - -/* Back Button */ -#back-button { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 5px 10px; - text-decoration: none; - color: var(--body-color); - background: var(--card-background); - border: 1px solid var(--card-border); - border-radius: 6px; - font-size: 12px; - font-weight: 500; - transition: all 0.2s ease; - width: fit-content; -} - -#back-button:hover { - background: var(--accent-light); - border-color: var(--accent); - color: var(--accent); -} - -#back-button svg { - width: 14px; - height: 14px; -} - -/* Header */ -header { - flex-shrink: 0; - margin-bottom: 16px; -} - -.header-content { - text-align: center; - margin-top: 12px; +header h1 { + margin-bottom: 4px; + font-size: 22px; } .header-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - background: var(--accent-light); - border-radius: 50%; - margin-bottom: 10px; -} - -.header-icon svg { width: 22px; height: 22px; - color: var(--accent); -} - -header h1 { - margin: 0 0 6px; - font-size: 22px; - font-weight: 700; - color: var(--label-color); -} - -.subtitle { - margin: 0; - font-size: 13px; - color: var(--body-color); - opacity: 0.85; -} - -.subtitle.secondary { - margin-top: 4px; - font-size: 12px; - opacity: 0.7; -} - -.subtitle a { - color: var(--accent); - text-decoration: none; - font-weight: 500; -} - -.subtitle a:hover { - text-decoration: underline; -} - -/* Main content */ -main { - flex: 1; - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; } /* Tier Cards */ .tier { - flex: 1; background: var(--card-background); border: 1px solid var(--card-border); - border-radius: 12px; - padding: 14px 16px; + border-radius: 10px; + padding: 10px 14px; box-shadow: 0 1px 4px var(--box-shadow-color); display: flex; align-items: flex-start; @@ -168,8 +40,8 @@ main { display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 30px; + height: 30px; border-radius: 50%; flex-shrink: 0; background: var(--accent); @@ -222,9 +94,9 @@ footer { flex-shrink: 0; background: var(--card-background); border: 1px solid var(--card-border); - border-radius: 12px; - padding: 14px 18px; - margin-top: 16px; + border-radius: 10px; + padding: 10px 14px; + margin-top: 8px; } .footer-content { diff --git a/src/options/donors/donors.html b/src/options/donors/donors.html index bd130c1..8745b7b 100644 --- a/src/options/donors/donors.html +++ b/src/options/donors/donors.html @@ -10,6 +10,7 @@ + @@ -35,20 +36,17 @@
+ + + Back +
- - - Back - - -
-
- -
-

Supporters

-

RYS is made possible by generous donations from people like you.

-

You can also support by sharing with a friend or leaving a review

-
+

+ + Supporters +

+

RYS is supported by people like you.

+

You can also support by sharing with a friend or leaving a review

diff --git a/src/options/feedback/feedback.css b/src/options/feedback/feedback.css index e8d2ade..51ea0ac 100644 --- a/src/options/feedback/feedback.css +++ b/src/options/feedback/feedback.css @@ -8,134 +8,29 @@ } html[dark_mode='true'] { - --body-color: #E0E0E0; - --body-background-color: #1a1a2e; - --card-background: #16213e; - --card-border: #0f3460; - --label-color: #E9E9E9; - --box-shadow-color: rgba(0, 0, 0, 0.4); --accent-light: rgba(33, 150, 243, 0.15); --github: #E0E0E0; } -html[dark_mode='false'], html:not([dark_mode]) { - --body-color: #333; - --body-background-color: #f8f9fa; - --card-background: #fff; - --card-border: #e9ecef; - --label-color: #212529; - --box-shadow-color: rgba(0, 0, 0, 0.08); -} - -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - padding: 0; - height: 600px; - overflow: hidden; -} - -body { - background-color: var(--body-background-color); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - color: var(--body-color); - line-height: 1.4; -} - -.page-container { - height: 600px; - max-width: 640px; - margin: 0 auto; - padding: 12px 16px; - display: flex; - flex-direction: column; -} - -/* Back Button */ -#back-button { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 5px 10px; - text-decoration: none; - color: var(--body-color); - background: var(--card-background); - border: 1px solid var(--card-border); - border-radius: 6px; - font-size: 12px; - font-weight: 500; - transition: all 0.2s ease; - width: fit-content; -} - -#back-button:hover { - background: var(--accent-light); - border-color: var(--accent); - color: var(--accent); -} - -#back-button svg { - width: 14px; - height: 14px; -} - -/* Header */ -header { - flex-shrink: 0; - margin-bottom: 12px; -} - -.header-content { - text-align: center; - margin-top: 8px; +header h1 { + font-size: 18px; } .header-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - background: var(--accent-light); - border-radius: 50%; - margin-bottom: 6px; -} - -.header-icon svg { - width: 18px; - height: 18px; - color: var(--accent); -} - -header h1 { - margin: 0; - font-size: 18px; - font-weight: 700; - color: var(--label-color); + width: 20px; + height: 20px; } .subtitle { display: none; } -/* Main content */ -main { - flex: 1; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; -} - /* Section Cards */ .section-card { background: var(--card-background); border: 1px solid var(--card-border); border-radius: 10px; - padding: 12px 14px; + padding: 10px 12px; box-shadow: 0 1px 4px var(--box-shadow-color); } @@ -143,7 +38,7 @@ main { display: flex; align-items: center; gap: 6px; - margin: 0 0 8px; + margin: 0 0 6px; font-size: 14px; font-weight: 600; color: var(--label-color); @@ -157,7 +52,7 @@ main { } .section-card > p { - margin: 0 0 10px; + margin: 0 0 8px; font-size: 12px; color: var(--body-color); opacity: 0.85; @@ -175,8 +70,8 @@ main { flex-direction: column; align-items: center; justify-content: center; - gap: 6px; - padding: 10px 6px; + gap: 4px; + padding: 8px 6px; background: var(--body-background-color); border-radius: 8px; text-decoration: none; @@ -242,7 +137,7 @@ main { /* Troubleshooting */ .troubleshoot-steps { - margin-bottom: 8px; + margin-bottom: 6px; } .troubleshoot-steps p { @@ -265,7 +160,7 @@ main { .divider { height: 1px; background: var(--card-border); - margin: 8px 0; + margin: 6px 0; } /* Help Request */ @@ -284,7 +179,7 @@ main { display: flex; justify-content: space-between; align-items: center; - padding: 6px 12px; + padding: 5px 10px; } .info-row:not(:last-child) { diff --git a/src/options/feedback/feedback.html b/src/options/feedback/feedback.html index 29d18d5..51a902a 100644 --- a/src/options/feedback/feedback.html +++ b/src/options/feedback/feedback.html @@ -10,6 +10,7 @@ + @@ -22,19 +23,16 @@
+ + + Back +
- - - Back - - -
-
- -
-

Feedback & Support

-

We'd love to hear from you

-
+

+ + Feedback & Support +

+

We'd love to hear from you

diff --git a/src/options/general.css b/src/options/general.css index ce35e4d..740b0a3 100644 --- a/src/options/general.css +++ b/src/options/general.css @@ -27,6 +27,8 @@ html[dark_mode='true'] { --body-background-color: #393939; --label-color: #E9E9E9; --box-shadow-color: rgba(127, 127, 127, 0.90); + --blue-color: hsl(238 80% 70%); + --light-blue: hsl(238 50% 70%); } html[dark_mode='false'], html:not([dark_mode]) { @@ -43,10 +45,12 @@ html[global_enable="false"] #lock_code_container, html[global_enable="true"][lock_code="false"] #lock_code_container, html[global_enable="true"][lock_code="true"]:not([entering_lock_code]) #lock_code_container, html[global_enable="true"] #disabled_message_container, -html[global_enable="false"] #main_container, -html[global_enable="false"] #search_bar { +html[global_enable="false"] #main_container { display: none; } +html[global_enable="false"] #search_bar { + visibility: hidden; +} body { background-color: var(--body-background-color); diff --git a/src/options/header.css b/src/options/header.css index 2c80ece..2b5a10f 100644 --- a/src/options/header.css +++ b/src/options/header.css @@ -43,12 +43,19 @@ html[dark_mode='false'] #header-light { display: none } max-width: var(--icon-dim); } #header-text { - margin-right: auto; font-size: 1.3rem; color: var(--label-color); } +#header-premium-badge { + font-size: 0.7rem; + font-weight: 500; + color: var(--blue-color, #4a6cf7); + letter-spacing: 0.02em; +} + #search_bar { + margin-left: auto; margin-right: auto; } #search_bar input { diff --git a/src/options/main.html b/src/options/main.html index d78e89b..1ffff37 100644 --- a/src/options/main.html +++ b/src/options/main.html @@ -17,6 +17,9 @@ + + + @@ -121,6 +124,107 @@
+ + + + + + + +