Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Changes since the last non-beta release.

#### Pro

##### Changed

- **Breaking: removed legacy key-file license fallback**: `config/react_on_rails_pro_license.key` is no longer read. Move your token to the `REACT_ON_RAILS_PRO_LICENSE` environment variable. A migration warning is logged at startup when the legacy file is detected and the environment variable is missing. [PR 2454](https://github.com/shakacode/react_on_rails/pull/2454) by [ihabadham](https://github.com/ihabadham).

##### Fixed

- **Fixed node renderer upload race condition causing ENOENT errors and asset corruption during concurrent requests**. Concurrent multipart uploads (e.g., during pod rollovers) all wrote to a single shared path (`uploads/<filename>`), causing file overwrites, `ENOENT` errors, and cross-contamination between requests. Each request now gets its own isolated upload directory (`uploads/<uuid>/`), eliminating all shared-path collisions. [PR 2456](https://github.com/shakacode/react_on_rails/pull/2456) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import * as jwt from 'jsonwebtoken';
import * as fs from 'fs';
import * as path from 'path';
import { PUBLIC_KEY } from './licensePublicKey.js';

/**
Expand Down Expand Up @@ -54,39 +52,24 @@ export type LicenseStatus = 'valid' | 'expired' | 'invalid' | 'missing';
// intentional - license validation happens once per worker on first access, and
// the result is cached for the lifetime of that worker process.
//
// The caching here is deterministic - given the same environment/config file, every
// The caching here is deterministic - given the same environment variable value, every
// worker will compute the same cached values. Redundant computation across workers
// is acceptable since license validation is infrequent (once per worker startup).
let cachedLicenseStatus: LicenseStatus | undefined;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cachedLicenseStatus variable uses undefined as its "not yet initialized" sentinel, while cachedLicenseOrganization and cachedLicensePlan use the UNINITIALIZED symbol specifically to distinguish between "not yet computed" and "computed as undefined".

This inconsistency works today only because LicenseStatus never includes undefined in its union. But if that assumption ever changes, this check silently breaks. For consistency and future safety, consider:

Suggested change
let cachedLicenseStatus: LicenseStatus | undefined;
const UNINITIALIZED = Symbol('uninitialized');
let cachedLicenseStatus: LicenseStatus | typeof UNINITIALIZED = UNINITIALIZED;
let cachedLicenseOrganization: string | undefined | typeof UNINITIALIZED = UNINITIALIZED;
let cachedLicensePlan: ValidPlan | undefined | typeof UNINITIALIZED = UNINITIALIZED;

And update getLicenseStatus() to check cachedLicenseStatus !== UNINITIALIZED, and reset() to set it back to UNINITIALIZED.

let cachedLicenseOrganization: string | undefined;
let cachedLicensePlan: ValidPlan | undefined;
const UNINITIALIZED = Symbol('uninitialized');
let cachedLicenseOrganization: string | undefined | typeof UNINITIALIZED = UNINITIALIZED;
let cachedLicensePlan: ValidPlan | undefined | typeof UNINITIALIZED = UNINITIALIZED;

/**
* Loads the license string from environment variable or config file.
* Loads the license string from environment variable.
* @returns License string or undefined if not found
* @private
*/
function loadLicenseString(): string | undefined {
// First try environment variable
const envLicense = process.env.REACT_ON_RAILS_PRO_LICENSE?.trim();
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)) {
const content = fs.readFileSync(configPath, 'utf8').trim();
if (content) {
return content;
}
}
} catch {
// File read error - return undefined to indicate missing license
}

return undefined;
// `|| undefined` converts an empty/whitespace-only env var to undefined,
// so it is reported as 'missing' rather than 'invalid'.
return envLicense || undefined;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is slightly inaccurate: .trim() already handles the whitespace removal. By the time || undefined executes, envLicense is either undefined (env var not set) or a trimmed string. The || undefined only converts the empty-string case. Consider:

Suggested change
return envLicense || undefined;
// `.trim()` strips surrounding whitespace/newlines; `|| undefined` converts the resulting
// empty string to `undefined` so it is reported as 'missing' rather than 'invalid'.
return envLicense || undefined;

}

