From 8825666512ff6bfb16b237f3180af4ed259583a8 Mon Sep 17 00:00:00 2001 From: Lawrence Hook Date: Sun, 15 Feb 2026 18:45:37 -0500 Subject: [PATCH] Merge the monetization feature branch. Add RYS Premium subscription system Introduce optional paid tier with sign-in, Stripe billing, and premium feature gating. Core features remain free. Includes Node.js server for auth, payments, and license management. Extension changes: - Sign-in flow with magic link email - Premium/upgrade/account modals with branded UI - Premium feature gating (60+ advanced settings) - Password lock and scheduling (premium) - Fix "hide all but first row" for YouTube's new flat homepage DOM - Hide sidebar ad panels by default - Enable "Hide all Shorts" by default Server: - Magic link auth with rate limiting - Stripe checkout, webhooks, and billing portal - JWT-based license tokens - AWS SES transactional emails (welcome, sign-in, cancellation) - Grandfathered donor support Also adds CI workflow, server test suite, and extension unit tests. --- .github/workflows/test.yml | 26 + .gitignore | 1 + DEPLOYMENT.md | 521 ++++ ISSUES.md | 8 + LAUNCH_CHECKLIST.md | 40 + README.md | 2 +- TESTING.md | 357 +++ premium-subscription-spec.md | 682 ++++++ server/.env.example | 18 + server/.gitignore | 4 + server/README.md | 72 + server/data.example/grandfathered.json | 4 + server/package-lock.json | 2630 +++++++++++++++++++++ server/package.json | 27 + server/src/config.js | 37 + server/src/index.js | 153 ++ server/src/routes/auth.js | 202 ++ server/src/routes/billing.js | 35 + server/src/routes/checkout.js | 46 + server/src/routes/license.js | 42 + server/src/routes/webhook.js | 109 + server/src/services/email.js | 195 ++ server/src/services/jwt.js | 63 + server/src/services/stripe.js | 105 + server/src/storage/index.js | 297 +++ server/src/templates.js | 168 ++ server/tests/auth.test.js | 180 ++ server/tests/checkout.test.js | 159 ++ server/tests/health.test.js | 43 + server/tests/jwt.test.js | 100 + server/tests/license.test.js | 133 ++ server/tests/setup.js | 37 + server/tests/storage.test.js | 168 ++ src/chrome_manifest.json | 3 +- src/content-script/main.css | 80 +- src/content-script/main.js | 139 +- src/firefox_manifest.json | 5 +- src/options/body.css | 480 ++++ src/options/donors/donors.css | 148 +- src/options/donors/donors.html | 24 +- src/options/feedback/feedback.css | 129 +- src/options/feedback/feedback.html | 22 +- src/options/general.css | 8 +- src/options/header.css | 9 +- src/options/main.html | 115 +- src/options/main.js | 40 +- src/options/settings-menu.js | 431 +++- src/options/subpage-base.css | 129 + src/shared/auth.js | 115 + src/shared/banners.js | 12 +- src/shared/config.js | 19 + src/shared/license.js | 177 ++ src/shared/main.js | 209 +- tests/content-script/url-patterns.test.js | 181 ++ tests/package.json | 14 + tests/setup.js | 235 ++ tests/shared/auth.test.js | 260 ++ tests/shared/banners.test.js | 118 + tests/shared/license-billing.test.js | 154 ++ tests/shared/license.test.js | 249 ++ tests/shared/main.test.js | 225 ++ tests/shared/md5.test.js | 106 + tests/shared/utils.test.js | 346 +++ 63 files changed, 10364 insertions(+), 482 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 DEPLOYMENT.md create mode 100644 ISSUES.md create mode 100644 LAUNCH_CHECKLIST.md create mode 100644 TESTING.md create mode 100644 premium-subscription-spec.md create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/README.md create mode 100644 server/data.example/grandfathered.json create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/config.js create mode 100644 server/src/index.js create mode 100644 server/src/routes/auth.js create mode 100644 server/src/routes/billing.js create mode 100644 server/src/routes/checkout.js create mode 100644 server/src/routes/license.js create mode 100644 server/src/routes/webhook.js create mode 100644 server/src/services/email.js create mode 100644 server/src/services/jwt.js create mode 100644 server/src/services/stripe.js create mode 100644 server/src/storage/index.js create mode 100644 server/src/templates.js create mode 100644 server/tests/auth.test.js create mode 100644 server/tests/checkout.test.js create mode 100644 server/tests/health.test.js create mode 100644 server/tests/jwt.test.js create mode 100644 server/tests/license.test.js create mode 100644 server/tests/setup.js create mode 100644 server/tests/storage.test.js create mode 100644 src/options/subpage-base.css create mode 100644 src/shared/auth.js create mode 100644 src/shared/config.js create mode 100644 src/shared/license.js create mode 100644 tests/content-script/url-patterns.test.js create mode 100644 tests/package.json create mode 100644 tests/setup.js create mode 100644 tests/shared/auth.test.js create mode 100644 tests/shared/banners.test.js create mode 100644 tests/shared/license-billing.test.js create mode 100644 tests/shared/license.test.js create mode 100644 tests/shared/main.test.js create mode 100644 tests/shared/md5.test.js create mode 100644 tests/shared/utils.test.js 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 bc51fc5..7b71d24 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 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) --- 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..215377b --- /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.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "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 c48741b..b323784 100644 --- a/src/chrome_manifest.json +++ b/src/chrome_manifest.json @@ -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 dcfff07..89dfc12 100644 --- a/src/firefox_manifest.json +++ b/src/firefox_manifest.json @@ -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 @@
+ + + + + + + +