diff --git a/CHANGELOG.md b/CHANGELOG.md index e1aeb7f0b7..ce3f7c193c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th Changes since the last non-beta release. +#### Added + +- **Attribution Comment**: Added HTML comment attribution to Rails views containing React on Rails functionality. The comment automatically displays which version is in use (open source React on Rails or React on Rails Pro) and, for Pro users, shows the license status. This helps identify React on Rails usage across your application. [PR #1857](https://github.com/shakacode/react_on_rails/pull/1857) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + #### Breaking Changes - **React on Rails Core Package**: Several Pro-only methods have been removed from the core package and are now exclusively available in the `react-on-rails-pro` package. If you're using any of the following methods, you'll need to migrate to React on Rails Pro: @@ -65,7 +69,7 @@ To migrate to React on Rails Pro: import ReactOnRails from 'react-on-rails-pro'; ``` -4. If you're using a free license for personal (non-production) use, you can obtain one at [React on Rails Pro License](https://www.shakacode.com/react-on-rails-pro). The Pro package is free for personal, educational, and non-production usage. +4. If you're using a free license, you can obtain one at [React on Rails Pro License](https://www.shakacode.com/react-on-rails-pro). **Important: The free 3-month evaluation license is intended for personal, educational, and evaluation purposes only. It should NOT be used for production deployments.** Production use requires a paid license. **Note:** If you're not using any of the Pro-only methods listed above, no changes are required. diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index c37e81bd2c..60cdd21020 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -620,10 +620,23 @@ def rails_context_if_not_already_rendered @rendered_rails_context = true - content_tag(:script, - json_safe_and_pretty(data).html_safe, - type: "application/json", - id: "js-react-on-rails-context") + attribution_comment = react_on_rails_attribution_comment + script_tag = content_tag(:script, + json_safe_and_pretty(data).html_safe, + type: "application/json", + id: "js-react-on-rails-context") + + "#{attribution_comment}\n#{script_tag}".html_safe + end + + # Generates the HTML attribution comment + # Pro version calls ReactOnRailsPro::Utils for license-specific details + def react_on_rails_attribution_comment + if ReactOnRails::Utils.react_on_rails_pro? + ReactOnRailsPro::Utils.pro_attribution_comment + else + "" + end end # prepend the rails_context if not yet applied diff --git a/lib/react_on_rails/pro_utils.rb b/lib/react_on_rails/pro_utils.rb index 030e5ece8b..bf19e9bf78 100644 --- a/lib/react_on_rails/pro_utils.rb +++ b/lib/react_on_rails/pro_utils.rb @@ -5,9 +5,9 @@ module ProUtils PRO_ONLY_OPTIONS = %i[immediate_hydration].freeze # Checks if React on Rails Pro features are available - # @return [Boolean] true if Pro license is valid, false otherwise + # @return [Boolean] true if Pro is installed and licensed, false otherwise def self.support_pro_features? - ReactOnRails::Utils.react_on_rails_pro_licence_valid? + ReactOnRails::Utils.react_on_rails_pro? end def self.disable_pro_render_options_if_not_licensed(raw_options) diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index 66b1e2d64c..624040a7ed 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -228,11 +228,19 @@ def self.gem_available?(name) end end - # Todo -- remove this for v13, as we don't need both boolean and number + # Checks if React on Rails Pro is installed and licensed. + # This method validates the license and will raise an exception if invalid. + # + # @return [Boolean] true if Pro is available with valid license + # @raise [ReactOnRailsPro::Error] if license is invalid def self.react_on_rails_pro? return @react_on_rails_pro if defined?(@react_on_rails_pro) - @react_on_rails_pro = gem_available?("react_on_rails_pro") + @react_on_rails_pro = begin + return false unless gem_available?("react_on_rails_pro") + + ReactOnRailsPro::Utils.validated_license_data!.present? + end end # Return an empty string if React on Rails Pro is not installed @@ -246,21 +254,6 @@ def self.react_on_rails_pro_version end end - def self.react_on_rails_pro_licence_valid? - return @react_on_rails_pro_licence_valid if defined?(@react_on_rails_pro_licence_valid) - - @react_on_rails_pro_licence_valid = begin - return false unless react_on_rails_pro? - - # Maintain compatibility with legacy versions of React on Rails Pro: - # Earlier releases did not require license validation, as they were distributed as private gems. - # This check ensures that the method works correctly regardless of the installed version. - return true unless ReactOnRailsPro::Utils.respond_to?(:licence_valid?) - - ReactOnRailsPro::Utils.licence_valid? - end - end - def self.rsc_support_enabled? return false unless react_on_rails_pro? diff --git a/react_on_rails_pro/.gitignore b/react_on_rails_pro/.gitignore index edfb0b3f97..0134fd4266 100644 --- a/react_on_rails_pro/.gitignore +++ b/react_on_rails_pro/.gitignore @@ -72,3 +72,6 @@ yalc.lock # File Generated by ROR FS-based Registry **/generated + +# React on Rails Pro License Key +config/react_on_rails_pro_license.key diff --git a/react_on_rails_pro/CHANGELOG.md b/react_on_rails_pro/CHANGELOG.md index 4ece4dc3f1..73268d64ab 100644 --- a/react_on_rails_pro/CHANGELOG.md +++ b/react_on_rails_pro/CHANGELOG.md @@ -18,6 +18,7 @@ You can find the **package** version numbers from this repo's tags and below in ### Added - Added `cached_stream_react_component` helper method, similar to `cached_react_component` but for streamed components. +- **License Validation System**: Implemented comprehensive JWT-based license validation with offline verification using RSA-256 signatures. License validation occurs at startup in both Ruby and Node.js environments. Supports required fields (`sub`, `iat`, `exp`) and optional fields (`plan`, `organization`, `iss`). FREE evaluation licenses are available for 3 months at [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro). [PR #1857](https://github.com/shakacode/react_on_rails/pull/1857) by [AbanoubGhadban](https://github.com/AbanoubGhadban). ### Changed (Breaking) - `config.prerender_caching`, which controls caching for non-streaming components, now also controls caching for streamed components. To disable caching for an individual render, pass `internal_option(:skip_prerender_cache)`. diff --git a/react_on_rails_pro/CI_SETUP.md b/react_on_rails_pro/CI_SETUP.md new file mode 100644 index 0000000000..9bf98629c5 --- /dev/null +++ b/react_on_rails_pro/CI_SETUP.md @@ -0,0 +1,502 @@ +# React on Rails Pro - CI/CD Setup Guide + +This guide explains how to configure React on Rails Pro licenses for CI/CD environments. + +## Quick Start + +**All CI/CD environments require a valid license!** + +1. Get a FREE 3-month license at [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) +2. Add `REACT_ON_RAILS_PRO_LICENSE` to your CI environment variables +3. Done! Your tests will run with a valid license + +**⚠️ Important: The free 3-month evaluation license is intended for personal, educational, and evaluation purposes only (including CI/CD testing). It should NOT be used for production deployments. Production use requires a paid license.** + +## Getting a License for CI + +You have two options: + +### Option 1: Use a Team Member's License +- Any developer's FREE license works for CI +- Share it via CI secrets/environment variables +- Easy and quick + +### Option 2: Create a Dedicated CI License +- Register with `ci@yourcompany.com` or similar +- Get a FREE 3-month evaluation license (for personal, educational, and evaluation purposes only) +- Renew every 3 months (or use a paid license for production) + +## Configuration by CI Provider + +### GitHub Actions + +**Step 1: Add License to Secrets** + +1. Go to your repository settings +2. Navigate to: Settings → Secrets and variables → Actions +3. Click "New repository secret" +4. Name: `REACT_ON_RAILS_PRO_LICENSE` +5. Value: Your complete JWT license token (starts with `eyJ...`) + +**Step 2: Use in Workflow** + +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + env: + REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.REACT_ON_RAILS_PRO_LICENSE }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3 + bundler-cache: true + + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: | + bundle install + yarn install + + - name: Run tests + run: bundle exec rspec +``` + +### GitLab CI/CD + +**Step 1: Add License to CI/CD Variables** + +1. Go to your project +2. Navigate to: Settings → CI/CD → Variables +3. Click "Add variable" +4. Key: `REACT_ON_RAILS_PRO_LICENSE` +5. Value: Your license token +6. ✅ Check "Protect variable" (optional) +7. ✅ Check "Mask variable" (recommended) + +**Step 2: Use in Pipeline** + +```yaml +# .gitlab-ci.yml +image: ruby:3.3 + +variables: + RAILS_ENV: test + NODE_ENV: test + +before_script: + - gem install bundler + - bundle install --jobs $(nproc) + - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - + - echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list + - apt-get update && apt-get install -y nodejs yarn + - yarn install + +test: + script: + - bundle exec rspec + # License is automatically available from CI/CD variables +``` + +### CircleCI + +**Step 1: Add License to Environment Variables** + +1. Go to your project settings +2. Navigate to: Project Settings → Environment Variables +3. Click "Add Environment Variable" +4. Name: `REACT_ON_RAILS_PRO_LICENSE` +5. Value: Your license token + +**Step 2: Use in Config** + +```yaml +# .circleci/config.yml +version: 2.1 + +jobs: + test: + docker: + - image: cimg/ruby:3.3-node + + steps: + - checkout + + - restore_cache: + keys: + - gem-cache-{{ checksum "Gemfile.lock" }} + - yarn-cache-{{ checksum "yarn.lock" }} + + - run: + name: Install dependencies + command: | + bundle install --path vendor/bundle + yarn install + + - save_cache: + key: gem-cache-{{ checksum "Gemfile.lock" }} + paths: + - vendor/bundle + + - save_cache: + key: yarn-cache-{{ checksum "yarn.lock" }} + paths: + - node_modules + + - run: + name: Run tests + command: bundle exec rspec + # License is automatically available from environment variables + +workflows: + version: 2 + test: + jobs: + - test +``` + +### Travis CI + +**Step 1: Add License to Environment Variables** + +1. Go to your repository settings on Travis CI +2. Navigate to: More options → Settings → Environment Variables +3. Name: `REACT_ON_RAILS_PRO_LICENSE` +4. Value: Your license token +5. ✅ Check "Display value in build log": **NO** (keep it secret) + +**Step 2: Use in Config** + +```yaml +# .travis.yml +language: ruby +rvm: + - 3.3 + +node_js: + - 18 + +cache: + bundler: true + yarn: true + +before_install: + - nvm install 18 + - node --version + - yarn --version + +install: + - bundle install + - yarn install + +script: + - bundle exec rspec + # License is automatically available from environment variables +``` + +### Jenkins + +**Step 1: Add License to Credentials** + +1. Go to Jenkins → Manage Jenkins → Manage Credentials +2. Select appropriate domain +3. Add Credentials → Secret text +4. Secret: Your license token +5. ID: `REACT_ON_RAILS_PRO_LICENSE` +6. Description: "React on Rails Pro License" + +**Step 2: Use in Jenkinsfile** + +```groovy +// Jenkinsfile +pipeline { + agent any + + environment { + RAILS_ENV = 'test' + NODE_ENV = 'test' + } + + stages { + stage('Setup') { + steps { + // Load license from credentials + withCredentials([string(credentialsId: 'REACT_ON_RAILS_PRO_LICENSE', variable: 'REACT_ON_RAILS_PRO_LICENSE')]) { + sh 'echo "License loaded"' + } + } + } + + stage('Install Dependencies') { + steps { + sh 'bundle install' + sh 'yarn install' + } + } + + stage('Test') { + steps { + withCredentials([string(credentialsId: 'REACT_ON_RAILS_PRO_LICENSE', variable: 'REACT_ON_RAILS_PRO_LICENSE')]) { + sh 'bundle exec rspec' + } + } + } + } +} +``` + +### Bitbucket Pipelines + +**Step 1: Add License to Repository Variables** + +1. Go to Repository settings +2. Navigate to: Pipelines → Repository variables +3. Name: `REACT_ON_RAILS_PRO_LICENSE` +4. Value: Your license token +5. ✅ Check "Secured" (recommended) + +**Step 2: Use in Pipeline** + +```yaml +# bitbucket-pipelines.yml +image: ruby:3.3 + +definitions: + caches: + bundler: vendor/bundle + yarn: node_modules + +pipelines: + default: + - step: + name: Test + caches: + - bundler + - yarn + script: + - apt-get update && apt-get install -y nodejs npm + - npm install -g yarn + - bundle install --path vendor/bundle + - yarn install + - bundle exec rspec + # License is automatically available from repository variables +``` + +### Generic CI (Environment Variable) + +For any CI system that supports environment variables: + +**Step 1: Export Environment Variable** + +```bash +export REACT_ON_RAILS_PRO_LICENSE="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Step 2: Run Tests** + +```bash +bundle install +yarn install +bundle exec rspec +``` + +The license will be automatically picked up from the environment variable. + +## Docker-based CI + +If using Docker in CI: + +```dockerfile +# Dockerfile +FROM ruby:3.3-node + +# ... other setup ... + +# License will be passed at runtime via environment variable +# DO NOT COPY license file into image +ENV REACT_ON_RAILS_PRO_LICENSE="" + +CMD ["bundle", "exec", "rspec"] +``` + +**Run with license:** + +```bash +docker run -e REACT_ON_RAILS_PRO_LICENSE="$REACT_ON_RAILS_PRO_LICENSE" your-image +``` + +## Verification + +License validation happens automatically when Rails starts. + +✅ **If your CI tests run, your license is valid** +❌ **If license is invalid, Rails fails to start immediately** + +**No verification step needed** - the application won't start without a valid license. + +### Debug License Issues + +If Rails fails to start in CI with license errors: + +```bash +# Check if license environment variable is set (show first 20 chars only) +echo "License set: ${REACT_ON_RAILS_PRO_LICENSE:0:20}..." + +# Decode the license to check expiration +bundle exec rails runner " + require 'jwt' + payload = JWT.decode(ENV['REACT_ON_RAILS_PRO_LICENSE'], nil, false).first + puts 'Email: ' + payload['sub'] + puts 'Expires: ' + Time.at(payload['exp']).to_s + puts 'Expired: ' + (Time.now.to_i > payload['exp']).to_s +" +``` + +**Common issues:** +- License not set in CI environment variables +- License truncated when copying (should be 500+ characters) +- License expired (get a new FREE license at https://shakacode.com/react-on-rails-pro) + +## Security Best Practices + +1. ✅ **Always use secrets/encrypted variables** - Never commit licenses to code +2. ✅ **Mask license in logs** - Most CI systems support this +3. ✅ **Limit license access** - Only give to necessary jobs/pipelines +4. ✅ **Rotate regularly** - Get new FREE license every 3 months +5. ✅ **Use organization secrets** - Share across repositories when appropriate + +## Troubleshooting + +### Error: "No license found" in CI + +**Checklist:** +- ✅ License added to CI environment variables +- ✅ Variable name is exactly `REACT_ON_RAILS_PRO_LICENSE` +- ✅ License value is complete (not truncated) +- ✅ License is accessible in the job/step + +**Debug:** +```bash +# Check if variable exists (don't print full value!) +if [ -n "$REACT_ON_RAILS_PRO_LICENSE" ]; then + echo "✅ License environment variable is set" +else + echo "❌ License environment variable is NOT set" +fi +``` + +### Error: "License has expired" + +**Solution:** +1. Get a new FREE 3-month license from [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) +2. Update the `REACT_ON_RAILS_PRO_LICENSE` variable in your CI settings +3. Done! No code changes needed + +### Tests Pass Locally But Fail in CI + +**Common causes:** +- License not set in CI environment +- Wrong variable name +- License truncated when copying + +**Solution:** +Compare local and CI environments: + +```bash +# Local +echo $REACT_ON_RAILS_PRO_LICENSE | wc -c # Should be ~500+ characters + +# In CI (add debug step) +echo $REACT_ON_RAILS_PRO_LICENSE | wc -c # Should match local +``` + +## Multiple Environments + +### Separate Licenses for Different Environments + +If you want different licenses per environment: + +```yaml +# GitHub Actions example +jobs: + test: + env: + REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.CI_LICENSE }} + + staging-deploy: + env: + REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.STAGING_LICENSE }} + + production-deploy: + env: + REACT_ON_RAILS_PRO_LICENSE: ${{ secrets.PRODUCTION_LICENSE }} +``` + +### When to Use Different Licenses + +- **CI/Test**: FREE evaluation license (for personal, educational, and evaluation purposes - renew every 3 months) +- **Staging**: Can use FREE evaluation license for non-production testing or paid license +- **Production**: Paid license (required - free licenses are NOT for production use) + +## License Renewal + +### Setting Up Renewal Reminders + +FREE evaluation licenses (for personal, educational, and evaluation purposes only) expire every 3 months. Set a reminder: + +1. **Calendar reminder**: 2 weeks before expiration +2. **CI notification**: Tests will fail when expired +3. **Email**: We'll send renewal reminders + +### Renewal Process + +1. Visit [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) +2. Log in with your email +3. Get new FREE license (or upgrade to paid) +4. Update `REACT_ON_RAILS_PRO_LICENSE` in CI settings +5. Done! No code changes needed + +## Support + +Need help with CI setup? + +- **Documentation**: [LICENSE_SETUP.md](./LICENSE_SETUP.md) +- **Get FREE License**: [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) +- **Email Support**: support@shakacode.com +- **CI Issues**: Include your CI provider name and error message + +## License Management + +**Centralized License Management** (for teams): + +1. **1Password/Vault**: Store license in team vault +2. **CI Variables**: Sync from secrets manager +3. **Documentation**: Keep renewal dates in team wiki +4. **Automation**: Script license updates across environments + +```bash +# Example: Update license across multiple CI systems +./update-ci-license.sh "new-license-token" +``` + +--- + +**Quick Links:** +- 🎁 [Get FREE License](https://shakacode.com/react-on-rails-pro) +- 📚 [General Setup](./LICENSE_SETUP.md) +- 📧 [Support](mailto:support@shakacode.com) diff --git a/react_on_rails_pro/Gemfile.lock b/react_on_rails_pro/Gemfile.lock index ba52aba824..c2c4975857 100644 --- a/react_on_rails_pro/Gemfile.lock +++ b/react_on_rails_pro/Gemfile.lock @@ -25,6 +25,7 @@ PATH connection_pool execjs (~> 2.9) httpx (~> 1.5) + jwt (~> 2.7) rainbow react_on_rails (>= 16.0.0) @@ -184,6 +185,8 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.7.2) + jwt (2.9.3) + base64 launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) diff --git a/react_on_rails_pro/LICENSE_SETUP.md b/react_on_rails_pro/LICENSE_SETUP.md new file mode 100644 index 0000000000..fd45dadad4 --- /dev/null +++ b/react_on_rails_pro/LICENSE_SETUP.md @@ -0,0 +1,272 @@ +# React on Rails Pro License Setup + +This document explains how to configure your React on Rails Pro license. + +## Getting a FREE License + +**All users need a license** - even for development and evaluation! + +### Get Your FREE Evaluation License (3 Months) + +1. Visit [https://shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) +2. Register with your email +3. Receive your FREE 3-month evaluation license immediately +4. Use it for development, testing, and evaluation + +**No credit card required!** + +**⚠️ Important: The free 3-month evaluation license is intended for personal, educational, and evaluation purposes only. It should NOT be used for production deployments. Production use requires a paid license.** + +## License Types + +### Free License +- **Duration**: 3 months +- **Usage**: Personal, educational, and evaluation purposes only (development, testing, evaluation, CI/CD) - **NOT for production** +- **Cost**: FREE - just register with your email +- **Renewal**: Get a new free license or upgrade to paid + +### Paid License +- **Duration**: 1 year (or longer) +- **Usage**: Production deployment +- **Cost**: Subscription-based +- **Support**: Includes professional support + +## Installation + +### Method 1: Environment Variable (Recommended) + +Set the `REACT_ON_RAILS_PRO_LICENSE` environment variable: + +```bash +export REACT_ON_RAILS_PRO_LICENSE="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**For different environments:** + +```bash +# Development (.env file) +REACT_ON_RAILS_PRO_LICENSE=your_license_token_here + +# Production (Heroku) +heroku config:set REACT_ON_RAILS_PRO_LICENSE="your_token" + +# Production (Docker) +# Add to docker-compose.yml or Dockerfile ENV + +# CI/CD +# Add to your CI environment variables (see CI_SETUP.md) +``` + +### Method 2: Configuration File + +Create `config/react_on_rails_pro_license.key` in your Rails root: + +```bash +echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." > config/react_on_rails_pro_license.key +``` + +**Important**: Add this file to your `.gitignore` to avoid committing your license: + +```bash +# Add to .gitignore +echo "config/react_on_rails_pro_license.key" >> .gitignore +``` + +**Never commit your license to version control.** + +## License Validation + +The license is validated at multiple points: + +1. **Ruby Gem**: When Rails application starts +2. **Node Renderer**: When the Node renderer process starts +3. **Browser Package**: Trusts server-side validation (via `railsContext.rorPro`) + +### All Environments Require Valid License + +React on Rails Pro requires a valid license in **all environments**: + +- ✅ **Development**: Requires license (use FREE license) - **Fails immediately on expiration** +- ✅ **Test**: Requires license (use FREE license) - **Fails immediately on expiration** +- ✅ **CI/CD**: Requires license (use FREE license) - **Fails immediately on expiration** +- ✅ **Production**: Requires license (use paid license) - **1-month grace period after expiration** + +Get your FREE evaluation license in 30 seconds - no credit card required! + +### Production Grace Period + +**Production environments only** receive a **1-month grace period** when a license expires: + +- ⚠️ **During grace period**: Application continues to run but logs ERROR messages on every startup +- ❌ **After grace period**: Application fails to start (same as dev/test) +- 🔔 **Warning messages**: Include days remaining in grace period +- ✅ **Development/Test**: No grace period - fails immediately (helps catch expiration early) + +**Important**: The grace period is designed to give production deployments time to renew, but you should: +1. Monitor your logs for license expiration warnings +2. Renew licenses before they expire +3. Test license renewal in development/staging first + +## Team Setup + +### For Development Teams + +Each developer should: + +1. Get their own FREE license from [shakacode.com](https://shakacode.com/react-on-rails-pro) +2. Store it locally using one of the methods above +3. Ensure `config/react_on_rails_pro_license.key` is in your `.gitignore` + +### For CI/CD + +Set up CI with a license (see [CI_SETUP.md](./CI_SETUP.md) for detailed instructions): + +1. Get a FREE license (can use any team member's or create `ci@yourcompany.com`) +2. Add to CI environment variables as `REACT_ON_RAILS_PRO_LICENSE` +3. Renew every 3 months (or use a paid license) + +**Recommended**: Use GitHub Secrets, GitLab CI Variables, or your CI provider's secrets management. + +## Verification + +### Verify License is Working + +**Ruby Console:** +```ruby +rails console +> ReactOnRails::Utils.react_on_rails_pro? +# Should return: true +``` + +**Note:** With startup validation enabled, your Rails app won't start with an invalid license. If you can run the Rails console, your license is valid. + +**Check License Details:** +```ruby +> ReactOnRailsPro::LicenseValidator.license_data +# Shows: {"sub"=>"your@email.com", "exp"=>1234567890, "plan"=>"free", ...} +``` + +**Browser JavaScript Console:** +```javascript +window.railsContext.rorPro +// Should return: true +``` + +## Troubleshooting + +### Error: "No license found" + +**Solutions:** +1. Verify environment variable: `echo $REACT_ON_RAILS_PRO_LICENSE` +2. Check config file exists: `ls config/react_on_rails_pro_license.key` +3. **Get a FREE license**: [https://shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) + +### Error: "Invalid license signature" + +**Causes:** +- License token was truncated or modified +- Wrong license format (must be complete JWT token) + +**Solutions:** +1. Ensure you copied the complete license (starts with `eyJ`) +2. Check for extra spaces or newlines +3. Get a new FREE license if corrupted + +### Error: "License has expired" + +**What happens:** +- **Development/Test/CI**: Application fails to start immediately +- **Production**: 1-month grace period with ERROR logs, then fails to start + +**Solutions:** +1. **Free License**: Get a new 3-month FREE license +2. **Paid License**: Contact support to renew +3. Visit: [https://shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) + +**If you see grace period warnings in production:** +- You have time to renew, but don't wait! +- The warning shows how many days remain +- Plan your license renewal before the grace period ends + +### Error: "License is missing required expiration field" + +**Cause:** You may have an old license format + +**Solution:** Get a new FREE license from [shakacode.com](https://shakacode.com/react-on-rails-pro) + +### Application Won't Start + +If your application fails to start due to license issues: + +1. **Quick fix**: Set a valid license environment variable +2. **Get FREE license**: Takes 30 seconds at [shakacode.com](https://shakacode.com/react-on-rails-pro) +3. Check logs for specific error message +4. Ensure license is accessible to all processes (Rails + Node renderer) + +## License Technical Details + +### Format + +The license is a JWT (JSON Web Token) signed with RSA-256, containing: + +```json +{ + "sub": "user@example.com", // Your email (REQUIRED) + "iat": 1234567890, // Issued at timestamp (REQUIRED) + "exp": 1234567890, // Expiration timestamp (REQUIRED) + "plan": "free", // License plan: "free" or "paid" (Optional) + "organization": "Your Company", // Organization name (Optional) + "iss": "api" // Issuer identifier (Optional, standard JWT claim) +} +``` + +### Security + +- **Offline validation**: No internet connection required +- **Public key verification**: Uses embedded RSA public key +- **Tamper-proof**: Any modification invalidates the signature +- **No tracking**: License validation happens locally + +### Privacy + +- We only collect email during registration +- No usage tracking or phone-home in the license system +- License is validated offline using cryptographic signatures + +## Support + +Need help? + +1. **Quick Start**: Get a FREE license at [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) +2. **Documentation**: Check [CI_SETUP.md](./CI_SETUP.md) for CI configuration +3. **Email**: support@shakacode.com +4. **License Management**: [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro) + +## Security Best Practices + +1. ✅ **Never commit licenses to Git** - Add `config/react_on_rails_pro_license.key` to `.gitignore` +2. ✅ **Use environment variables in production** +3. ✅ **Use CI secrets for CI/CD environments** +4. ✅ **Don't share licenses publicly** +5. ✅ **Each developer gets their own FREE license** +6. ✅ **Renew before expiration** (we'll send reminders) + +## FAQ + +**Q: Why do I need a license for development?** +A: We provide FREE 3-month licenses so we can track usage and provide better support. Registration takes 30 seconds! + +**Q: Can I use a free license in production?** +A: Free licenses are for evaluation only. Production deployments require a paid license. + +**Q: Can multiple developers share one license?** +A: Each developer should get their own FREE license. For CI, you can share one license via environment variable. + +**Q: What happens when my free license expires?** +A: Get a new 3-month FREE license, or upgrade to a paid license for production use. + +**Q: Do I need internet to validate the license?** +A: No! License validation is completely offline using cryptographic signatures. + +**Q: Is my email shared or sold?** +A: Never. We only use it to send you license renewals and important updates. diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb index 98399d7da5..5dbe2dafae 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -9,6 +9,8 @@ require "react_on_rails_pro/error" require "react_on_rails_pro/utils" require "react_on_rails_pro/configuration" +require "react_on_rails_pro/license_public_key" +require "react_on_rails_pro/license_validator" require "react_on_rails_pro/cache" require "react_on_rails_pro/stream_cache" require "react_on_rails_pro/server_rendering_pool/pro_rendering" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/engine.rb b/react_on_rails_pro/lib/react_on_rails_pro/engine.rb index 8c772aff18..622680c076 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/engine.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/engine.rb @@ -7,5 +7,18 @@ class Engine < Rails::Engine initializer "react_on_rails_pro.routes" do ActionDispatch::Routing::Mapper.include ReactOnRailsPro::Routes end + + # Validate license on Rails startup + # This ensures the application fails fast if the license is invalid or missing + initializer "react_on_rails_pro.validate_license" do + # Use after_initialize to ensure Rails.logger is available + config.after_initialize do + Rails.logger.info "[React on Rails Pro] Validating license..." + + ReactOnRailsPro::LicenseValidator.validated_license_data! + + Rails.logger.info "[React on Rails Pro] License validation successful" + end + end end end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb b/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb new file mode 100644 index 0000000000..2853d001b5 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + module LicensePublicKey + # ShakaCode's public key for React on Rails Pro license verification + # The private key corresponding to this public key is held by ShakaCode + # and is never committed to the repository + # Last updated: 2025-10-09 15:57:09 UTC + # Source: http://shakacode.com/api/public-key + # + # You can update this public key by running the rake task: + # react_on_rails_pro:update_public_key + # This task fetches the latest key from the API endpoint: + # http://shakacode.com/api/public-key + # + # TODO: Add a prepublish check to ensure this key matches the latest public key from the API. + # This should be implemented after publishing the API endpoint on the ShakaCode website. + KEY = OpenSSL::PKey::RSA.new(<<~PEM.strip.strip_heredoc) + -----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlJFK3aWuycVp9X05qhGo +FLztH8yjpuAKUoC4DKHX0fYjNIzwG3xwhLWKKDCmnNfuzW5R09/albl59/ZCHFyS +I7H7Aita1l9rnHCHEyyyJUs/E7zMG27lsECkNoCJr5cD/qtabY45uggFJrl3YRgy +ieonNQvxLtvPuatAPd6jfs/PlHOYA3z+t0C5uDW5YlXJkLKzKKiikvxsyOnk94Uq +J7FWzSdlvY08aLkERZDlGuWcjvQexVz7NCAMR050aEgobwxg2AuaCWDd8cDH6Asq +mhGxQr7ulvrXfDMI6dBqa3ihfjgk+dpA8ilfUsCFc8ovbIA0oE8BTIxogyYr2KaH +vQIDAQAB +-----END PUBLIC KEY----- + PEM + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/license_validator.rb b/react_on_rails_pro/lib/react_on_rails_pro/license_validator.rb new file mode 100644 index 0000000000..80f9dca8d4 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/license_validator.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "jwt" + +module ReactOnRailsPro + class LicenseValidator + # Grace period: 1 month (in seconds) + GRACE_PERIOD_SECONDS = 30 * 24 * 60 * 60 + + class << self + # Validates the license and returns the license data + # Caches the result after first validation + # @return [Hash] The license data + # @raise [ReactOnRailsPro::Error] if license is invalid + def validated_license_data! + return @license_data if defined?(@license_data) + + begin + # Load and decode license (but don't cache yet) + license_data = load_and_decode_license + + # Validate the license (raises if invalid, returns grace_days) + grace_days = validate_license_data(license_data) + + # Validation passed - now cache both data and grace days + @license_data = license_data + @grace_days_remaining = grace_days + + @license_data + rescue JWT::DecodeError => e + error = "Invalid license signature: #{e.message}. " \ + "Your license file may be corrupted. " \ + "Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro" + handle_invalid_license(error) + rescue StandardError => e + error = "License validation error: #{e.message}. " \ + "Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro" + handle_invalid_license(error) + end + end + + def reset! + remove_instance_variable(:@license_data) if defined?(@license_data) + remove_instance_variable(:@grace_days_remaining) if defined?(@grace_days_remaining) + end + + # Checks if the current license is an evaluation/free license + # @return [Boolean] true if plan is not "paid" + def evaluation? + data = validated_license_data! + plan = data["plan"].to_s + plan != "paid" && !plan.start_with?("paid_") + end + + # Returns remaining grace period days if license is expired but in grace period + # @return [Integer, nil] Number of days remaining, or nil if not in grace period + def grace_days_remaining + # Ensure license is validated and cached + validated_license_data! + + # Return cached grace days (nil if not in grace period) + @grace_days_remaining + end + + private + + # Validates the license data and raises if invalid + # Logs info/errors and handles grace period logic + # @param license [Hash] The decoded license data + # @return [Integer, nil] Grace days remaining if in grace period, nil otherwise + # @raise [ReactOnRailsPro::Error] if license is invalid + def validate_license_data(license) + # Check that exp field exists + unless license["exp"] + error = "License is missing required expiration field. " \ + "Your license may be from an older version. " \ + "Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro" + handle_invalid_license(error) + end + + # Check expiry with grace period for production + current_time = Time.now.to_i + exp_time = license["exp"] + grace_days = nil + + if current_time > exp_time + days_expired = ((current_time - exp_time) / (24 * 60 * 60)).to_i + + error = "License has expired #{days_expired} day(s) ago. " \ + "Get a FREE evaluation license (3 months) at https://shakacode.com/react-on-rails-pro " \ + "or upgrade to a paid license for production use." + + # In production, allow a grace period of 1 month with error logging + if production? && within_grace_period?(exp_time) + # Calculate grace days once here + grace_days = calculate_grace_days_remaining(exp_time) + Rails.logger.error( + "[React on Rails Pro] WARNING: #{error} " \ + "Grace period: #{grace_days} day(s) remaining. " \ + "Application will fail to start after grace period expires." + ) + else + handle_invalid_license(error) + end + end + + # Log license type if present (for analytics) + log_license_info(license) + + # Return grace days (nil if not in grace period) + grace_days + end + + def production? + Rails.env.production? + end + + def within_grace_period?(exp_time) + Time.now.to_i <= exp_time + GRACE_PERIOD_SECONDS + end + + # Calculates remaining grace period days + # @param exp_time [Integer] Expiration timestamp + # @return [Integer] Days remaining (0 or more) + def calculate_grace_days_remaining(exp_time) + grace_end = exp_time + GRACE_PERIOD_SECONDS + seconds_remaining = grace_end - Time.now.to_i + return 0 if seconds_remaining <= 0 + + (seconds_remaining / (24 * 60 * 60)).to_i + end + + def load_and_decode_license + license_string = load_license_string + + JWT.decode( + # The JWT token containing the license data + license_string, + # RSA public key used to verify the JWT signature + public_key, + # verify_signature: NEVER set to false! When false, signature verification is skipped, + # allowing anyone to forge licenses. Must always be true for security. + true, + # NOTE: Never remove the 'algorithm' parameter from JWT.decode to prevent algorithm bypassing vulnerabilities. + # Ensure to hardcode the expected algorithm. + # See: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ + algorithm: "RS256", + # Disable automatic expiration verification so we can handle it manually with custom logic + verify_expiration: false + # JWT.decode returns an array [data, header]; we use `.first` to get the data (payload). + ).first + end + + def load_license_string + # First try environment variable + license = ENV.fetch("REACT_ON_RAILS_PRO_LICENSE", nil) + return license if license.present? + + # Then try config file + config_path = Rails.root.join("config", "react_on_rails_pro_license.key") + return File.read(config_path).strip if config_path.exist? + + error_msg = "No license found. Please set REACT_ON_RAILS_PRO_LICENSE environment variable " \ + "or create #{config_path} file. " \ + "Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro" + handle_invalid_license(error_msg) + end + + def public_key + ReactOnRailsPro::LicensePublicKey::KEY + end + + def handle_invalid_license(message) + full_message = "[React on Rails Pro] #{message}" + Rails.logger.error(full_message) + raise ReactOnRailsPro::Error, full_message + end + + def log_license_info(license) + plan = license["plan"] + iss = license["iss"] + + Rails.logger.info("[React on Rails Pro] License plan: #{plan}") if plan + Rails.logger.info("[React on Rails Pro] Issued by: #{iss}") if iss + end + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/utils.rb b/react_on_rails_pro/lib/react_on_rails_pro/utils.rb index 58480472e0..f67f613cc3 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/utils.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/utils.rb @@ -16,6 +16,14 @@ def self.rorp_puts(message) puts "[ReactOnRailsPro] #{message}" end + # Validates the license and raises an exception if invalid. + # + # @return [Boolean] true if license is valid + # @raise [ReactOnRailsPro::Error] if license is invalid + def self.validated_license_data! + LicenseValidator.validated_license_data! + end + def self.copy_assets return if ReactOnRailsPro.configuration.assets_to_copy.blank? @@ -156,5 +164,23 @@ def self.printable_cache_key(cache_key) end end.join("_").underscore end + + # Generates the Pro-specific HTML attribution comment based on license status + # Called by React on Rails helper to generate license-specific attribution + def self.pro_attribution_comment + base = "Powered by React on Rails Pro (c) ShakaCode" + + # Check if in grace period + grace_days = ReactOnRailsPro::LicenseValidator.grace_days_remaining + comment = if grace_days + "#{base} | Licensed (Expired - Grace Period: #{grace_days} day(s) remaining)" + elsif ReactOnRailsPro::LicenseValidator.evaluation? + "#{base} | Evaluation License" + else + "#{base} | Licensed" + end + + "" + end end end diff --git a/react_on_rails_pro/package.json b/react_on_rails_pro/package.json index 476562820b..11b5f93300 100644 --- a/react_on_rails_pro/package.json +++ b/react_on_rails_pro/package.json @@ -44,6 +44,7 @@ "@fastify/multipart": "^8.3.1 || ^9.0.3", "fastify": "^4.29.0 || ^5.2.1", "fs-extra": "^11.2.0", + "jsonwebtoken": "^9.0.2", "lockfile": "^1.0.4" }, "devDependencies": { @@ -59,6 +60,7 @@ "@tsconfig/node14": "^14.1.2", "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.10", "@types/lockfile": "^1.0.4", "@types/touch": "^3.1.5", "babel-jest": "^29.7.0", diff --git a/react_on_rails_pro/packages/node-renderer/src/master.ts b/react_on_rails_pro/packages/node-renderer/src/master.ts index 4780603aca..ef8996c861 100644 --- a/react_on_rails_pro/packages/node-renderer/src/master.ts +++ b/react_on_rails_pro/packages/node-renderer/src/master.ts @@ -7,10 +7,16 @@ import log from './shared/log'; import { buildConfig, Config, logSanitizedConfig } from './shared/configBuilder'; import restartWorkers from './master/restartWorkers'; import * as errorReporter from './shared/errorReporter'; +import { getValidatedLicenseData } from './shared/licenseValidator'; const MILLISECONDS_IN_MINUTE = 60000; export = function masterRun(runningConfig?: Partial) { + // Validate license before starting - required in all environments + log.info('[React on Rails Pro] Validating license...'); + getValidatedLicenseData(); + log.info('[React on Rails Pro] License validation successful'); + // Store config in app state. From now it can be loaded by any module using getConfig(): const config = buildConfig(runningConfig); const { workersCount, allWorkersRestartInterval, delayBetweenIndividualWorkerRestarts } = config; diff --git a/react_on_rails_pro/packages/node-renderer/src/shared/licensePublicKey.ts b/react_on_rails_pro/packages/node-renderer/src/shared/licensePublicKey.ts new file mode 100644 index 0000000000..88c898a601 --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/src/shared/licensePublicKey.ts @@ -0,0 +1,19 @@ +// ShakaCode's public key for React on Rails Pro license verification +// The private key corresponding to this public key is held by ShakaCode +// and is never committed to the repository +// Last updated: 2025-10-09 15:57:09 UTC +// Source: http://shakacode.com/api/public-key +// +// You can update this public key by running the rake task: +// react_on_rails_pro:update_public_key +// This task fetches the latest key from the API endpoint: +// http://shakacode.com/api/public-key +export const PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlJFK3aWuycVp9X05qhGo +FLztH8yjpuAKUoC4DKHX0fYjNIzwG3xwhLWKKDCmnNfuzW5R09/albl59/ZCHFyS +I7H7Aita1l9rnHCHEyyyJUs/E7zMG27lsECkNoCJr5cD/qtabY45uggFJrl3YRgy +ieonNQvxLtvPuatAPd6jfs/PlHOYA3z+t0C5uDW5YlXJkLKzKKiikvxsyOnk94Uq +J7FWzSdlvY08aLkERZDlGuWcjvQexVz7NCAMR050aEgobwxg2AuaCWDd8cDH6Asq +mhGxQr7ulvrXfDMI6dBqa3ihfjgk+dpA8ilfUsCFc8ovbIA0oE8BTIxogyYr2KaH +vQIDAQAB +-----END PUBLIC KEY-----`; diff --git a/react_on_rails_pro/packages/node-renderer/src/shared/licenseValidator.ts b/react_on_rails_pro/packages/node-renderer/src/shared/licenseValidator.ts new file mode 100644 index 0000000000..38c2df15f3 --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/src/shared/licenseValidator.ts @@ -0,0 +1,259 @@ +import * as jwt from 'jsonwebtoken'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PUBLIC_KEY } from './licensePublicKey'; + +interface LicenseData { + // Subject (email for whom the license is issued) + sub?: string; + // Issued at timestamp + iat?: number; + // Required: expiration timestamp + exp: number; + // Optional: license plan (e.g., "free", "paid") + plan?: string; + // Issuer (who issued the license) + iss?: string; + // Allow additional fields + [key: string]: unknown; +} + +// Grace period: 1 month (in seconds) +const GRACE_PERIOD_SECONDS = 30 * 24 * 60 * 60; + +// Module-level state for caching +let cachedLicenseData: LicenseData | undefined; +let cachedGraceDaysRemaining: number | undefined; + +/** + * Handles invalid license by logging error and exiting. + * @private + */ +function handleInvalidLicense(message: string): never { + const fullMessage = `[React on Rails Pro] ${message}`; + console.error(fullMessage); + // Validation errors should prevent the application from starting + process.exit(1); +} + +/** + * Checks if running in production environment. + * @private + */ +function isProduction(): boolean { + return process.env.NODE_ENV === 'production'; +} + +/** + * Checks if current time is within grace period after expiration. + * @private + */ +function isWithinGracePeriod(expTime: number): boolean { + return Math.floor(Date.now() / 1000) <= expTime + GRACE_PERIOD_SECONDS; +} + +/** + * Calculates remaining grace period days. + * @private + */ +function calculateGraceDaysRemaining(expTime: number): number { + const graceEnd = expTime + GRACE_PERIOD_SECONDS; + const secondsRemaining = graceEnd - Math.floor(Date.now() / 1000); + return secondsRemaining <= 0 ? 0 : Math.floor(secondsRemaining / (24 * 60 * 60)); +} + +/** + * Logs license information for analytics. + * @private + */ +function logLicenseInfo(license: LicenseData): void { + const { plan, iss } = license; + + if (plan) { + console.log(`[React on Rails Pro] License plan: ${plan}`); + } + if (iss) { + console.log(`[React on Rails Pro] Issued by: ${iss}`); + } +} + +/** + * Loads the license string from environment variable or config file. + * @private + */ +// eslint-disable-next-line consistent-return +function loadLicenseString(): string { + // First try environment variable + const envLicense = process.env.REACT_ON_RAILS_PRO_LICENSE; + if (envLicense) { + return envLicense; + } + + // Then try config file (relative to project root) + try { + const configPath = path.join(process.cwd(), 'config', 'react_on_rails_pro_license.key'); + if (fs.existsSync(configPath)) { + return fs.readFileSync(configPath, 'utf8').trim(); + } + } catch (error) { + console.error(`[React on Rails Pro] Error reading license file: ${(error as Error).message}`); + } + + const errorMsg = + 'No license found. Please set REACT_ON_RAILS_PRO_LICENSE environment variable ' + + 'or create config/react_on_rails_pro_license.key file. ' + + 'Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro'; + + handleInvalidLicense(errorMsg); +} + +/** + * Loads and decodes the license from environment or file. + * @private + */ +function loadAndDecodeLicense(): LicenseData { + const licenseString = loadLicenseString(); + + const decoded = jwt.verify(licenseString, PUBLIC_KEY, { + // Enforce RS256 algorithm only to prevent "alg=none" and downgrade attacks. + // Adding other algorithms to the whitelist (e.g., ['RS256', 'HS256']) can introduce vulnerabilities: + // If the public key is mistakenly used as a secret for HMAC algorithms (like HS256), attackers could forge tokens. + // Always carefully review algorithm changes to avoid signature bypass risks. + algorithms: ['RS256'], + // Disable automatic expiration verification so we can handle it manually with custom logic + ignoreExpiration: true, + }) as LicenseData; + + return decoded; +} + +/** + * Validates the license data and throws if invalid. + * Logs info/errors and handles grace period logic. + * + * @param license - The decoded license data + * @returns Grace days remaining if in grace period, undefined otherwise + * @throws Never returns - exits process if license is invalid + * @private + */ +function validateLicenseData(license: LicenseData): number | undefined { + // Check that exp field exists + if (!license.exp) { + const error = + 'License is missing required expiration field. ' + + 'Your license may be from an older version. ' + + 'Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro'; + handleInvalidLicense(error); + } + + // Check expiry with grace period for production + const currentTime = Math.floor(Date.now() / 1000); + const expTime = license.exp; + let graceDays: number | undefined; + + if (currentTime > expTime) { + const daysExpired = Math.floor((currentTime - expTime) / (24 * 60 * 60)); + + const error = + `License has expired ${daysExpired} day(s) ago. ` + + 'Get a FREE evaluation license (3 months) at https://shakacode.com/react-on-rails-pro ' + + 'or upgrade to a paid license for production use.'; + + // In production, allow a grace period of 1 month with error logging + if (isProduction() && isWithinGracePeriod(expTime)) { + // Calculate grace days once here + graceDays = calculateGraceDaysRemaining(expTime); + console.error( + `[React on Rails Pro] WARNING: ${error} ` + + `Grace period: ${graceDays} day(s) remaining. ` + + 'Application will fail to start after grace period expires.', + ); + } else { + handleInvalidLicense(error); + } + } + + // Log license type if present (for analytics) + logLicenseInfo(license); + + // Return grace days (undefined if not in grace period) + return graceDays; +} + +/** + * Validates the license and returns the license data. + * Caches the result after first validation. + * + * @returns The validated license data + * @throws Exits process if license is invalid + */ +// eslint-disable-next-line consistent-return +export function getValidatedLicenseData(): LicenseData { + if (cachedLicenseData !== undefined) { + return cachedLicenseData; + } + + try { + // Load and decode license (but don't cache yet) + const licenseData = loadAndDecodeLicense(); + + // Validate the license (raises if invalid, returns grace_days) + const graceDays = validateLicenseData(licenseData); + + // Validation passed - now cache both data and grace days + cachedLicenseData = licenseData; + cachedGraceDaysRemaining = graceDays; + + return cachedLicenseData; + } catch (error: unknown) { + if (error instanceof Error && error.name === 'JsonWebTokenError') { + const errorMsg = + `Invalid license signature: ${error.message}. ` + + 'Your license file may be corrupted. ' + + 'Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro'; + handleInvalidLicense(errorMsg); + } else if (error instanceof Error) { + const errorMsg = + `License validation error: ${error.message}. ` + + 'Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro'; + handleInvalidLicense(errorMsg); + } else { + const errorMsg = + 'License validation error: Unknown error. ' + + 'Get a FREE evaluation license at https://shakacode.com/react-on-rails-pro'; + handleInvalidLicense(errorMsg); + } + } +} + +/** + * Checks if the current license is an evaluation/free license. + * + * @returns true if plan is not "paid" + */ +export function isEvaluation(): boolean { + const data = getValidatedLicenseData(); + const plan = String(data.plan || ''); + return plan !== 'paid' && !plan.startsWith('paid_'); +} + +/** + * Returns remaining grace period days if license is expired but in grace period. + * + * @returns Number of days remaining, or undefined if not in grace period + */ +export function getGraceDaysRemaining(): number | undefined { + // Ensure license is validated and cached + getValidatedLicenseData(); + + // Return cached grace days (undefined if not in grace period) + return cachedGraceDaysRemaining; +} + +/** + * Resets all cached validation state (primarily for testing). + */ +export function reset(): void { + cachedLicenseData = undefined; + cachedGraceDaysRemaining = undefined; +} diff --git a/react_on_rails_pro/packages/node-renderer/tests/licenseValidator.test.ts b/react_on_rails_pro/packages/node-renderer/tests/licenseValidator.test.ts new file mode 100644 index 0000000000..06149fd0e0 --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/tests/licenseValidator.test.ts @@ -0,0 +1,274 @@ +import * as jwt from 'jsonwebtoken'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; + +// Mock modules +jest.mock('fs'); +jest.mock('../src/shared/licensePublicKey', () => ({ + PUBLIC_KEY: '', +})); + +interface LicenseData { + sub?: string; + exp: number; + plan?: string; + iss?: string; + [key: string]: unknown; +} + +interface LicenseValidatorModule { + getValidatedLicenseData: () => LicenseData; + isEvaluation: () => boolean; + getGraceDaysRemaining: () => number | undefined; + reset: () => void; +} + +describe('LicenseValidator', () => { + let testPrivateKey: string; + let testPublicKey: string; + let mockProcessExit: jest.SpyInstance; + let mockConsoleError: jest.SpyInstance; + + beforeEach(() => { + // Clear the module cache to get a fresh instance + jest.resetModules(); + + // Mock process.exit globally to prevent tests from actually exiting + mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => { + // Do nothing - let tests continue + return undefined as never; + }); + + // Mock console methods to suppress logs during tests + mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + + // Reset fs mocks to default (no file exists) + jest.mocked(fs.existsSync).mockReturnValue(false); + jest.mocked(fs.readFileSync).mockReturnValue(''); + + // Generate test RSA key pair + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + testPrivateKey = privateKey; + testPublicKey = publicKey; + + // Mock the public key module + jest.doMock('../src/shared/licensePublicKey', () => ({ + PUBLIC_KEY: testPublicKey, + })); + + // Clear environment variable + delete process.env.REACT_ON_RAILS_PRO_LICENSE; + + // Import after mocking and reset the validator state + const module = jest.requireActual('../src/shared/licenseValidator'); + module.reset(); + }); + + afterEach(() => { + delete process.env.REACT_ON_RAILS_PRO_LICENSE; + jest.restoreAllMocks(); + }); + + describe('getValidatedLicenseData', () => { + it('returns valid license data for valid license in ENV', () => { + const validPayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, // Valid for 1 hour + }; + + const validToken = jwt.sign(validPayload, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = validToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + const data = module.getValidatedLicenseData(); + expect(data).toBeDefined(); + expect(data.sub).toBe('test@example.com'); + expect(data.exp).toBe(validPayload.exp); + }); + + it('calls process.exit for expired license in non-production', () => { + const expiredPayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000) - 7200, + exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + }; + + const expiredToken = jwt.sign(expiredPayload, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = expiredToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + + // Call getValidatedLicenseData which should trigger process.exit + module.getValidatedLicenseData(); + + // Verify process.exit was called with code 1 + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('License has expired')); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('FREE evaluation license')); + }); + + it('calls process.exit for license missing exp field', () => { + const payloadWithoutExp = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + // exp field is missing + }; + + const tokenWithoutExp = jwt.sign(payloadWithoutExp, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = tokenWithoutExp; + + const module = jest.requireActual('../src/shared/licenseValidator'); + + module.getValidatedLicenseData(); + + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('License is missing required expiration field'), + ); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('FREE evaluation license')); + }); + + it('calls process.exit for invalid signature', () => { + // Generate a different key pair for invalid signature + const wrongKeyPair = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + const wrongKey = wrongKeyPair.privateKey; + + const validPayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const invalidToken = jwt.sign(validPayload, wrongKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = invalidToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + + module.getValidatedLicenseData(); + + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid license signature')); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('FREE evaluation license')); + }); + + it('calls process.exit for missing license', () => { + delete process.env.REACT_ON_RAILS_PRO_LICENSE; + + // Mock fs.existsSync to return false (no config file) + jest.mocked(fs.existsSync).mockReturnValue(false); + + const module = jest.requireActual('../src/shared/licenseValidator'); + + module.getValidatedLicenseData(); + + expect(mockProcessExit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('No license found')); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('FREE evaluation license')); + }); + + it('caches validation result', () => { + const validPayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const validToken = jwt.sign(validPayload, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = validToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + + // First call + const data1 = module.getValidatedLicenseData(); + expect(data1.sub).toBe('test@example.com'); + + // Change ENV (shouldn't affect cached result) + delete process.env.REACT_ON_RAILS_PRO_LICENSE; + + // Second call should use cache + const data2 = module.getValidatedLicenseData(); + expect(data2.sub).toBe('test@example.com'); + }); + }); + + describe('isEvaluation', () => { + it('returns true for free license', () => { + const freePayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + plan: 'free', + }; + + const validToken = jwt.sign(freePayload, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = validToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + expect(module.isEvaluation()).toBe(true); + }); + + it('returns false for paid license', () => { + const paidPayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + plan: 'paid', + }; + + const validToken = jwt.sign(paidPayload, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = validToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + expect(module.isEvaluation()).toBe(false); + }); + }); + + describe('reset', () => { + it('clears cached validation data', () => { + const validPayload = { + sub: 'test@example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const validToken = jwt.sign(validPayload, testPrivateKey, { algorithm: 'RS256' }); + process.env.REACT_ON_RAILS_PRO_LICENSE = validToken; + + const module = jest.requireActual('../src/shared/licenseValidator'); + + // Validate once to cache + module.getValidatedLicenseData(); + + // Reset and change license + module.reset(); + delete process.env.REACT_ON_RAILS_PRO_LICENSE; + + // Should fail now since license is missing and cache was cleared + module.getValidatedLicenseData(); + expect(mockProcessExit).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/react_on_rails_pro/rakelib/public_key_management.rake b/react_on_rails_pro/rakelib/public_key_management.rake new file mode 100644 index 0000000000..dfd3367213 --- /dev/null +++ b/react_on_rails_pro/rakelib/public_key_management.rake @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" + +# React on Rails Pro License Public Key Management Tasks +# +# Usage: +# rake react_on_rails_pro:update_public_key # From production (shakacode.com) +# rake react_on_rails_pro:update_public_key[local] # From localhost:8788 +# rake react_on_rails_pro:update_public_key[custom.com] # From custom hostname +# rake react_on_rails_pro:verify_public_key # Verify current configuration +# rake react_on_rails_pro:public_key_help # Show help + +namespace :react_on_rails_pro do + desc "Update the public key for React on Rails Pro license validation" + task :update_public_key, [:source] do |_task, args| + source = args[:source] || "production" + + # Determine the API URL based on the source + api_url = case source + when "local", "localhost" + # Use the default local URL created by the Cloudflare Wrangler tool when the worker is run locally + "http://localhost:8788/api/public-key" + when "production", "prod" + "https://www.shakacode.com/api/public-key" + else + # Check if it's a custom URL or hostname + if source.start_with?("http://", "https://") + # Full URL provided + source.end_with?("/api/public-key") ? source : "#{source}/api/public-key" + else + # Just a hostname provided + "https://#{source}/api/public-key" + end + end + + puts "Fetching public key from: #{api_url}" + + begin + uri = URI(api_url) + response = Net::HTTP.get_response(uri) + + if response.code != "200" + puts "❌ Failed to fetch public key. HTTP Status: #{response.code}" + puts "Response: #{response.body}" + exit 1 + end + + data = JSON.parse(response.body) + public_key = data["publicKey"] + + if public_key.nil? || public_key.empty? + puts "❌ No public key found in response" + exit 1 + end + + # TODO: Add a prepublish check to ensure this key matches the latest public key from the API. + # This should be implemented after publishing the API endpoint on the ShakaCode website. + # Update Ruby public key file + ruby_file_path = File.join(File.dirname(__FILE__), "..", "lib", "react_on_rails_pro", "license_public_key.rb") + ruby_content = <<~RUBY.strip_heredoc + # frozen_string_literal: true + + module ReactOnRailsPro + module LicensePublicKey + # ShakaCode's public key for React on Rails Pro license verification + # The private key corresponding to this public key is held by ShakaCode + # and is never committed to the repository + # Last updated: #{Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")} + # Source: #{api_url} + # + # You can update this public key by running the rake task: + # react_on_rails_pro:update_public_key + # This task fetches the latest key from the API endpoint: + # http://shakacode.com/api/public-key + KEY = OpenSSL::PKey::RSA.new(<<~PEM.strip.strip_heredoc) + #{public_key.strip} + PEM + end + end + RUBY + + File.write(ruby_file_path, ruby_content) + puts "✅ Updated Ruby public key: #{ruby_file_path}" + + # Update Node/TypeScript public key file + node_file_path = File.join(File.dirname(__FILE__), "..", "packages", "node-renderer", "src", "shared", "licensePublicKey.ts") + node_content = <<~TYPESCRIPT + // ShakaCode's public key for React on Rails Pro license verification + // The private key corresponding to this public key is held by ShakaCode + // and is never committed to the repository + // Last updated: #{Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")} + // Source: #{api_url} + // + // You can update this public key by running the rake task: + // react_on_rails_pro:update_public_key + // This task fetches the latest key from the API endpoint: + // http://shakacode.com/api/public-key + export const PUBLIC_KEY = `#{public_key.strip}`; + TYPESCRIPT + + File.write(node_file_path, node_content) + puts "✅ Updated Node public key: #{node_file_path}" + + puts "\n✅ Successfully updated public keys from #{api_url}" + puts "\nPublic key info:" + puts " Algorithm: #{data['algorithm'] || 'RSA-2048'}" + puts " Format: #{data['format'] || 'PEM'}" + puts " Usage: #{data['usage'] || 'React on Rails Pro license verification'}" + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + puts "❌ Network error: #{e.message}" + puts "Please check your internet connection and the API URL." + exit 1 + rescue JSON::ParserError => e + puts "❌ Failed to parse JSON response: #{e.message}" + exit 1 + rescue StandardError => e + puts "❌ Error: #{e.message}" + puts e.backtrace.first(5) + exit 1 + end + end + + desc "Show usage examples for updating the public key" + task :public_key_help do + puts <<~HELP + React on Rails Pro - Public Key Management + ========================================== + + Update public key from different sources: + + 1. From production (ShakaCode's official server): + rake react_on_rails_pro:update_public_key + rake react_on_rails_pro:update_public_key[production] + + 2. From local development server: + rake react_on_rails_pro:update_public_key[local] + + 3. From a custom hostname: + rake react_on_rails_pro:update_public_key[staging.example.com] + + 4. From a custom full URL: + rake react_on_rails_pro:update_public_key[https://api.example.com/api/public-key] + + Verify current public key: + rake react_on_rails_pro:verify_public_key + + Note: The public key is used to verify JWT licenses for React on Rails Pro. + The corresponding private key is held securely by ShakaCode. + HELP + end +end diff --git a/react_on_rails_pro/react_on_rails_pro.gemspec b/react_on_rails_pro/react_on_rails_pro.gemspec index 03faec2430..0b95a6e910 100644 --- a/react_on_rails_pro/react_on_rails_pro.gemspec +++ b/react_on_rails_pro/react_on_rails_pro.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |s| s.files = `git ls-files -z`.split("\x0") .reject { |f| f.match( - %r{^(test|spec|features|tmp|node_modules|packages|coverage|Gemfile.lock)/} + %r{^(test|spec|features|tmp|node_modules|packages|coverage|Gemfile.lock|lib/tasks)/} ) } s.bindir = "exe" @@ -32,6 +32,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency "connection_pool" s.add_runtime_dependency "execjs", "~> 2.9" s.add_runtime_dependency "httpx", "~> 1.5" + s.add_runtime_dependency "jwt", "~> 2.7" s.add_runtime_dependency "rainbow" s.add_runtime_dependency "react_on_rails", ">= 16.0.0" s.add_development_dependency "bundler" diff --git a/react_on_rails_pro/spec/dummy/.gitignore b/react_on_rails_pro/spec/dummy/.gitignore new file mode 100644 index 0000000000..6fc8396546 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/.gitignore @@ -0,0 +1,2 @@ +# React on Rails Pro license file +config/react_on_rails_pro_license.key diff --git a/react_on_rails_pro/spec/dummy/Gemfile.lock b/react_on_rails_pro/spec/dummy/Gemfile.lock index 3655722068..4a000e6931 100644 --- a/react_on_rails_pro/spec/dummy/Gemfile.lock +++ b/react_on_rails_pro/spec/dummy/Gemfile.lock @@ -25,6 +25,7 @@ PATH connection_pool execjs (~> 2.9) httpx (~> 1.5) + jwt (~> 2.7) rainbow react_on_rails (>= 16.0.0) @@ -195,6 +196,8 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.7.2) + jwt (2.9.3) + base64 launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) diff --git a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb index 1caad9f14d..9cae3fb11b 100644 --- a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb @@ -13,7 +13,7 @@ def render_to_string(*args); end def response; end end -describe ReactOnRailsProHelper, type: :helper do +describe ReactOnRailsProHelper do # In order to test the pro helper, we need to load the methods from the regular helper. # I couldn't see any easier way to do this. include ReactOnRails::Helper @@ -695,4 +695,45 @@ def render_cached_random_value(cache_key) end end end + + describe "attribution comment in stream_react_component" do + include StreamingTestHelpers + + let(:component_name) { "TestComponent" } + let(:props) { { test: "data" } } + let(:component_options) { { prerender: true, id: "#{component_name}-react-component-0" } } + let(:chunks) do + [ + { html: "
Test Content
", consoleReplayScript: "" } + ] + end + + before do + @rorp_rendering_fibers = [] + ReactOnRailsPro::Request.instance_variable_set(:@connection, nil) + original_httpx_plugin = HTTPX.method(:plugin) + allow(HTTPX).to receive(:plugin) do |*args| + original_httpx_plugin.call(:mock_stream).plugin(*args) + end + clear_stream_mocks + + mock_streaming_response(%r{http://localhost:3800/bundles/[a-f0-9]{32}-test/render/[a-f0-9]{32}}, 200, + count: 1) do |yielder| + chunks.each do |chunk| + yielder.call("#{chunk.to_json}\n") + end + end + end + + it "includes the Pro attribution comment in the rendered output" do + result = stream_react_component(component_name, props: props, **component_options) + expect(result).to include("") + end + end + + context "when license is in grace period" do + before do + allow(ReactOnRailsPro::LicenseValidator).to receive(:grace_days_remaining).and_return(15) + end + + it "returns attribution comment with grace period information" do + result = described_class.pro_attribution_comment + expected = "" + expect(result).to eq(expected) + end + end + + context "when license is in grace period with 1 day remaining" do + before do + allow(ReactOnRailsPro::LicenseValidator).to receive(:grace_days_remaining).and_return(1) + end + + it "returns attribution comment with singular day" do + result = described_class.pro_attribution_comment + expected = "" + expect(result).to eq(expected) + end + end + + context "when using evaluation license" do + before do + allow(ReactOnRailsPro::LicenseValidator).to receive_messages(grace_days_remaining: nil, evaluation?: true) + end + + it "returns evaluation license attribution comment" do + result = described_class.pro_attribution_comment + expect(result).to eq("") + end + end + + context "when grace_days_remaining returns 0" do + before do + allow(ReactOnRailsPro::LicenseValidator).to receive(:grace_days_remaining).and_return(0) + end + + it "returns attribution comment with grace period information" do + result = described_class.pro_attribution_comment + expected = "" + expect(result).to eq(expected) + end + end + end end end +# rubocop:enable Metrics/ModuleLength diff --git a/react_on_rails_pro/yarn.lock b/react_on_rails_pro/yarn.lock index 34296d39ae..73fd9af02c 100644 --- a/react_on_rails_pro/yarn.lock +++ b/react_on_rails_pro/yarn.lock @@ -1867,6 +1867,14 @@ dependencies: "@types/node" "*" +"@types/jsonwebtoken@^9.0.10": + version "9.0.10" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz#a7932a47177dcd4283b6146f3bd5c26d82647f09" + integrity sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA== + dependencies: + "@types/ms" "*" + "@types/node" "*" + "@types/lockfile@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@types/lockfile/-/lockfile-1.0.4.tgz#9d6a6d1b6dbd4853cecc7f334bc53ea0ff363b8e" @@ -1877,6 +1885,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node@*": version "20.14.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.9.tgz#12e8e765ab27f8c421a1820c99f5f313a933b420" @@ -2652,6 +2665,11 @@ buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" @@ -3345,6 +3363,13 @@ duplexer@~0.1.1: resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + electron-to-chromium@^1.5.73: version "1.5.134" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.134.tgz#d90008c4f8a506c1a6d1b329f922d83e18904101" @@ -5662,6 +5687,22 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@^9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + 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" + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5: version "3.3.5" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" @@ -5672,6 +5713,23 @@ jsonfile@^6.0.1: object.assign "^4.1.4" object.values "^1.1.6" +jwa@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" + integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -5808,6 +5866,26 @@ lodash.escaperegexp@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" @@ -5823,6 +5901,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.uniqby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" diff --git a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb index 3b6608a21b..1b68f3fcdd 100644 --- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb @@ -22,9 +22,22 @@ class PlainReactOnRailsHelper } allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true + react_on_rails_pro?: true, + react_on_rails_pro_version: "", + rsc_support_enabled?: false ) + # Stub ReactOnRailsPro::Utils.pro_attribution_comment for all tests + # since react_on_rails_pro? is set to true by default + pro_module = Module.new + utils_module = Module.new do + def self.pro_attribution_comment + "" + end + end + stub_const("ReactOnRailsPro", pro_module) + stub_const("ReactOnRailsPro::Utils", utils_module) + # Configure immediate_hydration to true for tests since they expect that behavior ReactOnRails.configure do |config| config.immediate_hydration = true @@ -245,7 +258,7 @@ def helper.append_javascript_pack_tag(name, **options) it { expect(self).to respond_to :react_component } it { is_expected.to be_an_instance_of ActiveSupport::SafeBuffer } - it { is_expected.to start_with "\s*$} } it { is_expected.to include react_component_div } @@ -389,7 +402,7 @@ def helper.append_javascript_pack_tag(name, **options) subject(:react_app) { react_component("App", props: props, immediate_hydration: true) } before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end it { is_expected.to include(badge_html_string) } @@ -405,7 +418,7 @@ def helper.append_javascript_pack_tag(name, **options) subject(:react_app) { react_component("App", props: props) } before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end around do |example| @@ -421,7 +434,7 @@ def helper.append_javascript_pack_tag(name, **options) subject(:react_app) { react_component("App", props: props, immediate_hydration: false) } before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end it { is_expected.not_to include(badge_html_string) } @@ -437,7 +450,7 @@ def helper.append_javascript_pack_tag(name, **options) before do allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true + react_on_rails_pro?: true ) end @@ -483,7 +496,7 @@ def helper.append_javascript_pack_tag(name, **options) subject(:react_app) { react_component_hash("App", props: props, immediate_hydration: true) } before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end it "adds badge to componentHtml" do @@ -496,7 +509,7 @@ def helper.append_javascript_pack_tag(name, **options) before do allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true + react_on_rails_pro?: true ) end @@ -523,7 +536,7 @@ def helper.append_javascript_pack_tag(name, **options) it { expect(self).to respond_to :redux_store } it { is_expected.to be_an_instance_of ActiveSupport::SafeBuffer } - it { is_expected.to start_with "" } it { @@ -541,7 +554,7 @@ def helper.append_javascript_pack_tag(name, **options) subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: true) } before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end it { is_expected.to include(badge_html_string) } @@ -557,7 +570,7 @@ def helper.append_javascript_pack_tag(name, **options) subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: false) } before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end it { is_expected.not_to include(badge_html_string) } @@ -568,7 +581,7 @@ def helper.append_javascript_pack_tag(name, **options) before do allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true + react_on_rails_pro?: true ) end @@ -631,5 +644,232 @@ def helper.append_javascript_pack_tag(name, **options) expect(helper).to have_received(:rails_context).with(server_side: false) end end + + describe "#react_on_rails_attribution_comment" do + let(:helper) { PlainReactOnRailsHelper.new } + + context "when React on Rails Pro is installed" do + let(:pro_comment) { "" } + + before do + # ReactOnRailsPro::Utils is already stubbed in global before block + # Just override the return value for this context + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + allow(ReactOnRailsPro::Utils).to receive(:pro_attribution_comment).and_return(pro_comment) + end + + it "returns the Pro attribution comment" do + result = helper.send(:react_on_rails_attribution_comment) + expect(result).to eq(pro_comment) + end + + it "calls ReactOnRailsPro::Utils.pro_attribution_comment" do + helper.send(:react_on_rails_attribution_comment) + expect(ReactOnRailsPro::Utils).to have_received(:pro_attribution_comment) + end + end + + context "when React on Rails Pro is NOT installed" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) + end + + it "returns the open source attribution comment" do + result = helper.send(:react_on_rails_attribution_comment) + expect(result).to eq("") + end + end + end + + describe "attribution comment inclusion in rendered output" do + let(:props) { { name: "Test" } } + + before do + allow(SecureRandom).to receive(:uuid).and_return(0) + end + + describe "#react_component" do + context "when React on Rails Pro is installed" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + allow(ReactOnRailsPro::Utils).to receive(:pro_attribution_comment) + .and_return("") + end + + it "includes the Pro attribution comment in the rendered output" do + result = react_component("App", props: props) + expect(result).to include("") + end + + it "includes the attribution comment only once" do + result = react_component("App", props: props) + comment_count = result.scan("") + end + + it "includes the attribution comment only once" do + result = react_component("App", props: props) + comment_count = result.scan("") + end + + it "includes the Pro attribution comment in the rendered output" do + result = redux_store("TestStore", props: props) + expect(result).to include("") + end + + it "includes the attribution comment only once" do + result = redux_store("TestStore", props: props) + comment_count = result.scan("") + end + + it "includes the attribution comment only once" do + result = redux_store("TestStore", props: props) + comment_count = result.scan("") + end + + it "includes the Pro attribution comment in the componentHtml" do + result = react_component_hash("App", props: props, prerender: true) + expect(result["componentHtml"]).to include("") + end + + it "includes the attribution comment only once" do + result = react_component_hash("App", props: props, prerender: true) + comment_count = result["componentHtml"].scan("") + end + + it "includes the attribution comment only once" do + result = react_component_hash("App", props: props, prerender: true) + comment_count = result["componentHtml"].scan("") + end + + it "includes the attribution comment only once when calling multiple react_component helpers" do + result1 = react_component("App1", props: props) + result2 = react_component("App2", props: props) + combined_result = result1 + result2 + + comment_count = combined_result.scan("" + end + end + stub_const("ReactOnRailsPro", pro_module) + stub_const("ReactOnRailsPro::Utils", utils_module) end around do |example| diff --git a/spec/react_on_rails/react_component/render_options_spec.rb b/spec/react_on_rails/react_component/render_options_spec.rb index 9c1dcc7b21..8ef62cba5c 100644 --- a/spec/react_on_rails/react_component/render_options_spec.rb +++ b/spec/react_on_rails/react_component/render_options_spec.rb @@ -21,7 +21,7 @@ def the_attrs(react_component_name: "App", options: {}) # TODO: test pro features without license before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(true) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) end it "works without raising error" do