Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3a0697d
feat: add license auto-refresh configuration options
ihabadham Dec 11, 2025
6052e83
feat: add LicenseFetcher service for automatic license renewal
ihabadham Dec 11, 2025
8c905f6
feat: add LicenseCache for persisting fetched license tokens
ihabadham Dec 11, 2025
1fb7901
feat: integrate auto-refresh logic into license validator
ihabadham Dec 11, 2025
b68d448
fix: add daily threshold for ≤7 days refresh window
ihabadham Dec 11, 2025
6fb0fb7
test: add comprehensive tests for automatic license renewal
ihabadham Dec 12, 2025
07d000e
refactor: move WebMock requires to spec_helper.rb
ihabadham Dec 22, 2025
464fbb7
fix: seed license cache on first boot for auto-refresh to work
ihabadham Dec 22, 2025
0808bad
refactor: extract license refresh logic to LicenseRefreshChecker module
ihabadham Dec 22, 2025
319c712
docs: add automatic license renewal documentation
ihabadham Dec 23, 2025
18cdfe7
feat(node-renderer): add automatic license refresh support
ihabadham Dec 24, 2025
b1b83f7
feat(gem): add User-Agent header to license fetch requests
ihabadham Dec 24, 2025
f2b0f41
fix(tests): use fake timers for async-retry tests
ihabadham Dec 24, 2025
2b305e0
test(node-renderer): add missing license test cases
ihabadham Dec 24, 2025
58260a7
fix: add ENV support for license_api_url and fix TypeScript types
ihabadham Dec 26, 2025
2ceb06f
chore: add lychee config with licenses.shakacode.com exclusion
ihabadham Dec 26, 2025
7593128
fix(tests): prevent async-retry timer leak in licenseFetcher tests
ihabadham Dec 26, 2025
b68b676
fix: replace async-retry with custom retry to fix Jest timer leak
ihabadham Dec 26, 2025
ee27a11
Harden auto-refresh edge cases and add timeout coverage
justin808 Mar 13, 2026
9264d46
Merge origin/main into jg-codex/conflict-resolve-2254
justin808 Mar 27, 2026
432304e
Fix license refresh guard and lint regressions
justin808 Mar 28, 2026
4c1846a
Harden Pro license refresh flow
justin808 Mar 28, 2026
f0bb81d
Avoid retrying auth failures
justin808 Mar 28, 2026
ec43d8f
Harden license cache writes and parsing
justin808 Mar 28, 2026
86fe0cc
Exclude flaky GitHub links from lychee
justin808 Mar 28, 2026
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 .lychee.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ exclude = [
# PLANNED DEPLOYMENTS NOT YET LIVE
# ============================================================================
'^https://ror-spec-dummy\.reactrails\.com', # spec/dummy demo - deployment pending
# TODO: Remove this exclusion once licenses.shakacode.com is deployed
'^https://licenses\.shakacode\.com', # React on Rails Pro licensing app - deployment pending

# ============================================================================
# SITES FROM PROJECTS.MD THAT BLOCK BOTS OR ARE UNRELIABLE
Expand Down Expand Up @@ -99,6 +101,8 @@ exclude = [
# the changelog is committed). Exclude all compare links since they're
# auto-generated by changelog tooling.
'^https://github\.com/shakacode/react_on_rails/compare/',
'^https://github\.com/shakacode/react_on_rails/pull/2280$', # Intermittent 502 from GitHub PR page in CI
'^https://github\.com/shakacode/shakapacker/blob/cdf32835d3e0949952b8b4b53063807f714f9b24/package/environments/base\.js(#.*)?$', # Intermittent 502 from GitHub blob view in CI

# ============================================================================
# DELETED GITHUB USER ACCOUNTS
Expand Down
22 changes: 22 additions & 0 deletions docs/oss/configuration/configuration-pro.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,28 @@ ReactOnRailsPro.configure do |config|
# - Faster page loading
# - Selective hydration of client components
# - Progressive rendering with Suspense boundaries

################################################################################
# LICENSE AUTO-REFRESH CONFIGURATION
# See LICENSE_SETUP.md for detailed documentation on automatic license renewal.
################################################################################

# License key for automatic license renewal. When configured, the gem fetches
# fresh license tokens automatically as your current token approaches expiration.
# Can also be set via ENV: REACT_ON_RAILS_PRO_LICENSE_KEY (takes precedence).
# Get your license key from the dashboard after purchasing a paid subscription.
# Default is nil (auto-refresh disabled unless set).
config.license_key = ENV["REACT_ON_RAILS_PRO_LICENSE_KEY"]

# Enable or disable automatic license refresh. Set to false for air-gapped
# environments or when outbound network calls are not permitted.
# Auto-refresh only activates if license_key is also configured.
# Default is true.
config.auto_refresh_license = true

# URL of the license API server. Only change if using a custom license server.
# Default is "https://licenses.shakacode.com".
config.license_api_url = "https://licenses.shakacode.com"
end
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ and for "@fastify/..." dependencies in your package.json. Consider removing them
await worker.default(config).ready();
} else {
const master = require('./master.js') as typeof import('./master.js');
master.default(config);
await master.default(config);
}
/* eslint-enable global-require,@typescript-eslint/no-require-imports */
}
6 changes: 4 additions & 2 deletions packages/react-on-rails-pro-node-renderer/src/master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import log from './shared/log.js';
import { buildConfig, Config, logSanitizedConfig } from './shared/configBuilder.js';
import restartWorkers from './master/restartWorkers.js';
import * as errorReporter from './shared/errorReporter.js';
import { getLicenseStatus } from './shared/licenseValidator.js';
import { getLicenseStatus, warmLicenseValidationState } from './shared/licenseValidator.js';