/**
Expand Down Expand Up @@ -251,7 +234,7 @@ function determineLicenseOrganization(): string | undefined {
* @returns The organization name or undefined if not available
*/
export function getLicenseOrganization(): string | undefined {
if (cachedLicenseOrganization !== undefined) {
if (cachedLicenseOrganization !== UNINITIALIZED) {
return cachedLicenseOrganization;
}

Expand Down Expand Up @@ -289,7 +272,7 @@ function determineLicensePlan(): ValidPlan | undefined {
* @returns The plan type (e.g., "paid", "startup") or undefined if not available
*/
export function getLicensePlan(): ValidPlan | undefined {
if (cachedLicensePlan !== undefined) {
if (cachedLicensePlan !== UNINITIALIZED) {
return cachedLicensePlan;
}

Expand All @@ -302,6 +285,6 @@ export function getLicensePlan(): ValidPlan | undefined {
*/
export function reset(): void {
cachedLicenseStatus = undefined;
cachedLicenseOrganization = undefined;
cachedLicensePlan = undefined;
cachedLicenseOrganization = UNINITIALIZED;
cachedLicensePlan = UNINITIALIZED;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
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: '',
}));
Expand All @@ -25,10 +23,6 @@ describe('LicenseValidator', () => {
// Clear the module cache to get a fresh instance
jest.resetModules();

// 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,
Expand Down Expand Up @@ -138,7 +132,43 @@ describe('LicenseValidator', () => {

it('returns missing when no license is found', () => {
delete process.env.REACT_ON_RAILS_PRO_LICENSE;
jest.mocked(fs.existsSync).mockReturnValue(false);

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicenseStatus()).toBe('missing');
});

it('returns valid when env var has surrounding whitespace', () => {
const validPayload = {
sub: 'test@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
org: 'Acme Corp',
};

const validToken = jwt.sign(validPayload, testPrivateKey, { algorithm: 'RS256' });
process.env.REACT_ON_RAILS_PRO_LICENSE = ` ${validToken} `;

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicenseStatus()).toBe('valid');
});

it('returns valid when env var has trailing newline', () => {
const validPayload = {
sub: 'test@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
org: 'Acme Corp',
};

const validToken = jwt.sign(validPayload, testPrivateKey, { algorithm: 'RS256' });
process.env.REACT_ON_RAILS_PRO_LICENSE = `${validToken}\n`;

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicenseStatus()).toBe('valid');
});

it('returns missing when env var is whitespace only', () => {
process.env.REACT_ON_RAILS_PRO_LICENSE = ' ';

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicenseStatus()).toBe('missing');
Expand Down Expand Up @@ -381,7 +411,6 @@ describe('LicenseValidator', () => {

it('returns undefined when license is missing', () => {
delete process.env.REACT_ON_RAILS_PRO_LICENSE;
jest.mocked(fs.existsSync).mockReturnValue(false);

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicenseOrganization()).toBeUndefined();
Expand All @@ -408,6 +437,35 @@ describe('LicenseValidator', () => {

expect(module.getLicenseOrganization()).toBe('Acme Corp');
});

it('caches undefined result when org is absent', () => {
const missingOrgPayload = {
sub: 'test@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
plan: 'paid',
// No org field
};
const validOrgPayload = {
sub: 'test@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
plan: 'paid',
org: 'Acme Corp',
};

process.env.REACT_ON_RAILS_PRO_LICENSE = jwt.sign(missingOrgPayload, testPrivateKey, {
algorithm: 'RS256',
});

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicenseOrganization()).toBeUndefined();

process.env.REACT_ON_RAILS_PRO_LICENSE = jwt.sign(validOrgPayload, testPrivateKey, {
algorithm: 'RS256',
});
expect(module.getLicenseOrganization()).toBeUndefined();
});
});

describe('getLicensePlan', () => {
Expand Down Expand Up @@ -464,7 +522,6 @@ describe('LicenseValidator', () => {

it('returns undefined when license is missing', () => {
delete process.env.REACT_ON_RAILS_PRO_LICENSE;
jest.mocked(fs.existsSync).mockReturnValue(false);

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicensePlan()).toBeUndefined();
Expand Down Expand Up @@ -513,6 +570,35 @@ describe('LicenseValidator', () => {

expect(module.getLicensePlan()).toBe('startup');
});

it('caches undefined result for invalid plan type', () => {
const invalidPlanPayload = {
sub: 'test@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
plan: 'free',
org: 'Acme Corp',
};
const validPlanPayload = {
sub: 'test@example.com',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
plan: 'startup',
org: 'Acme Corp',
};

process.env.REACT_ON_RAILS_PRO_LICENSE = jwt.sign(invalidPlanPayload, testPrivateKey, {
algorithm: 'RS256',
});

const module = jest.requireActual<LicenseValidatorModule>('../src/shared/licenseValidator');
expect(module.getLicensePlan()).toBeUndefined();

process.env.REACT_ON_RAILS_PRO_LICENSE = jwt.sign(validPlanPayload, testPrivateKey, {
algorithm: 'RS256',
});
expect(module.getLicensePlan()).toBeUndefined();
});
});

describe('reset', () => {
Expand Down
2 changes: 1 addition & 1 deletion react_on_rails_pro/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Order matters. If the base package isn't published first, the chain breaks.

`ReactOnRailsPro::LicenseValidator` runs on engine startup via JWT validation.

- License key: `config/react_on_rails_pro_license.key` or `REACT_ON_RAILS_PRO_LICENSE` env var
- License key: `REACT_ON_RAILS_PRO_LICENSE` environment variable
- Expired licenses cause startup failures in dummy app
- License is checked in Pro engine initializer (`lib/react_on_rails_pro/engine.rb`)

Expand Down
27 changes: 8 additions & 19 deletions react_on_rails_pro/LICENSE_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ This change allows your application to start even with license issues, giving yo

## Installation

### Method 1: Environment Variable (Recommended)
### Environment Variable (Required)

Set the `REACT_ON_RAILS_PRO_LICENSE` environment variable:

Expand All @@ -72,22 +72,8 @@ heroku config:set REACT_ON_RAILS_PRO_LICENSE="your_token"
# Add to your CI environment variables if needed
```

### 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.**
Configure your license token via the `REACT_ON_RAILS_PRO_LICENSE` environment variable.
Never commit license tokens to version control.

## License Validation

Expand All @@ -105,7 +91,10 @@ When no license is present, the application runs in **unlicensed mode**. This is

No license setup is needed for development. Developers can install and use React on Rails Pro immediately.

For production deployments, share a paid license via environment variable or configuration file.
For production deployments, configure a paid license via the `REACT_ON_RAILS_PRO_LICENSE` environment variable.

> Migration note: `config/react_on_rails_pro_license.key` is no longer read.
> If you used that file previously, move the token to `REACT_ON_RAILS_PRO_LICENSE`.

### For CI/CD

Expand Down Expand Up @@ -339,7 +328,7 @@ Need help?

## Security Best Practices

1. ✅ **Never commit licenses to Git** — Add `config/react_on_rails_pro_license.key` to `.gitignore`
1. ✅ **Never commit licenses to Git** — Keep license tokens in environment variables or secret managers
2. ✅ **Use environment variables in production**
3. ✅ **Use CI secrets for production deployment pipelines**
4. ✅ **Don't share licenses publicly**
Expand Down
8 changes: 1 addition & 7 deletions react_on_rails_pro/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,7 @@ React on Rails Pro uses a license-optional model to simplify evaluation and deve
export REACT_ON_RAILS_PRO_LICENSE="your-license-token-here"
```

Or create a config file at `config/react_on_rails_pro_license.key`:

```bash
echo "your-license-token-here" > config/react_on_rails_pro_license.key
```

⚠️ **Security Warning**: Never commit your license token to version control. Add `config/react_on_rails_pro_license.key` to your `.gitignore`. For production, use environment variables or secure secret management systems (Rails credentials, Heroku config vars, AWS Secrets Manager, etc.).
⚠️ **Security Warning**: Never commit your license token to version control. For production, use environment variables or secure secret management systems (Rails credentials, Heroku config vars, AWS Secrets Manager, etc.).

For complete license setup instructions, see [LICENSE_SETUP.md](https://github.com/shakacode/react_on_rails/blob/master/react_on_rails_pro/LICENSE_SETUP.md).

Expand Down
19 changes: 13 additions & 6 deletions react_on_rails_pro/docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ Package names have changed:

**Important:** Pro users should now import from `react-on-rails-pro` instead of `react-on-rails`. The Pro package includes all core features plus Pro-exclusive functionality.

## Breaking Changes and Deprecation Policy

To reduce upgrade risk, React on Rails Pro follows this policy:

1. **Deprecate first when practical** (docs/changelog + clear replacement).
2. **Warn at runtime when practical** if a deprecated setup is detected.
3. **Remove in a later release** with a short migration note in this guide.
4. **Exception:** security/legal fixes may be removed immediately, but must include an explicit upgrade note.

### Your Current Setup (GitHub Packages)

If you're upgrading, you currently have:
Expand Down Expand Up @@ -179,13 +188,11 @@ Configure your React on Rails Pro license token as an environment variable:
export REACT_ON_RAILS_PRO_LICENSE="your-license-token-here"
```

Or create a config file at `config/react_on_rails_pro_license.key`:

```bash
echo "your-license-token-here" > config/react_on_rails_pro_license.key
```
> **Migration note (legacy key-file setup):**
> `config/react_on_rails_pro_license.key` is no longer read by React on Rails Pro.
> If you previously used that file, move the token into `REACT_ON_RAILS_PRO_LICENSE`.

⚠️ **Security Warning**: Never commit your license token to version control. Add `config/react_on_rails_pro_license.key` to your `.gitignore`. For production, use environment variables or secure secret management systems (Rails credentials, Heroku config vars, AWS Secrets Manager, etc.).
⚠️ **Security Warning**: Never commit your license token to version control. For production, use environment variables or secure secret management systems (Rails credentials, Heroku config vars, AWS Secrets Manager, etc.).

**Where to get your license token:** Contact [justin@shakacode.com](mailto:justin@shakacode.com) if you don't have your license token.

Expand Down
Loading
Loading