const MILLISECONDS_IN_MINUTE = 60000;
// How often to scan for orphaned upload directories.
Expand All @@ -19,7 +19,9 @@ const ORPHAN_CLEANUP_INTERVAL_MS = 5 * MILLISECONDS_IN_MINUTE;
// uploads in progress are never deleted by the cleanup timer.
const ORPHAN_AGE_THRESHOLD_MS = 30 * MILLISECONDS_IN_MINUTE;

export default function masterRun(runningConfig?: Partial<Config>) {
export default async function masterRun(runningConfig?: Partial<Config>) {
await warmLicenseValidationState();

// Check license status on startup and log appropriately
// Use warn in production, info in non-production (matches Ruby behavior)
// Check both NODE_ENV and RAILS_ENV for production detection to stay consistent
Expand Down
171 changes: 171 additions & 0 deletions packages/react-on-rails-pro-node-renderer/src/shared/licenseCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Caches fetched license tokens to disk.
* Persists across app restarts to reduce API calls.
* Validates that cached token belongs to the currently configured license_key.
*
* @module shared/licenseCache
*/

import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';

const CACHE_FILENAME = 'react_on_rails_pro_license.cache';

export interface CacheData {
token: string;
expires_at: string;
fetched_at: string;
license_key_hash: string;
}

/**
* Gets the current license key from environment.
*/
function getLicenseKey(): string | undefined {
return process.env.REACT_ON_RAILS_PRO_LICENSE_KEY;
}

/**
* Computes a hash of the license key for validation.
* Only stores first 16 chars of SHA256 hash.
*/
function computeKeyHash(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex').substring(0, 16);
}

/**
* Gets the current key hash, or null if no key is set.
*/
function getCurrentKeyHash(): string | null {
const key = getLicenseKey();
if (!key) {
return null;
}
return computeKeyHash(key);
}

/**
* Gets the cache directory path.
* Uses tmp/ relative to current working directory.
*/
function getCacheDir(): string {
return path.join(process.cwd(), 'tmp');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cache directory relies on process.cwd() matching Rails.root

The Ruby gem writes the cache to Rails.root.join("tmp"). The Node renderer writes to path.join(process.cwd(), 'tmp'). These are the same only when the Node renderer is started from the Rails root, which is the typical case but not guaranteed (e.g. cd /tmp && node /app/renderer/server.js).

If they diverge, the Ruby gem and Node renderer maintain separate cache files, so a token refreshed on the Ruby side won't be available to the Node renderer on the next restart (and vice versa). Consider making the cache path configurable via an environment variable (e.g. REACT_ON_RAILS_PRO_CACHE_DIR) so both sides can be pointed at the same location explicitly.

}
Comment on lines +52 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Node renderer is a separate process from Rails and its working directory may not be the Rails application root. Depending on how the renderer is launched, process.cwd() could resolve to the renderer's own package directory, leaving the cache file outside the Rails tmp/ folder that the Ruby gem reads from.

The Ruby gem resolves the cache path as Rails.root.join("tmp", ...). If the paths diverge, the token refreshed by the Node renderer will never be seen by the gem (and vice versa), making the cache effectively write-only from one side.

Consider making the cache directory configurable (e.g. REACT_ON_RAILS_PRO_CACHE_DIR env var) so operators can point both sides at the same path, and add a note to the documentation about this requirement.


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hardcoded tmp/ relative to process.cwd()

The cache is always written to <cwd>/tmp/. This directory:

  1. May not exist in non-Rails environments (e.g. standalone Node.js deployments).
  2. Is not guaranteed to be writable (some container images use read-only root filesystems).
  3. Has no gitignore entry added by this PR — the cache file containing a valid JWT token may accidentally be committed.

Consider making the cache path configurable via REACT_ON_RAILS_PRO_LICENSE_CACHE_DIR and documenting that tmp/react_on_rails_pro_license.cache should be in .gitignore.

/**
* Gets the full cache file path.
*/
function getCachePath(): string {
return path.join(getCacheDir(), CACHE_FILENAME);
}

/**
* Validates that the cached data belongs to the current license key.
*/
function isValidForCurrentKey(data: CacheData): boolean {
const storedHash = data.license_key_hash;
if (!storedHash) {
return false;
}

const currentHash = getCurrentKeyHash();
return storedHash === currentHash;
}

/**
* Reads the cached license data from disk.
* Returns null if cache doesn't exist, is invalid, or belongs to a different license key.
*/
export function readCache(): CacheData | null {
try {
const cachePath = getCachePath();

if (!fs.existsSync(cachePath)) {
return null;
}

const content = fs.readFileSync(cachePath, 'utf8');
const data = JSON.parse(content) as CacheData;

if (!isValidForCurrentKey(data)) {
return null;
}

return data;
} catch {
return null;
}
}

/**
* Writes license data to the cache file.
* Automatically adds license_key_hash and fetched_at.
*/
export function writeCache(data: { token: string; expires_at: string }): void {
try {
const cacheDir = getCacheDir();
const cachePath = getCachePath();

// Ensure cache directory exists
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}

const currentKeyHash = getCurrentKeyHash();
if (!currentKeyHash) {
console.warn('[React on Rails Pro] Cannot write cache: no license key configured');
return;
}

const cacheData: CacheData = {
token: data.token,
expires_at: data.expires_at,
fetched_at: new Date().toISOString(),
license_key_hash: currentKeyHash,
};

const tempPath = `${cachePath}.${crypto.randomBytes(8).toString('hex')}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(cacheData, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, cachePath);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.warn(`[React on Rails Pro] Failed to write license cache: ${errorMessage}`);
}
}

/**
* Gets the cached token, or null if not available.
*/
export function getCachedToken(): string | null {
const data = readCache();
return data?.token ?? null;
Comment on lines +140 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Performance: readCache() (disk I/O + JSON parse) is called once per accessor

getCachedToken(), getFetchedAt(), and getExpiresAt() each independently call readCache(). A single call to maybeRefreshLicense therefore reads and parses the cache file 2–3 times before potentially writing it. Since this runs at process startup, consider returning the full CacheData from a single read and letting callers destructure, or memoising the result for the duration of a single warm-up cycle.

}

/**
* Gets the fetched_at timestamp from cache.
* Returns null if cache doesn't exist or is invalid.
*/
export function getFetchedAt(): Date | null {
const data = readCache();
if (!data?.fetched_at) {
return null;
}

const parsedDate = new Date(data.fetched_at);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
}

/**
* Gets the expires_at timestamp from cache.
* Returns null if cache doesn't exist or is invalid.
*/
export function getExpiresAt(): Date | null {
const data = readCache();
if (!data?.expires_at) {
return null;
}

const parsedDate = new Date(data.expires_at);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
}
Loading
Loading