Fix code quality violations and rename select input components

Move small tasks from wishlist to todo, update npm packages
Replace #[Auth] attributes with manual auth checks and code quality rule
Remove on_jqhtml_ready lifecycle method from framework
Complete ACL system with 100-based role indexing and /dev/acl tester
WIP: ACL system implementation with debug instrumentation
Convert rsx:check JS linting to RPC socket server
Clean up docs and fix $id→$sid in man pages, remove SSR/FPC feature
Reorganize wishlists: priority order, mark sublayouts complete, add email
Update model_fetch docs: mark MVP complete, fix enum docs, reorganize
Comprehensive documentation overhaul: clarity, compression, and critical rules
Convert Contacts/Projects CRUD to Model.fetch() and add fetch_or_null()
Add JS ORM relationship lazy-loading and fetch array handling
Add JS ORM relationship fetching and CRUD documentation
Fix ORM hydration and add IDE resolution for Base_* model stubs
Rename Json_Tree_Component to JS_Tree_Debug_Component and move to framework
Enhance JS ORM infrastructure and add Json_Tree class name badges

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-23 21:39:43 +00:00
parent 78553d4edf
commit 84ca3dfe42
167 changed files with 7538 additions and 49164 deletions

View File

@@ -2,14 +2,14 @@
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
/**
* Bootstrap 5 CDN Bundle
*
* Provides Bootstrap 5 CSS and JavaScript via CDN.
*/
class Bootstrap5_Bundle extends Rsx_Bundle_Abstract
class Bootstrap5_Bundle extends Rsx_Asset_Bundle_Abstract
{
/**
* Define the bundle configuration

View File

@@ -2,7 +2,7 @@
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
/**
* jQuery CDN Bundle
@@ -10,7 +10,7 @@ use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
* Provides jQuery library via CDN. This bundle is automatically included
* in all other bundles as a required dependency.
*/
class Jquery_Bundle extends Rsx_Bundle_Abstract
class Jquery_Bundle extends Rsx_Asset_Bundle_Abstract
{
/**
* Define the bundle configuration

View File

@@ -2,7 +2,7 @@
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
/**
* Lodash CDN Bundle
@@ -10,7 +10,7 @@ use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
* Provides Lodash utility library via CDN. This bundle is automatically included
* in all other bundles as a required dependency.
*/
class Lodash_Bundle extends Rsx_Bundle_Abstract
class Lodash_Bundle extends Rsx_Asset_Bundle_Abstract
{
/**
* Define the bundle configuration

View File

@@ -15,9 +15,9 @@
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
class Quill_Bundle extends Rsx_Bundle_Abstract
class Quill_Bundle extends Rsx_Asset_Bundle_Abstract
{
/**
* Define the bundle configuration

View File

@@ -1,47 +0,0 @@
<?php
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Tom Select CDN Bundle
*
* Provides Tom Select (modern select2 alternative) via CDN.
* Tom Select is a lightweight, vanilla JS library for enhanced select boxes
* with search, ajax, tagging, and accessibility features.
*
* Features:
* - Text search/filtering
* - Ajax/remote data loading
* - Custom value creation (tagging)
* - Multi-select support
* - Excellent accessibility (ARIA, screen readers)
* - Bootstrap-friendly theming
*/
class Tom_Select_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [], // No local files
'cdn_assets' => [
'css' => [
[
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.default.min.css',
],
],
'js' => [
[
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js',
],
],
],
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\RSpade\CodeQuality;
use App\RSpade\CodeQuality\CodeQuality_Violation;
use App\RSpade\CodeQuality\Support\CacheManager;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\Js_CodeQuality_Rpc;
use App\RSpade\CodeQuality\Support\ViolationCollector;
use App\RSpade\Core\Manifest\Manifest;
@@ -409,7 +410,7 @@ class CodeQualityChecker
}
/**
* Lint JavaScript file (from monolith line 602)
* Lint JavaScript file using RPC server
* Returns true if syntax error found
*/
protected static function lint_javascript_file(string $file_path): bool
@@ -428,70 +429,60 @@ class CodeQualityChecker
if (str_contains($file_path, '/resource/vscode_extension/')) {
return false;
}
// Create cache directory for lint flags
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/js-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/js-lint-passed';
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
// Generate flag file path (no .js extension to avoid IDE detection)
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$relative_path = str_replace($base_path . '/', '', $file_path);
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
// Check if lint was already passed
if (file_exists($flag_path)) {
$source_mtime = filemtime($file_path);
$flag_mtime = filemtime($flag_path);
if ($flag_mtime >= $source_mtime) {
// File hasn't changed since last successful lint
return false; // No errors
}
}
// Run JavaScript syntax check using Node.js
$linter_path = $base_path . '/bin/js-linter.js';
if (!file_exists($linter_path)) {
// Linter script not found, skip linting
return false;
}
$command = sprintf('node %s %s 2>&1', escapeshellarg($linter_path), escapeshellarg($file_path));
$output = shell_exec($command);
// Lint via RPC server (lazy starts if not running)
$error = Js_CodeQuality_Rpc::lint($file_path);
// Check if there's a syntax error
if ($output && trim($output) !== '') {
if ($error !== null) {
// Delete flag file if it exists (file now has errors)
if (file_exists($flag_path)) {
unlink($flag_path);
}
// Parse error message for line number if available
$line_number = 0;
if (preg_match('/Line (\d+)/', $output, $matches)) {
$line_number = (int)$matches[1];
}
$line_number = $error['line'] ?? 0;
$message = $error['message'] ?? 'Unknown syntax error';
static::$collector->add(
new CodeQuality_Violation(
'JS-SYNTAX',
$file_path,
$line_number,
trim($output),
$message,
'critical',
null,
'Fix the JavaScript syntax error before running other checks.'
)
);
return true; // Error found
}
// Create flag file to indicate successful lint
touch($flag_path);
return false; // No errors
}

View File

@@ -27,7 +27,6 @@ class LifecycleMethodsStatic_CodeQualityRule extends CodeQualityRule_Abstract
'on_app_modules_init',
'on_app_init',
'on_app_ready',
'on_jqhtml_ready',
];
public function get_id(): string

View File

@@ -3,6 +3,7 @@
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\Js_CodeQuality_Rpc;
/**
* JavaScript 'this' Usage Rule
@@ -94,8 +95,7 @@ class ThisUsage_CodeQualityRule extends CodeQualityRule_Abstract
}
/**
* Use Node.js with acorn to parse JavaScript and find violations
* Uses external parser script stored in resources directory
* Analyze JavaScript file for 'this' usage violations via RPC server
*/
private function parse_with_acorn(string $file_path): array
{
@@ -125,33 +125,8 @@ class ThisUsage_CodeQualityRule extends CodeQualityRule_Abstract
}
}
// Path to the parser script
$parser_script = __DIR__ . '/resource/this-usage-parser.js';
if (!file_exists($parser_script)) {
// Parser script missing - fatal error
throw new \RuntimeException("JS-THIS parser script missing: {$parser_script}");
}
// Run Node.js parser with the external script
$output = shell_exec("cd /tmp && node " . escapeshellarg($parser_script) . " " . escapeshellarg($file_path) . " 2>&1");
if (!$output) {
return [];
}
$result = json_decode($output, true);
if (!$result) {
return [];
}
// Check for errors from the parser
if (isset($result['error'])) {
// Parser encountered an error but it's not fatal for the rule
return [];
}
$violations = $result['violations'] ?? [];
// Analyze via RPC server (lazy starts if not running)
$violations = Js_CodeQuality_Rpc::analyze_this($file_path);
// Cache the result
file_put_contents($cache_file, json_encode($violations));

View File

@@ -1,378 +0,0 @@
#!/usr/bin/env node
/**
* JavaScript 'this' usage parser for code quality checks
*
* PURPOSE: Parse JavaScript files to find 'this' usage violations
* according to RSpade coding standards.
*
* USAGE: node this-usage-parser.js <filepath>
* OUTPUT: JSON with violations array
*
* RULES:
* - Anonymous functions can use: const $var = $(this) as first line
* - Instance methods must use: const that = this as first line
* - Static methods should never use 'this', use ClassName instead
* - Arrow functions are ignored (they inherit 'this')
*
* @FILENAME-CONVENTION-EXCEPTION - Node.js utility script
*/
const fs = require('fs');
const acorn = require('acorn');
const walk = require('acorn-walk');
// Known jQuery callback methods - used for better remediation messages
const JQUERY_CALLBACKS = new Set([
'click', 'dblclick', 'mouseenter', 'mouseleave', 'mousedown', 'mouseup',
'mousemove', 'mouseover', 'mouseout', 'change', 'submit', 'focus', 'blur',
'keydown', 'keyup', 'keypress', 'resize', 'scroll', 'select', 'load',
'on', 'off', 'one', 'each', 'map', 'filter',
'fadeIn', 'fadeOut', 'slideDown', 'slideUp', 'animate',
'done', 'fail', 'always', 'then', 'ready', 'hover'
]);
function analyzeFile(filePath) {
try {
const code = fs.readFileSync(filePath, 'utf8');
const lines = code.split('\n');
let ast;
try {
ast = acorn.parse(code, {
ecmaVersion: 2020,
sourceType: 'module',
locations: true
});
} catch (e) {
// Parse error - return empty violations
return { violations: [], error: `Parse error: ${e.message}` };
}
const violations = [];
const classInfo = new Map(); // Track class info
// First pass: identify all classes and their types
walk.simple(ast, {
ClassDeclaration(node) {
const hasStaticInit = node.body.body.some(member =>
member.static && member.key?.name === 'init'
);
classInfo.set(node.id.name, {
isStatic: hasStaticInit
});
}
});
// Helper to check if first line of function has valid pattern
function checkFirstLinePattern(funcNode) {
if (!funcNode.body || !funcNode.body.body || funcNode.body.body.length === 0) {
return null;
}
let checkIndex = 0;
const firstStmt = funcNode.body.body[0];
// Check if first statement is e.preventDefault() or similar
if (firstStmt.type === 'ExpressionStatement' &&
firstStmt.expression?.type === 'CallExpression' &&
firstStmt.expression?.callee?.type === 'MemberExpression' &&
firstStmt.expression?.callee?.property?.name === 'preventDefault') {
// First line is preventDefault, check second line for pattern
checkIndex = 1;
if (funcNode.body.body.length <= 1) {
return null; // No second statement
}
}
const targetStmt = funcNode.body.body[checkIndex];
if (targetStmt.type !== 'VariableDeclaration') {
return null;
}
const firstDecl = targetStmt.declarations[0];
if (!firstDecl || !firstDecl.init) {
return null;
}
const varKind = targetStmt.kind; // 'const', 'let', or 'var'
// Check for 'that = this' pattern
if (firstDecl.id.name === 'that' &&
firstDecl.init.type === 'ThisExpression') {
if (varKind !== 'const') {
return 'that-pattern-wrong-kind';
}
return 'that-pattern';
}
// Check for 'CurrentClass = this' pattern (for static polymorphism)
if (firstDecl.id.name === 'CurrentClass' &&
firstDecl.init.type === 'ThisExpression') {
if (varKind !== 'const') {
return 'currentclass-pattern-wrong-kind';
}
return 'currentclass-pattern';
}
// Check for '$var = $(this)' pattern
if (firstDecl.id.name.startsWith('$') &&
firstDecl.init.type === 'CallExpression' &&
firstDecl.init.callee.name === '$' &&
firstDecl.init.arguments.length === 1 &&
firstDecl.init.arguments[0].type === 'ThisExpression') {
if (varKind !== 'const') {
return 'jquery-pattern-wrong-kind';
}
return 'jquery-pattern';
}
return null;
}
// Helper to detect if we're in a jQuery callback (best effort)
function isLikelyJQueryCallback(ancestors) {
for (let i = ancestors.length - 1; i >= 0; i--) {
const node = ancestors[i];
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
const methodName = node.callee.property.name;
if (JQUERY_CALLBACKS.has(methodName)) {
return true;
}
}
}
return false;
}
// Walk the AST looking for 'this' usage
walk.ancestor(ast, {
ThisExpression(node, ancestors) {
// Skip arrow functions - they inherit 'this'
for (const ancestor of ancestors) {
if (ancestor.type === 'ArrowFunctionExpression') {
return; // Skip - arrow functions inherit context
}
}
// Find containing function and class
let containingFunc = null;
let containingClass = null;
let isAnonymousFunc = false;
let isStaticMethod = false;
let isConstructor = false;
let isInstanceMethod = false;
let hasMethodDefinition = false;
// First pass: check if we're in a MethodDefinition
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (ancestor.type === 'MethodDefinition') {
hasMethodDefinition = true;
isStaticMethod = ancestor.static;
isConstructor = ancestor.kind === 'constructor';
isInstanceMethod = !ancestor.static && ancestor.kind !== 'constructor';
break;
}
}
// Second pass: find function and class
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (!containingFunc && (
ancestor.type === 'FunctionExpression' ||
ancestor.type === 'FunctionDeclaration'
)) {
containingFunc = ancestor;
// Only mark as anonymous if NOT inside a MethodDefinition
isAnonymousFunc = ancestor.type === 'FunctionExpression' && !hasMethodDefinition;
}
if (!containingClass && (
ancestor.type === 'ClassDeclaration' ||
ancestor.type === 'ClassExpression'
)) {
containingClass = ancestor;
}
}
if (!containingFunc) {
return; // Not in a function
}
// Skip constructors - 'this' is allowed for property assignment
if (isConstructor) {
return;
}
// Skip instance methods - 'this' is allowed directly in instance methods
// Only enforce aliasing for anonymous functions and static methods
if (isInstanceMethod) {
return;
}
// Check if this is part of the allowed first-line pattern with const
const parent = ancestors[ancestors.length - 2];
const firstStmt = containingFunc.body?.body?.[0];
let checkIndex = 0;
// Check if first statement is preventDefault
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.type === 'CallExpression' &&
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
if (hasPreventDefault) {
checkIndex = 1;
}
const targetStmt = containingFunc.body?.body?.[checkIndex];
const isTargetConst = targetStmt?.type === 'VariableDeclaration' && targetStmt?.kind === 'const';
// Check if this 'this' is inside $(this) on the first or second line
// For jQuery pattern: const $var = $(this)
if (parent && parent.type === 'CallExpression' && parent.callee?.name === '$') {
// This is $(this) - check if it's in the right position with const
if (isTargetConst &&
targetStmt?.declarations?.[0]?.init === parent &&
targetStmt?.declarations?.[0]?.id?.name?.startsWith('$')) {
return; // This is const $var = $(this) in correct position
}
}
// Check if this 'this' is the 'const that = this' in correct position
if (parent && parent.type === 'VariableDeclarator' &&
parent.id?.name === 'that' &&
isTargetConst &&
targetStmt?.declarations?.[0]?.id?.name === 'that') {
return; // This is 'const that = this' in correct position
}
// Check if this 'this' is the 'const CurrentClass = this' in correct position
if (parent && parent.type === 'VariableDeclarator' &&
parent.id?.name === 'CurrentClass' &&
isTargetConst &&
targetStmt?.declarations?.[0]?.id?.name === 'CurrentClass') {
return; // This is 'const CurrentClass = this' in correct position
}
// Check what pattern is used
const pattern = checkFirstLinePattern(containingFunc);
// Determine the violation and remediation
let message = '';
let remediation = '';
const lineNum = node.loc.start.line;
const codeSnippet = lines[lineNum - 1].trim();
const className = containingClass?.id?.name || 'unknown';
const isJQueryContext = isLikelyJQueryCallback(ancestors);
// Anonymous functions take precedence - even if inside a static method
if (isAnonymousFunc) {
if (!pattern) {
// Check if there's a preventDefault on the first line
const firstStmt = containingFunc.body?.body?.[0];
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.type === 'CallExpression' &&
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
if (isJQueryContext) {
message = `'this' in jQuery callback should be aliased for clarity.`;
if (hasPreventDefault) {
remediation = `Add 'const $element = $(this);' as the second line (after preventDefault), then use $element instead of 'this'.`;
} else {
remediation = `Add 'const $element = $(this);' as the first line of this function, then use $element instead of 'this'.`;
}
} else {
message = `Ambiguous 'this' usage in anonymous function.`;
if (hasPreventDefault) {
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as the second line (after preventDefault).\n` +
`If this is an instance context: Add 'const that = this;' as the second line.\n` +
`Then use the aliased variable instead of 'this'.`;
} else {
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as first line.\n` +
`If this is an instance context: Add 'const that = this;' as first line.\n` +
`Then use the aliased variable instead of 'this'.`;
}
}
} else if (pattern === 'that-pattern' || pattern === 'jquery-pattern') {
message = `'this' used after aliasing. Use the aliased variable instead.`;
// Find the variable declaration (might be first or second statement)
let varDeclIndex = 0;
const firstStmt = containingFunc.body?.body?.[0];
if (firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault') {
varDeclIndex = 1;
}
const varName = containingFunc.body?.body?.[varDeclIndex]?.declarations?.[0]?.id?.name;
remediation = pattern === 'jquery-pattern'
? `You already have 'const ${varName} = $(this)'. Use that variable instead of 'this'.`
: `You already have 'const that = this'. Use 'that' instead of 'this'.`;
} else if (pattern === 'that-pattern-wrong-kind') {
message = `Instance alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const that = this;' - the instance reference should never be reassigned.`;
} else if (pattern === 'jquery-pattern-wrong-kind') {
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
}
} else if (isStaticMethod) {
if (!pattern) {
message = `Static method in '${className}' should not use naked 'this'.`;
remediation = `Static methods have two options:\n` +
`1. If you need the exact class (no polymorphism): Replace 'this' with '${className}'\n` +
`2. If you need polymorphism (inherited classes): Add 'const CurrentClass = this;' as first line\n` +
` Then use CurrentClass.name or CurrentClass.property for polymorphic access.\n` +
`Consider: Does this.name need to work for child classes? If yes, use CurrentClass pattern.`;
} else if (pattern === 'currentclass-pattern') {
message = `'this' used after aliasing to CurrentClass. Use 'CurrentClass' instead.`;
remediation = `You already have 'const CurrentClass = this;'. Use 'CurrentClass' for all access, not naked 'this'.`;
} else if (pattern === 'currentclass-pattern-wrong-kind') {
message = `CurrentClass pattern must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const CurrentClass = this;' - the CurrentClass reference should never be reassigned.`;
} else if (pattern === 'jquery-pattern') {
// jQuery pattern in static method's anonymous function is OK
return;
} else if (pattern === 'jquery-pattern-wrong-kind') {
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
}
if (isAnonymousFunc && !pattern) {
remediation += `\nException: If this is a jQuery callback, add 'const $element = $(this);' as the first line.`;
}
}
// NOTE: Instance methods are exempt from this rule - they can use 'this' directly
// The check returns early for instance methods, so this else block is unreachable for them
if (message) {
violations.push({
line: lineNum,
message: message,
codeSnippet: codeSnippet,
remediation: remediation
});
}
}
});
return { violations: violations };
} catch (error) {
return { violations: [], error: error.message };
}
}
// Main execution
const filePath = process.argv[2];
if (!filePath) {
console.log(JSON.stringify({ violations: [], error: 'No file path provided' }));
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.log(JSON.stringify({ violations: [], error: `File not found: ${filePath}` }));
process.exit(1);
}
const result = analyzeFile(filePath);
console.log(JSON.stringify(result));

View File

@@ -81,6 +81,48 @@ class InstanceMethods_CodeQualityRule extends CodeQualityRule_Abstract
// Check JavaScript classes
$this->check_javascript_classes($files);
// Check JS model monoprogenic enforcement
$this->check_js_model_monoprogenic($files);
}
/**
* HACK #3 - JS Model Monoprogenic Enforcement
*
* Ensures that JS model classes cannot be extended. Only Base_ stubs can be extended,
* not the PHP model class names directly. This prevents broken inheritance chains.
*
* Monoprogenic = can only produce one level of offspring
*/
private function check_js_model_monoprogenic(array $files): void
{
foreach ($files as $file => $metadata) {
// Skip if not a JavaScript class
if (!isset($metadata['class']) || ($metadata['extension'] ?? '') !== 'js') {
continue;
}
// Skip if no parent class
$parent_class = $metadata['extends'] ?? null;
if (!$parent_class) {
continue;
}
// Check if parent is a PHP model class name (not Base_)
if (\App\RSpade\Core\Manifest\Manifest::is_php_model_class($parent_class)) {
$this->add_violation(
$file,
1,
"Class '{$metadata['class']}' extends PHP model '{$parent_class}' directly. " .
"JS model classes are monoprogenic - they cannot be extended further.",
"extends {$parent_class}",
"JavaScript model classes must extend the Base_ stub instead:\n" .
" - Change: class {$metadata['class']} extends Base_{$parent_class}\n" .
" - The Base_ stub is auto-generated and properly inherits from Rsx_Js_Model",
'critical'
);
}
}
}
/**
@@ -263,6 +305,17 @@ class InstanceMethods_CodeQualityRule extends CodeQualityRule_Abstract
*/
private function is_js_class_instantiatable(string $class_name, array $files): bool
{
// HACK #2 - JS Model instantiatable bypass: If this is a PHP model class name or
// a Base_ stub for a PHP model, it's automatically instantiatable because model
// stubs are generated with @Instantiatable during bundle compilation.
$model_check_name = $class_name;
if (str_starts_with($class_name, 'Base_')) {
$model_check_name = substr($class_name, 5); // Remove "Base_" prefix
}
if (\App\RSpade\Core\Manifest\Manifest::is_php_model_class($model_check_name)) {
return true;
}
// Find the class metadata
$class_metadata = null;
foreach ($files as $file => $metadata) {

View File

@@ -0,0 +1,150 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Models;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Validates #[Ajax_Endpoint_Model_Fetch] attribute placement
*
* This attribute can ONLY be placed on:
* 1. Methods marked with #[Relationship] - exposes relationship to JavaScript
* 2. The static fetch($id) method - enables Model.fetch() in JavaScript
*
* For custom server-side methods that need JavaScript access:
* - Create a JS class extending Base_{Model}
* - Create an Ajax endpoint on an appropriate controller
* - Add the method to the JS class calling the Ajax endpoint
*/
class ModelAjaxFetchAttribute_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'MODEL-AJAX-FETCH-01';
}
/**
* Get the rule name
*/
public function get_name(): string
{
return 'Ajax Endpoint Model Fetch Attribute Validation';
}
/**
* Get the rule description
*/
public function get_description(): string
{
return 'Validates #[Ajax_Endpoint_Model_Fetch] is only used on relationships or static fetch($id)';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Get default severity
*/
public function get_default_severity(): string
{
return 'critical';
}
/**
* Whether this rule runs during manifest scan
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
/**
* Run the rule check
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check PHP files
if (!str_ends_with($file_path, '.php')) {
return;
}
// Check if it's a model class (extends Rsx_Model_Abstract or related)
$extends = $metadata['extends'] ?? null;
$class_name = $metadata['class'] ?? null;
if (!$class_name) {
return;
}
// Check all methods for the attribute
$all_methods = array_merge(
$metadata['static_methods'] ?? [],
$metadata['public_instance_methods'] ?? []
);
$lines = explode("\n", $contents);
foreach ($all_methods as $method_name => $method_data) {
// Check if method has Ajax_Endpoint_Model_Fetch attribute
$attributes = $method_data['attributes'] ?? [];
if (!isset($attributes['Ajax_Endpoint_Model_Fetch'])) {
continue;
}
// Valid case 1: Method is static fetch($id)
if ($method_name === 'fetch') {
$is_static = isset($metadata['static_methods']['fetch']);
if ($is_static) {
continue; // Valid use
}
}
// Valid case 2: Method has #[Relationship] attribute
if (isset($attributes['Relationship'])) {
continue; // Valid use
}
// Invalid use - find line number
$line_number = $this->_find_method_line($lines, $method_name);
$this->add_violation(
$file_path,
$line_number,
"#[Ajax_Endpoint_Model_Fetch] can only be applied to:\n" .
" 1. Methods marked with #[Relationship] (exposes relationship to JavaScript)\n" .
" 2. The static fetch(\$id) method (enables Model.fetch() in JavaScript)\n\n" .
"For custom server-side methods that need JavaScript access:\n" .
" 1. Create a JavaScript class with the same name as the PHP model, extending Base_{ModelName}\n" .
" 2. Create an Ajax endpoint on an appropriate controller\n" .
" 3. Add the method to the JS class and have it call the controller Ajax endpoint",
"#[Ajax_Endpoint_Model_Fetch] on {$method_name}()",
"Remove #[Ajax_Endpoint_Model_Fetch] and follow the custom method pattern above",
'critical'
);
}
}
/**
* Find the line number of a method declaration
*/
private function _find_method_line(array $lines, string $method_name): int
{
$pattern = '/function\s+' . preg_quote($method_name, '/') . '\s*\(/';
for ($i = 0; $i < count($lines); $i++) {
if (preg_match($pattern, $lines[$i])) {
return $i + 1;
}
}
return 1; // Default to first line if not found
}
}

View File

@@ -0,0 +1,316 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* EndpointAuthCheckRule - Validates that controller endpoints have auth checks
*
* This rule ensures that all #[Route], #[SPA], and #[Ajax_Endpoint] methods
* have authentication checks, either:
* - In the method body itself, OR
* - In a pre_dispatch() method in the same controller
*
* Valid auth check patterns:
* - Session::is_logged_in()
* - Session::get_user()
* - Permission::has_permission()
* - Permission::has_role()
* - response_unauthorized()
*
* Exemption:
* - Add @auth-exempt comment with reason to skip check for public endpoints
*/
class EndpointAuthCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'PHP-AUTH-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Endpoint Authentication Check';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Validates that controller endpoints (#[Route], #[SPA], #[Ajax_Endpoint]) have authentication checks';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Whether this rule is called during manifest scan
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Only run during rsx:check
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'high';
}
/**
* Patterns that indicate an auth check is present
*/
private const AUTH_CHECK_PATTERNS = [
'Session::is_logged_in',
'Session::get_user',
'Session::get_user_id',
'Permission::has_permission',
'Permission::has_role',
'Permission::authenticated',
'Permission::require_permission',
'Permission::require_role',
'response_unauthorized',
'->has_permission(',
'->has_role(',
];
/**
* Check a file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Read original file content (not sanitized) for comment checking
$original_contents = file_get_contents($file_path);
// Skip if file-level exception comment is present
if (strpos($original_contents, '@' . $this->get_id() . '-EXCEPTION') !== false) {
return;
}
// Skip if class-level @auth-exempt comment is present (all endpoints public)
if (strpos($original_contents, '@auth-exempt') !== false) {
// Check if @auth-exempt appears before class definition (in class docblock)
// Use regex to find actual class definition, not 'class' in use statements
if (preg_match('/^(abstract\s+)?class\s+\w+/m', $original_contents, $matches, PREG_OFFSET_CAPTURE)) {
$class_pos = $matches[0][1];
$exempt_pos = strpos($original_contents, '@auth-exempt');
if ($exempt_pos !== false && $exempt_pos < $class_pos) {
return;
}
}
}
// Only check controller files (must extend Rsx_Controller_Abstract)
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Rsx_Controller_Abstract') {
return;
}
// Skip archived files
if (str_contains($file_path, '/archive/') || str_contains($file_path, '/archived/')) {
return;
}
// Get the class name
$class_name = $metadata['class'] ?? null;
if (!$class_name) {
return;
}
// Check if pre_dispatch has auth check
$pre_dispatch_has_auth = $this->pre_dispatch_has_auth_check($contents, $metadata);
// Get public static methods from metadata
$methods = $metadata['public_static_methods'] ?? [];
foreach ($methods as $method_name => $method_info) {
// Skip pre_dispatch itself
if ($method_name === 'pre_dispatch') {
continue;
}
// Check if method has endpoint attributes
$has_endpoint_attr = false;
$endpoint_type = null;
$attributes = $method_info['attributes'] ?? [];
foreach ($attributes as $attr_name => $attr_data) {
$short_name = basename(str_replace('\\', '/', $attr_name));
if (in_array($short_name, ['Route', 'SPA', 'Ajax_Endpoint'])) {
$has_endpoint_attr = true;
$endpoint_type = $short_name;
break;
}
}
// Skip methods without endpoint attributes
if (!$has_endpoint_attr) {
continue;
}
// Get line number for this method
$line_number = $method_info['line'] ?? 1;
// Check if method has @auth-exempt comment
if ($this->method_has_auth_exempt($original_contents, $method_name, $line_number)) {
continue;
}
// If pre_dispatch has auth check, this endpoint is covered
if ($pre_dispatch_has_auth) {
continue;
}
// Check if method body has auth check
$method_body = $this->extract_method_body($contents, $method_name);
if ($method_body && $this->body_has_auth_check($method_body)) {
continue;
}
// Violation found - no auth check
$code_snippet = "#[{$endpoint_type}]\npublic static function {$method_name}(...)";
$this->add_violation(
$file_path,
$line_number,
"Endpoint '{$method_name}' has no authentication check",
$code_snippet,
$this->build_suggestion($method_name, $class_name),
'high'
);
}
}
/**
* Check if pre_dispatch method has an auth check
*/
private function pre_dispatch_has_auth_check(string $contents, array $metadata): bool
{
$methods = $metadata['public_static_methods'] ?? [];
if (!isset($methods['pre_dispatch'])) {
return false;
}
$pre_dispatch_body = $this->extract_method_body($contents, 'pre_dispatch');
if (!$pre_dispatch_body) {
return false;
}
return $this->body_has_auth_check($pre_dispatch_body);
}
/**
* Check if a code body has an auth check pattern
*/
private function body_has_auth_check(string $body): bool
{
foreach (self::AUTH_CHECK_PATTERNS as $pattern) {
if (str_contains($body, $pattern)) {
return true;
}
}
return false;
}
/**
* Check if a method has @auth-exempt comment
*/
private function method_has_auth_exempt(string $contents, string $method_name, int $method_line): bool
{
$lines = explode("\n", $contents);
// Check the 10 lines before the method definition for @auth-exempt
$start_line = max(0, $method_line - 11);
$end_line = $method_line - 1;
for ($i = $start_line; $i <= $end_line && $i < count($lines); $i++) {
$line = $lines[$i];
if (str_contains($line, '@auth-exempt')) {
return true;
}
// Stop if we hit another method definition
if (preg_match('/^\s*public\s+static\s+function\s+/', $line) && !str_contains($line, $method_name)) {
break;
}
}
return false;
}
/**
* Extract method body from file contents
*/
private function extract_method_body(string $contents, string $method_name): ?string
{
// Pattern to match method definition
$pattern = '/public\s+static\s+function\s+' . preg_quote($method_name, '/') . '\s*\([^)]*\)[^{]*\{/s';
if (!preg_match($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
return null;
}
$start_pos = $matches[0][1] + strlen($matches[0][0]) - 1;
$brace_count = 1;
$pos = $start_pos + 1;
$length = strlen($contents);
while ($pos < $length && $brace_count > 0) {
$char = $contents[$pos];
if ($char === '{') {
$brace_count++;
} elseif ($char === '}') {
$brace_count--;
}
$pos++;
}
return substr($contents, $start_pos, $pos - $start_pos);
}
/**
* Build suggestion for fixing the violation
*/
private function build_suggestion(string $method_name, string $class_name): string
{
$suggestions = [];
$suggestions[] = "Endpoint '{$method_name}' needs an authentication check.";
$suggestions[] = "";
$suggestions[] = "Option 1: Add auth check to pre_dispatch() (recommended for all endpoints in controller):";
$suggestions[] = " public static function pre_dispatch(Request \$request, array \$params = [])";
$suggestions[] = " {";
$suggestions[] = " if (!Session::is_logged_in()) {";
$suggestions[] = " return response_unauthorized();";
$suggestions[] = " }";
$suggestions[] = " return null;";
$suggestions[] = " }";
$suggestions[] = "";
$suggestions[] = "Option 2: Add auth check at start of method body:";
$suggestions[] = " if (!Session::is_logged_in()) {";
$suggestions[] = " return response_unauthorized();";
$suggestions[] = " }";
$suggestions[] = "";
$suggestions[] = "Option 3: Mark as public endpoint with @auth-exempt comment:";
$suggestions[] = " /**";
$suggestions[] = " * @auth-exempt Public endpoint for webhook receivers";
$suggestions[] = " */";
$suggestions[] = " #[Ajax_Endpoint]";
$suggestions[] = " public static function {$method_name}(...)";
return implode("\n", $suggestions);
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* ModelFetchAuthCheckRule - Validates that model fetch() methods have auth checks
*
* When a model has a fetch() method with the #[Ajax_Endpoint_Model_Fetch] attribute,
* it becomes callable from JavaScript via Orm_Controller. The fetch() method is
* responsible for implementing its own authorization checks.
*
* This rule ensures that fetch() methods in Rsx_Model_Abstract subclasses
* contain auth check patterns when they have the Ajax_Endpoint_Model_Fetch attribute.
*
* Valid auth check patterns:
* - Session::is_logged_in()
* - Session::get_user()
* - Permission::has_permission()
* - Permission::has_role()
* - response_unauthorized()
* - ->site_id check (verifies ownership)
*
* Exemption:
* - Add @auth-exempt comment with reason for public data fetch
*/
class ModelFetchAuthCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'PHP-MODEL-FETCH-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Model Fetch Authentication Check';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Validates that model fetch() methods with #[Ajax_Endpoint_Model_Fetch] have authentication checks';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*_model.php', '*_Model.php'];
}
/**
* Whether this rule is called during manifest scan
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Only run during rsx:check
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'high';
}
/**
* Patterns that indicate an auth check is present
*/
private const AUTH_CHECK_PATTERNS = [
'Session::is_logged_in',
'Session::get_user',
'Session::get_user_id',
'Session::get_site_id',
'Permission::has_permission',
'Permission::has_role',
'response_unauthorized',
'->has_permission(',
'->has_role(',
'->site_id', // Checking ownership via site_id
'get_site_id()', // Session site check
];
/**
* Check a file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Read original file content (not sanitized) for comment checking
$original_contents = file_get_contents($file_path);
// Skip if file-level exception comment is present
if (strpos($original_contents, '@' . $this->get_id() . '-EXCEPTION') !== false) {
return;
}
// Only check model files (must extend Rsx_Model_Abstract)
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Rsx_Model_Abstract') {
return;
}
// Skip archived files
if (str_contains($file_path, '/archive/') || str_contains($file_path, '/archived/')) {
return;
}
// Get the class name
$class_name = $metadata['class'] ?? null;
if (!$class_name) {
return;
}
// Get public static methods from metadata
$methods = $metadata['public_static_methods'] ?? [];
// Check if fetch() method exists
if (!isset($methods['fetch'])) {
return;
}
$fetch_info = $methods['fetch'];
$attributes = $fetch_info['attributes'] ?? [];
// Check if fetch has Ajax_Endpoint_Model_Fetch attribute
$has_fetch_attribute = false;
foreach ($attributes as $attr_name => $attr_data) {
$short_name = basename(str_replace('\\', '/', $attr_name));
if ($short_name === 'Ajax_Endpoint_Model_Fetch') {
$has_fetch_attribute = true;
break;
}
}
// Skip if fetch doesn't have the attribute (not exposed via ORM)
if (!$has_fetch_attribute) {
return;
}
// Get line number for fetch method
$line_number = $fetch_info['line'] ?? 1;
// Check if method has @auth-exempt comment
if ($this->method_has_auth_exempt($original_contents, 'fetch', $line_number)) {
return;
}
// Check if method body has auth check
$method_body = $this->extract_method_body($contents, 'fetch');
if ($method_body && $this->body_has_auth_check($method_body)) {
return;
}
// Violation found - no auth check
$code_snippet = "#[Ajax_Endpoint_Model_Fetch]\npublic static function fetch(\$id)";
$this->add_violation(
$file_path,
$line_number,
"Model fetch() method has no authentication check",
$code_snippet,
$this->build_suggestion($class_name),
'high'
);
}
/**
* Check if a code body has an auth check pattern
*/
private function body_has_auth_check(string $body): bool
{
foreach (self::AUTH_CHECK_PATTERNS as $pattern) {
if (str_contains($body, $pattern)) {
return true;
}
}
return false;
}
/**
* Check if a method has @auth-exempt comment
*/
private function method_has_auth_exempt(string $contents, string $method_name, int $method_line): bool
{
$lines = explode("\n", $contents);
// Check the 10 lines before the method definition for @auth-exempt
$start_line = max(0, $method_line - 11);
$end_line = $method_line - 1;
for ($i = $start_line; $i <= $end_line && $i < count($lines); $i++) {
$line = $lines[$i];
if (str_contains($line, '@auth-exempt')) {
return true;
}
// Stop if we hit another method definition
if (preg_match('/^\s*public\s+static\s+function\s+/', $line) && !str_contains($line, $method_name)) {
break;
}
}
return false;
}
/**
* Extract method body from file contents
*/
private function extract_method_body(string $contents, string $method_name): ?string
{
// Pattern to match method definition
$pattern = '/public\s+static\s+function\s+' . preg_quote($method_name, '/') . '\s*\([^)]*\)[^{]*\{/s';
if (!preg_match($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
return null;
}
$start_pos = $matches[0][1] + strlen($matches[0][0]) - 1;
$brace_count = 1;
$pos = $start_pos + 1;
$length = strlen($contents);
while ($pos < $length && $brace_count > 0) {
$char = $contents[$pos];
if ($char === '{') {
$brace_count++;
} elseif ($char === '}') {
$brace_count--;
}
$pos++;
}
return substr($contents, $start_pos, $pos - $start_pos);
}
/**
* Build suggestion for fixing the violation
*/
private function build_suggestion(string $class_name): string
{
$suggestions = [];
$suggestions[] = "Model fetch() method needs an authentication/authorization check.";
$suggestions[] = "";
$suggestions[] = "Option 1: Check user is logged in and verify ownership:";
$suggestions[] = " #[Ajax_Endpoint_Model_Fetch]";
$suggestions[] = " public static function fetch(\$id)";
$suggestions[] = " {";
$suggestions[] = " if (!Session::is_logged_in()) {";
$suggestions[] = " return null; // or response_unauthorized()";
$suggestions[] = " }";
$suggestions[] = " \$record = static::find(\$id);";
$suggestions[] = " if (!\$record || \$record->site_id !== Session::get_site_id()) {";
$suggestions[] = " return null; // Wrong site or not found";
$suggestions[] = " }";
$suggestions[] = " return \$record;";
$suggestions[] = " }";
$suggestions[] = "";
$suggestions[] = "Option 2: If this is intentionally public data, add @auth-exempt:";
$suggestions[] = " /**";
$suggestions[] = " * @auth-exempt Public reference data (countries, etc.)";
$suggestions[] = " */";
$suggestions[] = " #[Ajax_Endpoint_Model_Fetch]";
$suggestions[] = " public static function fetch(\$id) { ... }";
return implode("\n", $suggestions);
}
}

View File

@@ -55,3 +55,56 @@ After RPC: Single Node.js process, reused across all sanitizations (~1-2s startu
### Parallel to JS Parser
This architecture mirrors the JS parser RPC server pattern. See `/app/RSpade/Core/JavaScript/CLAUDE.md` for detailed RPC pattern documentation.
## Js_CodeQuality_Rpc - RPC Server Architecture
### Overview
JavaScript linting and this-usage analysis use a long-running Node.js RPC server via Unix socket to avoid spawning thousands of Node processes during code quality checks.
### Components
- `Js_CodeQuality_Rpc.php` - PHP client, manages server lifecycle
- `js-code-quality-server.js` - Node.js RPC server, processes lint and this-usage analysis requests
### Server Lifecycle
1. **Lazy start:** Server spawns on first lint or analyze_this call during code quality checks
2. **Startup:** Checks for stale socket, force-kills if found, starts fresh server
3. **Wait:** Polls socket with ping (50ms intervals, 10s max), fatal error if timeout
4. **Usage:** All JS linting and this-usage analysis goes through RPC
5. **Shutdown:** Graceful shutdown when code quality checks complete (registered shutdown handler)
### Socket
- **Path:** `storage/rsx-tmp/js-code-quality-server.sock`
- **Protocol:** Line-delimited JSON over Unix domain socket
### RPC Methods
- `ping``"pong"` - Health check
- `lint``{file: {status, error}, ...}` - Check JavaScript syntax using Babel parser
- `analyze_this``{file: {status, violations}, ...}` - Analyze 'this' usage patterns using Acorn
- `shutdown` → Graceful server termination
### PHP API
```php
Js_CodeQuality_Rpc::lint($file_path); // Returns error array or null
Js_CodeQuality_Rpc::analyze_this($file_path); // Returns violations array
Js_CodeQuality_Rpc::start_rpc_server(); // Lazy init, auto-called
Js_CodeQuality_Rpc::stop_rpc_server($force); // Clean shutdown
```
### Force Parameter
`stop_rpc_server($force = false)`:
- `false` (default): Send shutdown command, return immediately
- `true`: Send shutdown + wait + SIGTERM if needed (used for stale server cleanup)
### Cache Integration
Both lint and analyze_this have their own caching layers:
- **Lint cache:** Flag files in `storage/rsx-tmp/cache/js-lint-passed/` (mtime-based)
- **This-usage cache:** JSON files in `storage/rsx-tmp/cache/code-quality/js-this/` (mtime-based)
Cache is checked before RPC call - only files with stale cache are sent to the server.
### Error Handling
Server failure → fatal error for lint, silent failure for analyze_this.
### Performance Impact
Before RPC: Thousands of Node.js process spawns during rsx:check (~20+ seconds on first run)
After RPC: Single Node.js process, reused across all operations (~1-2s startup overhead)

View File

@@ -28,7 +28,6 @@ class InitializationSuggestions
} elseif ($is_user_code) {
return "Use ES6 class lifecycle methods:\n" .
" - on_app_ready() - For final initialization (most common)\n" .
" - on_jqhtml_ready() - After all JQHTML components loaded\n" .
" - on_app_init() - For app-level setup\n" .
" - on_modules_init() - For module initialization\n" .
" - on_modules_define() - For module metadata registration\n" .
@@ -61,7 +60,6 @@ class InitializationSuggestions
{
return "Use user code lifecycle methods instead:\n" .
" - on_app_ready() - For final initialization (most common)\n" .
" - on_jqhtml_ready() - After all JQHTML components loaded\n" .
" - on_app_init() - For app-level setup\n" .
" - on_modules_init() - For module initialization\n" .
" - on_modules_define() - For module metadata registration";

View File

@@ -0,0 +1,352 @@
<?php
namespace App\RSpade\CodeQuality\Support;
use Symfony\Component\Process\Process;
/**
* JavaScript Code Quality RPC Client
*
* Manages a persistent Node.js RPC server for JavaScript linting and
* this-usage analysis. This avoids spawning thousands of Node processes
* during code quality checks.
*
* RPC Methods:
* - lint: Check JavaScript syntax using Babel parser
* - analyze_this: Analyze 'this' usage patterns using Acorn
*/
class Js_CodeQuality_Rpc
{
/**
* Node.js RPC server script path
*/
protected const RPC_SERVER_SCRIPT = 'app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js';
/**
* Unix socket path for RPC server
*/
protected const RPC_SOCKET = 'storage/rsx-tmp/js-code-quality-server.sock';
/**
* RPC server process
*/
protected static $rpc_server_process = null;
/**
* Request ID counter
*/
protected static $request_id = 0;
/**
* Lint a JavaScript file for syntax errors
*
* @param string $file_path Path to the JavaScript file
* @return array|null Error info array or null if no errors
*/
public static function lint(string $file_path): ?array
{
// Start RPC server on first use (lazy initialization)
if (static::$rpc_server_process === null) {
static::start_rpc_server();
}
return static::_lint_via_rpc($file_path);
}
/**
* Analyze a JavaScript file for 'this' usage violations
*
* @param string $file_path Path to the JavaScript file
* @return array Violations array (may be empty)
*/
public static function analyze_this(string $file_path): array
{
// Start RPC server on first use (lazy initialization)
if (static::$rpc_server_process === null) {
static::start_rpc_server();
}
return static::_analyze_this_via_rpc($file_path);
}
/**
* Lint via RPC server
*/
protected static function _lint_via_rpc(string $file_path): ?array
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
$socket_path = $base_path . '/' . self::RPC_SOCKET;
try {
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
if (!$sock) {
throw new \RuntimeException("Failed to connect to RPC server: {$errstr}");
}
// Set blocking mode for reliable reads
stream_set_blocking($sock, true);
// Send lint request
$request = [
'id' => ++static::$request_id,
'method' => 'lint',
'files' => [$file_path]
];
fwrite($sock, json_encode($request) . "\n");
// Read response
$response = fgets($sock);
fclose($sock);
if (!$response) {
throw new \RuntimeException("No response from RPC server");
}
$data = json_decode($response, true);
if (!$data || !is_array($data)) {
throw new \RuntimeException("Invalid JSON response from RPC server");
}
if (isset($data['error'])) {
throw new \RuntimeException("RPC error: " . $data['error']);
}
if (!isset($data['results'][$file_path])) {
throw new \RuntimeException("No result for file in RPC response");
}
$result = $data['results'][$file_path];
if ($result['status'] === 'success') {
// Return the error info if present, null if no errors
return $result['error'];
}
// Handle error response
if ($result['status'] === 'error' && isset($result['error'])) {
throw new \RuntimeException("Lint error: " . ($result['error']['message'] ?? 'Unknown error'));
}
return null;
} catch (\Exception $e) {
throw new \RuntimeException(
"JavaScript lint RPC error for {$file_path}: " . $e->getMessage()
);
}
}
/**
* Analyze this-usage via RPC server
*/
protected static function _analyze_this_via_rpc(string $file_path): array
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
$socket_path = $base_path . '/' . self::RPC_SOCKET;
try {
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
if (!$sock) {
throw new \RuntimeException("Failed to connect to RPC server: {$errstr}");
}
// Set blocking mode for reliable reads
stream_set_blocking($sock, true);
// Send analyze_this request
$request = [
'id' => ++static::$request_id,
'method' => 'analyze_this',
'files' => [$file_path]
];
fwrite($sock, json_encode($request) . "\n");
// Read response
$response = fgets($sock);
fclose($sock);
if (!$response) {
throw new \RuntimeException("No response from RPC server");
}
$data = json_decode($response, true);
if (!$data || !is_array($data)) {
throw new \RuntimeException("Invalid JSON response from RPC server");
}
if (isset($data['error'])) {
throw new \RuntimeException("RPC error: " . $data['error']);
}
if (!isset($data['results'][$file_path])) {
throw new \RuntimeException("No result for file in RPC response");
}
$result = $data['results'][$file_path];
if ($result['status'] === 'success') {
return $result['violations'] ?? [];
}
// Handle error response - return empty violations, don't fail
return [];
} catch (\Exception $e) {
// Log error but don't fail the check
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', "RPC error for {$file_path}: " . $e->getMessage());
}
return [];
}
}
/**
* Start the RPC server
*/
public static function start_rpc_server(): void
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
$socket_path = $base_path . '/' . self::RPC_SOCKET;
if (file_exists($socket_path)) {
// Server might be running, force stop it
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', 'Found existing socket, forcing shutdown');
}
static::stop_rpc_server(force: true);
}
// Start new server
$server_script = $base_path . '/' . self::RPC_SERVER_SCRIPT;
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', 'Starting RPC server: ' . $server_script);
}
$process = new Process([
'node',
$server_script,
'--socket=' . $socket_path
]);
$process->start();
static::$rpc_server_process = $process;
// Register shutdown handler
register_shutdown_function([self::class, 'stop_rpc_server']);
// Wait for server to be ready (ping/pong up to 10 seconds)
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', 'Waiting for RPC server to be ready...');
}
$max_attempts = 200; // 10 seconds (50ms * 200)
$ready = false;
for ($i = 0; $i < $max_attempts; $i++) {
usleep(50000); // 50ms
if (file_exists($socket_path)) {
// Try to ping
if (static::ping_rpc_server()) {
$ready = true;
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', 'RPC server ready after ' . ($i * 50) . 'ms');
}
break;
}
}
}
if (!$ready) {
static::stop_rpc_server();
throw new \RuntimeException('Failed to start JS Code Quality RPC server - timeout after 10 seconds');
}
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', 'RPC server started successfully');
}
}
/**
* Ping the RPC server
*/
protected static function ping_rpc_server(): bool
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
$socket_path = $base_path . '/' . self::RPC_SOCKET;
try {
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
if (!$sock) {
return false;
}
// Set blocking mode for reliable reads
stream_set_blocking($sock, true);
// Send ping
fwrite($sock, json_encode(['id' => ++static::$request_id, 'method' => 'ping']) . "\n");
// Read response
$response = fgets($sock);
fclose($sock);
if (!$response) {
return false;
}
$data = json_decode($response, true);
return isset($data['result']) && $data['result'] === 'pong';
} catch (\Exception $e) {
return false;
}
}
/**
* Stop the RPC server
*/
public static function stop_rpc_server(bool $force = false): void
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html/system';
$socket_path = $base_path . '/' . self::RPC_SOCKET;
// Try graceful shutdown
if (file_exists($socket_path)) {
try {
$sock = @stream_socket_client("unix://{$socket_path}", $errno, $errstr, 0.5);
if ($sock) {
fwrite($sock, json_encode(['id' => 0, 'method' => 'shutdown']) . "\n");
fclose($sock);
}
} catch (\Exception $e) {
// Ignore errors
}
}
// Only wait and force kill if $force = true
if ($force && static::$rpc_server_process && static::$rpc_server_process->isRunning()) {
if (function_exists('console_debug')) {
console_debug('JS_CODE_QUALITY', 'Force stopping RPC server');
}
// Wait for graceful shutdown
sleep(1);
// Force kill if still running
if (static::$rpc_server_process->isRunning()) {
static::$rpc_server_process->stop(3, SIGTERM);
}
}
// Clean up socket file
if (file_exists($socket_path)) {
@unlink($socket_path);
}
}
}

View File

@@ -0,0 +1,570 @@
#!/usr/bin/env node
/**
* JavaScript Code Quality RPC Server
*
* Combines JavaScript linting (Babel parser) and this-usage analysis (Acorn)
* into a single persistent server to avoid spawning thousands of Node processes.
*
* Usage:
* Server mode: node js-code-quality-server.js --socket=/path/to/socket
*
* RPC Methods:
* - ping: Health check
* - lint: Check JavaScript syntax using Babel parser
* - analyze_this: Analyze 'this' usage patterns using Acorn
* - shutdown: Graceful server termination
*
* @FILENAME-CONVENTION-EXCEPTION - Node.js RPC server script
*/
const fs = require('fs');
const path = require('path');
const net = require('net');
// Resolve to system/node_modules since that's where packages are installed
const systemDir = path.resolve(__dirname, '../../../../..');
const babelParser = require(path.join(systemDir, 'node_modules', '@babel', 'parser'));
const acorn = require(path.join(systemDir, 'node_modules', 'acorn'));
const walk = require(path.join(systemDir, 'node_modules', 'acorn-walk'));
// Parse command line arguments
let socketPath = null;
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg.startsWith('--socket=')) {
socketPath = arg.substring('--socket='.length);
}
}
if (!socketPath) {
console.error('Usage: node js-code-quality-server.js --socket=/path/to/socket');
process.exit(1);
}
// =============================================================================
// LINTING LOGIC (Babel Parser)
// =============================================================================
/**
* Lint JavaScript file for syntax errors using Babel parser
* @param {string} content - File content
* @param {string} filePath - File path for error reporting
* @returns {object|null} Error object or null if no errors
*/
function lintFile(content, filePath) {
try {
babelParser.parse(content, {
sourceType: 'module',
plugins: [
'decorators-legacy',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
'optionalChaining',
'nullishCoalescingOperator',
'asyncGenerators',
'bigInt',
'dynamicImport',
'exportDefaultFrom',
'exportNamespaceFrom',
'objectRestSpread',
'topLevelAwait'
]
});
// No syntax errors
return null;
} catch (error) {
return {
message: error.message,
line: error.loc ? error.loc.line : null,
column: error.loc ? error.loc.column : null
};
}
}
// =============================================================================
// THIS-USAGE ANALYSIS LOGIC (Acorn Parser)
// =============================================================================
// Known jQuery callback methods - used for better remediation messages
const JQUERY_CALLBACKS = new Set([
'click', 'dblclick', 'mouseenter', 'mouseleave', 'mousedown', 'mouseup',
'mousemove', 'mouseover', 'mouseout', 'change', 'submit', 'focus', 'blur',
'keydown', 'keyup', 'keypress', 'resize', 'scroll', 'select', 'load',
'on', 'off', 'one', 'each', 'map', 'filter',
'fadeIn', 'fadeOut', 'slideDown', 'slideUp', 'animate',
'done', 'fail', 'always', 'then', 'ready', 'hover'
]);
/**
* Analyze JavaScript file for 'this' usage violations
* @param {string} content - File content
* @param {string} filePath - File path for error reporting
* @returns {object} Result with violations array or error
*/
function analyzeThisUsage(content, filePath) {
const lines = content.split('\n');
let ast;
try {
ast = acorn.parse(content, {
ecmaVersion: 2020,
sourceType: 'module',
locations: true
});
} catch (e) {
// Parse error - return empty violations
return { violations: [], error: `Parse error: ${e.message}` };
}
const violations = [];
const classInfo = new Map();
// First pass: identify all classes and their types
walk.simple(ast, {
ClassDeclaration(node) {
const hasStaticInit = node.body.body.some(member =>
member.static && member.key?.name === 'init'
);
classInfo.set(node.id.name, {
isStatic: hasStaticInit
});
}
});
// Helper to check if first line of function has valid pattern
function checkFirstLinePattern(funcNode) {
if (!funcNode.body || !funcNode.body.body || funcNode.body.body.length === 0) {
return null;
}
let checkIndex = 0;
const firstStmt = funcNode.body.body[0];
// Check if first statement is e.preventDefault() or similar
if (firstStmt.type === 'ExpressionStatement' &&
firstStmt.expression?.type === 'CallExpression' &&
firstStmt.expression?.callee?.type === 'MemberExpression' &&
firstStmt.expression?.callee?.property?.name === 'preventDefault') {
checkIndex = 1;
if (funcNode.body.body.length <= 1) {
return null;
}
}
const targetStmt = funcNode.body.body[checkIndex];
if (targetStmt.type !== 'VariableDeclaration') {
return null;
}
const firstDecl = targetStmt.declarations[0];
if (!firstDecl || !firstDecl.init) {
return null;
}
const varKind = targetStmt.kind;
// Check for 'that = this' pattern
if (firstDecl.id.name === 'that' &&
firstDecl.init.type === 'ThisExpression') {
if (varKind !== 'const') {
return 'that-pattern-wrong-kind';
}
return 'that-pattern';
}
// Check for 'CurrentClass = this' pattern
if (firstDecl.id.name === 'CurrentClass' &&
firstDecl.init.type === 'ThisExpression') {
if (varKind !== 'const') {
return 'currentclass-pattern-wrong-kind';
}
return 'currentclass-pattern';
}
// Check for '$var = $(this)' pattern
if (firstDecl.id.name.startsWith('$') &&
firstDecl.init.type === 'CallExpression' &&
firstDecl.init.callee.name === '$' &&
firstDecl.init.arguments.length === 1 &&
firstDecl.init.arguments[0].type === 'ThisExpression') {
if (varKind !== 'const') {
return 'jquery-pattern-wrong-kind';
}
return 'jquery-pattern';
}
return null;
}
// Helper to detect if we're in a jQuery callback
function isLikelyJQueryCallback(ancestors) {
for (let i = ancestors.length - 1; i >= 0; i--) {
const node = ancestors[i];
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
const methodName = node.callee.property.name;
if (JQUERY_CALLBACKS.has(methodName)) {
return true;
}
}
}
return false;
}
// Walk the AST looking for 'this' usage
walk.ancestor(ast, {
ThisExpression(node, ancestors) {
// Skip arrow functions - they inherit 'this'
for (const ancestor of ancestors) {
if (ancestor.type === 'ArrowFunctionExpression') {
return;
}
}
// Find containing function and class
let containingFunc = null;
let containingClass = null;
let isAnonymousFunc = false;
let isStaticMethod = false;
let isConstructor = false;
let isInstanceMethod = false;
let hasMethodDefinition = false;
// First pass: check if we're in a MethodDefinition
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (ancestor.type === 'MethodDefinition') {
hasMethodDefinition = true;
isStaticMethod = ancestor.static;
isConstructor = ancestor.kind === 'constructor';
isInstanceMethod = !ancestor.static && ancestor.kind !== 'constructor';
break;
}
}
// Second pass: find function and class
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (!containingFunc && (
ancestor.type === 'FunctionExpression' ||
ancestor.type === 'FunctionDeclaration'
)) {
containingFunc = ancestor;
isAnonymousFunc = ancestor.type === 'FunctionExpression' && !hasMethodDefinition;
}
if (!containingClass && (
ancestor.type === 'ClassDeclaration' ||
ancestor.type === 'ClassExpression'
)) {
containingClass = ancestor;
}
}
if (!containingFunc) {
return;
}
// Skip constructors and instance methods
if (isConstructor || isInstanceMethod) {
return;
}
// Check if this is part of the allowed first-line pattern
const parent = ancestors[ancestors.length - 2];
const firstStmt = containingFunc.body?.body?.[0];
let checkIndex = 0;
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.type === 'CallExpression' &&
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
if (hasPreventDefault) {
checkIndex = 1;
}
const targetStmt = containingFunc.body?.body?.[checkIndex];
const isTargetConst = targetStmt?.type === 'VariableDeclaration' && targetStmt?.kind === 'const';
// Check if this 'this' is inside $(this) on the first or second line
if (parent && parent.type === 'CallExpression' && parent.callee?.name === '$') {
if (isTargetConst &&
targetStmt?.declarations?.[0]?.init === parent &&
targetStmt?.declarations?.[0]?.id?.name?.startsWith('$')) {
return;
}
}
// Check if this 'this' is the 'const that = this' in correct position
if (parent && parent.type === 'VariableDeclarator' &&
parent.id?.name === 'that' &&
isTargetConst &&
targetStmt?.declarations?.[0]?.id?.name === 'that') {
return;
}
// Check if this 'this' is the 'const CurrentClass = this' in correct position
if (parent && parent.type === 'VariableDeclarator' &&
parent.id?.name === 'CurrentClass' &&
isTargetConst &&
targetStmt?.declarations?.[0]?.id?.name === 'CurrentClass') {
return;
}
// Check what pattern is used
const pattern = checkFirstLinePattern(containingFunc);
// Determine the violation and remediation
let message = '';
let remediation = '';
const lineNum = node.loc.start.line;
const codeSnippet = lines[lineNum - 1].trim();
const className = containingClass?.id?.name || 'unknown';
const isJQueryContext = isLikelyJQueryCallback(ancestors);
if (isAnonymousFunc) {
if (!pattern) {
const firstStmt = containingFunc.body?.body?.[0];
const hasPreventDefault = firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.type === 'CallExpression' &&
firstStmt?.expression?.callee?.type === 'MemberExpression' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault';
if (isJQueryContext) {
message = `'this' in jQuery callback should be aliased for clarity.`;
if (hasPreventDefault) {
remediation = `Add 'const $element = $(this);' as the second line (after preventDefault), then use $element instead of 'this'.`;
} else {
remediation = `Add 'const $element = $(this);' as the first line of this function, then use $element instead of 'this'.`;
}
} else {
message = `Ambiguous 'this' usage in anonymous function.`;
if (hasPreventDefault) {
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as the second line (after preventDefault).\n` +
`If this is an instance context: Add 'const that = this;' as the second line.\n` +
`Then use the aliased variable instead of 'this'.`;
} else {
remediation = `If this is a jQuery callback: Add 'const $element = $(this);' as first line.\n` +
`If this is an instance context: Add 'const that = this;' as first line.\n` +
`Then use the aliased variable instead of 'this'.`;
}
}
} else if (pattern === 'that-pattern' || pattern === 'jquery-pattern') {
message = `'this' used after aliasing. Use the aliased variable instead.`;
let varDeclIndex = 0;
const firstStmt = containingFunc.body?.body?.[0];
if (firstStmt?.type === 'ExpressionStatement' &&
firstStmt?.expression?.callee?.property?.name === 'preventDefault') {
varDeclIndex = 1;
}
const varName = containingFunc.body?.body?.[varDeclIndex]?.declarations?.[0]?.id?.name;
remediation = pattern === 'jquery-pattern'
? `You already have 'const ${varName} = $(this)'. Use that variable instead of 'this'.`
: `You already have 'const that = this'. Use 'that' instead of 'this'.`;
} else if (pattern === 'that-pattern-wrong-kind') {
message = `Instance alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const that = this;' - the instance reference should never be reassigned.`;
} else if (pattern === 'jquery-pattern-wrong-kind') {
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
}
} else if (isStaticMethod) {
if (!pattern) {
message = `Static method in '${className}' should not use naked 'this'.`;
remediation = `Static methods have two options:\n` +
`1. If you need the exact class (no polymorphism): Replace 'this' with '${className}'\n` +
`2. If you need polymorphism (inherited classes): Add 'const CurrentClass = this;' as first line\n` +
` Then use CurrentClass.name or CurrentClass.property for polymorphic access.\n` +
`Consider: Does this.name need to work for child classes? If yes, use CurrentClass pattern.`;
} else if (pattern === 'currentclass-pattern') {
message = `'this' used after aliasing to CurrentClass. Use 'CurrentClass' instead.`;
remediation = `You already have 'const CurrentClass = this;'. Use 'CurrentClass' for all access, not naked 'this'.`;
} else if (pattern === 'currentclass-pattern-wrong-kind') {
message = `CurrentClass pattern must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const CurrentClass = this;' - the CurrentClass reference should never be reassigned.`;
} else if (pattern === 'jquery-pattern') {
return;
} else if (pattern === 'jquery-pattern-wrong-kind') {
message = `jQuery element alias must use 'const', not 'let' or 'var'.`;
remediation = `Change to 'const $element = $(this);' - jQuery element references should never be reassigned.`;
}
if (isAnonymousFunc && !pattern) {
remediation += `\nException: If this is a jQuery callback, add 'const $element = $(this);' as the first line.`;
}
}
if (message) {
violations.push({
line: lineNum,
message: message,
codeSnippet: codeSnippet,
remediation: remediation
});
}
}
});
return { violations: violations };
}
// =============================================================================
// RPC SERVER
// =============================================================================
// Remove socket if exists
if (fs.existsSync(socketPath)) {
fs.unlinkSync(socketPath);
}
function handleRequest(data) {
try {
const request = JSON.parse(data);
switch (request.method) {
case 'ping':
return JSON.stringify({
id: request.id,
result: 'pong'
}) + '\n';
case 'lint':
const lintResults = {};
for (const file of request.files) {
try {
const content = fs.readFileSync(file, 'utf8');
const error = lintFile(content, file);
lintResults[file] = {
status: 'success',
error: error
};
} catch (error) {
lintResults[file] = {
status: 'error',
error: {
type: 'FileReadError',
message: error.message
}
};
}
}
return JSON.stringify({
id: request.id,
results: lintResults
}) + '\n';
case 'analyze_this':
const thisResults = {};
for (const file of request.files) {
try {
const content = fs.readFileSync(file, 'utf8');
const result = analyzeThisUsage(content, file);
thisResults[file] = {
status: 'success',
violations: result.violations,
error: result.error || null
};
} catch (error) {
thisResults[file] = {
status: 'error',
error: {
type: 'FileReadError',
message: error.message
}
};
}
}
return JSON.stringify({
id: request.id,
results: thisResults
}) + '\n';
case 'shutdown':
return JSON.stringify({
id: request.id,
result: 'shutting down'
}) + '\n';
default:
return JSON.stringify({
id: request.id,
error: 'Unknown method: ' + request.method
}) + '\n';
}
} catch (error) {
return JSON.stringify({
error: 'Invalid JSON request: ' + error.message
}) + '\n';
}
}
const server = net.createServer((socket) => {
let buffer = '';
socket.on('data', (data) => {
buffer += data.toString();
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.substring(0, newlineIndex);
buffer = buffer.substring(newlineIndex + 1);
if (line.trim()) {
const response = handleRequest(line);
socket.write(response);
try {
const request = JSON.parse(line);
if (request.method === 'shutdown') {
socket.end();
server.close(() => {
if (fs.existsSync(socketPath)) {
fs.unlinkSync(socketPath);
}
process.exit(0);
});
}
} catch (e) {
// Ignore
}
}
}
});
socket.on('error', (err) => {
console.error('Socket error:', err);
});
});
server.listen(socketPath, () => {
console.log('JS Code Quality RPC server listening on ' + socketPath);
});
server.on('error', (err) => {
console.error('Server error:', err);
process.exit(1);
});
process.on('SIGTERM', () => {
server.close(() => {
if (fs.existsSync(socketPath)) {
fs.unlinkSync(socketPath);
}
process.exit(0);
});
});
process.on('SIGINT', () => {
server.close(() => {
if (fs.existsSync(socketPath)) {
fs.unlinkSync(socketPath);
}
process.exit(0);
});
});

View File

@@ -49,12 +49,14 @@ class Bundle_Compile_Command extends Command
$bundle_arg = $this->argument('bundle');
// Get all bundle classes from manifest
// Get all MODULE bundle classes from manifest (not asset bundles)
// Module bundles are the top-level page bundles that get compiled
// Asset bundles are dependency declarations auto-discovered during compilation
$manifest_data = Manifest::get_all();
$bundle_classes = [];
foreach ($manifest_data as $file_info) {
if (isset($file_info['extends']) && $file_info['extends'] === 'Rsx_Bundle_Abstract') {
if (isset($file_info['extends']) && $file_info['extends'] === 'Rsx_Module_Bundle_Abstract') {
$fqcn = $file_info['fqcn'] ?? $file_info['class'] ?? null;
if ($fqcn) {
$bundle_classes[$fqcn] = $file_info['class'] ?? $fqcn;

View File

@@ -31,7 +31,7 @@ use App\RSpade\Core\Debug\Debugger;
* KEY FEATURES:
* - Backdoor authentication: Use --user-id to bypass login and test as any user
* - Plain text error output: Errors returned as plain text with stack traces
* - Console capture: JavaScript errors and logs captured (--console-log for all)
* - Console capture: JavaScript errors and logs captured (--console for all)
* - XHR/fetch tracking: Monitor API calls with --xhr-dump or --xhr-list
* - Element verification: Check DOM elements with --expect-element
* - HTML extraction: Get element HTML with --dump-element
@@ -103,7 +103,7 @@ use App\RSpade\Core\Debug\Debugger;
* - Redirect chain (if --follow-redirects used)
* - Response headers (if --headers used)
* - Console errors (always shown if present)
* - Console logs (if --console-log used)
* - Console logs (if --console used)
* - XHR/fetch requests (if --xhr-dump or --xhr-list used)
* - Input elements (if --input-elements used)
* - Cookies (if --cookies used)
@@ -133,7 +133,8 @@ class Route_Debug_Command extends Command
{--no-body : Suppress HTTP response body (show headers/status only)}
{--follow-redirects : Follow HTTP redirects and show full redirect chain}
{--headers : Display all HTTP response headers}
{--console-log : Display all browser console output (not just errors) and console_debug() output even if SHOW_CONSOLE_DEBUG_HTTP=false}
{--console : Display all browser console output (not just errors) and console_debug() output even if SHOW_CONSOLE_DEBUG_HTTP=false}
{--console-log : Alias for --console}
{--xhr-dump : Capture full details of XHR/fetch requests (URL, headers, body, response)}
{--input-elements : List all form input elements with values and attributes}
{--post= : Send POST request with JSON data (e.g., --post=\'{"key":"value"}\')}
@@ -189,7 +190,8 @@ class Route_Debug_Command extends Command
// Check if console_debug is disabled globally and user didn't override
$console_debug_enabled = config('rsx.console_debug.enabled', false) || env('CONSOLE_DEBUG_ENABLED') === 'true';
$console_debug_override = $this->option('console-log') ||
$console_debug_override = $this->option('console') ||
$this->option('console-log') ||
$this->option('console-list') ||
$this->option('console-debug-all') ||
$this->option('console-debug-filter') ||
@@ -224,8 +226,8 @@ class Route_Debug_Command extends Command
// Get headers flag
$headers = $this->option('headers');
// Get console-log flag
$console_log = $this->option('console-log');
// Get console flag (--console or --console-log alias)
$console_log = $this->option('console') || $this->option('console-log');
// Get xhr-dump flag
$xhr_dump = $this->option('xhr-dump');
@@ -539,8 +541,8 @@ class Route_Debug_Command extends Command
$this->line('');
$this->comment('DEBUGGING OUTPUT:');
$this->line(' php artisan rsx:debug / --console-log # All console output');
$this->line(' php artisan rsx:debug / --console-list # Alias for --console-log');
$this->line(' php artisan rsx:debug / --console # All console output');
$this->line(' php artisan rsx:debug / --console-log # Alias for --console');
$this->line(' php artisan rsx:debug / --console-debug-filter=AUTH # Filter console_debug');
$this->line(' php artisan rsx:debug / --console-debug-all # Show all console_debug channels');
$this->line(' php artisan rsx:debug / --console-debug-benchmark # With timing');

View File

@@ -1,279 +0,0 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
use Illuminate\Support\Facades\Redis;
use App\RSpade\Core\Bootstrap\ManifestKernel;
/**
* RSX SSR Full Page Cache (FPC) Create Command
* ==============================================
*
* PURPOSE:
* Generates static, pre-rendered HTML cache for routes marked with #[Static_Page] attribute.
* Uses Playwright to render pages in headless Chrome, capturing the fully-rendered DOM state
* for optimal SEO and performance.
*
* HOW IT WORKS:
* 1. Launches headless Chromium browser via Playwright
* 2. Navigates to the specified route with FPC generation headers
* 3. Waits for _debug_ready event (all components initialized)
* 4. Waits for network idle + 10ms buffer
* 5. Captures full DOM or redirect response
* 6. Generates ETag from build_key + URL + content hash
* 7. Stores in Redis: ssr_fpc:{build_key}:{url_hash}
*
* KEY FEATURES:
* - Exclusive lock (GENERATE_STATIC_CACHE) prevents concurrent generation
* - Strips GET parameters from URLs (static pages ignore query strings)
* - Handles redirect responses (caches 302 location without following)
* - Build key integration (auto-invalidates cache on deployment)
* - Comprehensive error logging to storage/logs/ssr-fpc-errors.log
*
* DESIGN GOALS:
* - SEO-optimized static pages for unauthenticated users
* - Fail-loud approach (fatal exception if generation fails)
* - Cache key includes build_key for automatic invalidation
* - Development only tool (production uses pre-generated cache)
*
* USAGE EXAMPLES:
* php artisan rsx:ssr_fpc:create / # Cache homepage
* php artisan rsx:ssr_fpc:create /about # Cache about page
* php artisan rsx:ssr_fpc:create /products/view/123 # Cache dynamic route
*
* FUTURE ROADMAP:
* - Support for --from-sitemap to generate all pages from sitemap.xml
* - Shared private key for FPC client authentication
* - External service for distributed cache generation
* - Parallelization for faster multi-page generation
* - Programmatic cache reset hooks (CMS updates, blog posts)
*
* IMPLEMENTATION DETAILS:
* - Uses X-RSpade-FPC-Client header to identify cache generation requests
* - Playwright script located in resource/playwright/generate-static-cache.js
* - Cache stored as JSON: {url, code, page_dom/redirect, build_key, etag, generated_at}
* - ETag is first 30 chars of SHA1(build_key + url + content)
* - Redis key format: ssr_fpc:{build_key}:{sha1(url)}
*
* SECURITY:
* - Only available in local/development/testing environments
* - Throws fatal error if attempted in production
* - FPC bypass header prevents cache serving during generation
*/
class Ssr_Fpc_Create_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:ssr_fpc:create
{url : The URL to generate static cache for (e.g., /about, /products)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate static page cache for SSR FPC system (development only)';
/**
* Execute the console command.
*/
public function handle()
{
// Check environment - throw fatal error in production
if (app()->environment('production')) {
throw new \RuntimeException('FATAL: rsx:ssr_fpc:create command is not available in production environment. This is a development-only tool.');
}
// Check if SSR FPC is enabled
if (!config('rsx.ssr_fpc.enabled', false)) {
$this->error('SSR FPC is disabled. Enable it in config/rsx.php or set SSR_FPC_ENABLED=true in .env');
return 1;
}
// Get the URL to generate cache for
$url = $this->argument('url');
// Strip query parameters from URL (static pages ignore query strings)
$url = parse_url($url, PHP_URL_PATH) ?: $url;
// Ensure URL starts with /
if (!str_starts_with($url, '/')) {
$url = '/' . $url;
}
$this->info("Generating static cache for: {$url}");
// Check if Playwright script exists
$playwright_script = base_path('app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js');
if (!file_exists($playwright_script)) {
$this->error("❌ Playwright script not found: {$playwright_script}");
$this->error('Please create the script or check your installation');
return 1;
}
// Check if node/npm is available
$node_check = new Process(['node', '--version']);
$node_check->run();
if (!$node_check->isSuccessful()) {
$this->error('❌ Node.js is not installed or not in PATH');
return 1;
}
// Check if playwright is installed
$playwright_check = new Process(['node', '-e', "require('playwright')"], base_path());
$playwright_check->run();
if (!$playwright_check->isSuccessful()) {
$this->warn('⚠️ Playwright not installed. Installing now...');
$npm_install = new Process(['npm', 'install', 'playwright'], base_path());
$npm_install->run(function ($type, $buffer) {
echo $buffer;
});
if (!$npm_install->isSuccessful()) {
$this->error('❌ Failed to install Playwright');
return 1;
}
$this->info('✅ Playwright installed');
$this->info('');
}
// Check if chromium browser is installed and up to date
$browser_check_script = "const {chromium} = require('playwright'); chromium.launch({headless:true}).then(b => {b.close(); process.exit(0);}).catch(e => {console.error(e.message); process.exit(1);});";
$browser_check = new Process(['node', '-e', $browser_check_script], base_path(), $_ENV, null, 10);
$browser_check->run();
if (!$browser_check->isSuccessful()) {
$error_output = $browser_check->getErrorOutput() . $browser_check->getOutput();
// Check if it's a browser not installed or out of date error
if (str_contains($error_output, "Executable doesn't exist") ||
str_contains($error_output, "browserType.launch") ||
str_contains($error_output, "Playwright was just installed or updated")) {
$this->info('Installing/updating Chromium browser...');
$browser_install = new Process(['npx', 'playwright', 'install', 'chromium'], base_path());
$browser_install->setTimeout(300); // 5 minute timeout for download
$browser_install->run(function ($type, $buffer) {
// Silent - downloads can be verbose
});
if (!$browser_install->isSuccessful()) {
$this->error('❌ Failed to install Chromium browser');
$this->error('Run manually: npx playwright install chromium');
return 1;
}
$this->info('✅ Chromium browser installed/updated');
$this->info('');
} else {
$this->error('❌ Browser check failed: ' . trim($error_output));
return 1;
}
}
// Get timeout from config
$timeout = config('rsx.ssr_fpc.generation_timeout', 30000);
// Build command arguments
$command_args = ['node', $playwright_script, $url, "--timeout={$timeout}"];
$env = array_merge($_ENV, [
'BASE_URL' => config('app.url')
]);
// Convert timeout from milliseconds to seconds for Process timeout
// Add 10 seconds buffer to the Process timeout to allow Playwright to timeout first
$process_timeout = ($timeout / 1000) + 10;
// Release the application lock before running Playwright to prevent lock contention
// The artisan command holds a WRITE lock which would block the web request's READ lock
\App\RSpade\Core\Bootstrap\RsxBootstrap::temporarily_release_lock();
$process = new Process(
$command_args,
base_path(),
$env,
null,
$process_timeout
);
$output = '';
$process->run(function ($type, $buffer) use (&$output) {
echo $buffer;
$output .= $buffer;
});
if (!$process->isSuccessful()) {
$this->error('❌ Failed to generate static cache');
$this->error('Check storage/logs/ssr-fpc-errors.log for details');
return 1;
}
// Parse JSON output from Playwright script
try {
$result = json_decode($output, true);
if (!$result) {
throw new \Exception('Invalid JSON response from Playwright');
}
} catch (\Exception $e) {
$this->error('❌ Failed to parse Playwright output: ' . $e->getMessage());
$this->error('Raw output: ' . $output);
return 1;
}
// Get build key from manifest
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
// Generate Redis cache key
$url_hash = sha1($url);
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
// Generate ETag (first 30 chars of SHA1)
$content_for_etag = $build_key . $url . ($result['page_dom'] ?? $result['redirect'] ?? '');
$etag = substr(sha1($content_for_etag), 0, 30);
// Build cache entry
$cache_entry = [
'url' => $url,
'code' => $result['code'],
'build_key' => $build_key,
'etag' => $etag,
'generated_at' => time(),
];
if ($result['code'] >= 300 && $result['code'] < 400) {
// Redirect response
$cache_entry['redirect'] = $result['redirect'];
$cache_entry['page_dom'] = null;
} else {
// Normal response
$cache_entry['page_dom'] = $result['page_dom'];
$cache_entry['redirect'] = null;
}
// Store in Redis as JSON
try {
Redis::set($redis_key, json_encode($cache_entry));
$this->info("✅ Static cache generated successfully");
$this->info(" Redis key: {$redis_key}");
$this->info(" ETag: {$etag}");
$this->info(" Status: {$result['code']}");
} catch (\Exception $e) {
$this->error('❌ Failed to store cache in Redis: ' . $e->getMessage());
return 1;
}
return 0;
}
}

View File

@@ -1,88 +0,0 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
/**
* RSX SSR Full Page Cache (FPC) Reset Command
* ============================================
*
* PURPOSE:
* Clears all SSR FPC caches from Redis. This is useful when you want to force
* regeneration of all static pages, such as after a major content update or
* when troubleshooting caching issues.
*
* HOW IT WORKS:
* 1. Scans Redis for all keys matching the pattern: ssr_fpc:*
* 2. Deletes all matching keys
* 3. Reports count of cleared cache entries
*
* USAGE:
* php artisan rsx:ssr_fpc:reset
*
* WHEN TO USE:
* - After major content updates across the site
* - When troubleshooting caching issues
* - Before deployment to ensure fresh cache generation
* - When the build_key has changed (auto-invalidation should handle this)
*
* SECURITY:
* - Available in all environments (local, staging, production)
* - Safe to run - only affects SSR FPC caches, not other Redis data
* - Does NOT affect:
* - Application caches
* - Session data
* - Queue jobs
* - Other Redis namespaced data
*/
class Ssr_Fpc_Reset_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:ssr_fpc:reset';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear all SSR Full Page Cache entries from Redis';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Clearing SSR FPC caches...');
try {
// Get all keys matching the SSR FPC pattern
$keys = Redis::keys('ssr_fpc:*');
if (empty($keys)) {
$this->info('No SSR FPC cache entries found.');
return 0;
}
// Delete all FPC cache keys
$deleted = Redis::del($keys);
$this->info("✅ Cleared {$deleted} SSR FPC cache entries");
return 0;
} catch (\Exception $e) {
$this->error('❌ Failed to clear SSR FPC caches: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -1,260 +0,0 @@
#!/usr/bin/env node
/**
* SSR FPC (Full Page Cache) Generation Script
* Generates static pre-rendered HTML cache using Playwright
*
* Usage: node generate-static-cache.js <route> [options]
*
* Arguments:
* route The route to generate cache for (e.g., /about)
*
* Options:
* --timeout=<ms> Navigation timeout in milliseconds (default 30000ms)
* --help Show this help message
*/
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
// Parse command line arguments
function parse_args() {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help')) {
console.log('SSR FPC Generation - Generate static pre-rendered HTML cache');
console.log('');
console.log('Usage: node generate-static-cache.js <route> [options]');
console.log('');
console.log('Arguments:');
console.log(' route The route to generate cache for (e.g., /about)');
console.log('');
console.log('Options:');
console.log(' --timeout=<ms> Navigation timeout in milliseconds (default 30000ms)');
console.log(' --help Show this help message');
process.exit(0);
}
const options = {
route: null,
timeout: 30000,
};
for (const arg of args) {
if (arg.startsWith('--timeout=')) {
options.timeout = parseInt(arg.substring(10));
if (options.timeout < 30000) {
console.error('Error: Timeout value is in milliseconds and must be no less than 30000 milliseconds (30 seconds)');
process.exit(1);
}
} else if (!arg.startsWith('--')) {
options.route = arg;
}
}
if (!options.route) {
console.error('Error: Route argument is required');
process.exit(1);
}
// Ensure route starts with /
if (!options.route.startsWith('/')) {
options.route = '/' + options.route;
}
return options;
}
// Log error to file
function log_error(url, error, details = {}) {
const log_dir = path.join(process.cwd(), 'storage', 'logs');
const log_file = path.join(log_dir, 'ssr-fpc-errors.log');
const timestamp = new Date().toISOString();
const log_entry = {
timestamp,
url,
error: error.message || String(error),
stack: error.stack || null,
...details
};
const log_line = JSON.stringify(log_entry) + '\n';
try {
if (!fs.existsSync(log_dir)) {
fs.mkdirSync(log_dir, { recursive: true });
}
fs.appendFileSync(log_file, log_line);
} catch (e) {
console.error('Failed to write error log:', e.message);
}
}
// Main execution
(async () => {
const options = parse_args();
const baseUrl = process.env.BASE_URL || 'http://localhost';
const fullUrl = baseUrl + options.route;
let browser;
let page;
try {
// Launch browser (always headless)
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const context = await browser.newContext({
ignoreHTTPSErrors: true
});
page = await context.newPage();
// Set up headers for FPC generation request
const extraHeaders = {
'X-RSpade-FPC-Client': '1', // Identifies this as FPC generation request
};
// Use route interception to add headers
await page.route('**/*', async (route, request) => {
const url = request.url();
// Only add headers to local requests
if (url.startsWith(baseUrl)) {
await route.continue({
headers: {
...request.headers(),
...extraHeaders
}
});
} else {
await route.continue();
}
});
// Navigate to the route
let response;
try {
response = await page.goto(fullUrl, {
waitUntil: 'networkidle',
timeout: options.timeout
});
} catch (error) {
log_error(fullUrl, error, {
phase: 'navigation',
timeout: options.timeout
});
throw error;
}
if (!response) {
const error = new Error('Navigation failed - no response');
log_error(fullUrl, error, { phase: 'navigation' });
throw error;
}
// Check for redirect (300-399 status codes)
const status = response.status();
if (status >= 300 && status < 400) {
// Redirect response - cache the redirect
const redirect_location = response.headers()['location'];
if (!redirect_location) {
const error = new Error(`Redirect response (${status}) but no Location header found`);
log_error(fullUrl, error, {
phase: 'redirect_check',
status,
headers: response.headers()
});
throw error;
}
// Output redirect response as JSON
const result = {
url: options.route,
code: status,
redirect: redirect_location,
page_dom: null
};
console.log(JSON.stringify(result));
await browser.close();
process.exit(0);
}
// Wait for RSX framework and jqhtml components to complete initialization
try {
await page.evaluate(() => {
return new Promise((resolve) => {
// Check if RSX framework with _debug_ready event is available
if (window.Rsx && window.Rsx.on) {
// Use Rsx._debug_ready event which fires after all jqhtml components complete lifecycle
window.Rsx.on('_debug_ready', function() {
resolve();
});
} else {
// Fallback for non-RSX pages: wait for DOMContentLoaded + 200ms
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(function() { resolve(); }, 200);
} else {
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() { resolve(); }, 200);
});
}
}
});
});
} catch (error) {
log_error(fullUrl, error, {
phase: 'wait_for_ready',
has_rsx: await page.evaluate(() => typeof window.Rsx !== 'undefined')
});
throw error;
}
// Additional 10ms wait for final network settle
await new Promise(resolve => setTimeout(resolve, 10));
// Get the fully rendered DOM
let page_dom;
try {
page_dom = await page.content();
} catch (error) {
log_error(fullUrl, error, { phase: 'get_dom' });
throw error;
}
// Output success response as JSON
const result = {
url: options.route,
code: status,
page_dom: page_dom,
redirect: null
};
console.log(JSON.stringify(result));
await browser.close();
process.exit(0);
} catch (error) {
if (browser) {
await browser.close();
}
// Log error if not already logged
if (!error.logged) {
log_error(fullUrl, error, { phase: 'unknown' });
}
console.error('FATAL: SSR FPC generation failed');
console.error(error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
})();

View File

@@ -0,0 +1,29 @@
<%--
JS_Tree_Debug_Component
A universal "var_dump" style component for debugging JavaScript values.
Renders any JavaScript value as an expandable/collapsible tree, similar to browser DevTools.
Useful for debugging, displaying error metadata, and inspecting ORM model instances.
$data - The JavaScript value to display. Pass directly (unquoted) for objects/arrays:
$data=this.data.myObject (correct - passes object reference)
$data="<%= this.data.myObject %>" (wrong - stringifies the object)
$expand_depth - How many levels deep to expand by default (default: 1)
$root_label - Optional label for the root element
$show_class_names - If true, display class names for object instances in a small
bordered badge (default: false). Shown after { when expanded,
after } when collapsed. Only for named class instances, not
generic Object or Array.
--%>
<Define:JS_Tree_Debug_Component tag="div" class="js-tree-debug">
<% if (JS_Tree_Debug_Node.get_type(this.args.data) !== 'object' && JS_Tree_Debug_Node.get_type(this.args.data) !== 'array') { %>
<span class="js-tree-debug-value js-tree-debug-<%= JS_Tree_Debug_Node.get_type(this.args.data) %>"><%= JS_Tree_Debug_Node.format_value(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
<% } else { %>
<JS_Tree_Debug_Node
$data=this.args.data
$expand_depth=(this.args.expand_depth ?? 1)
$label=(this.args.root_label || null)
$show_class_names=(this.args.show_class_names ?? false)
/>
<% } %>
</Define:JS_Tree_Debug_Component>

View File

@@ -0,0 +1,3 @@
class JS_Tree_Debug_Component extends Component {
// No special logic needed at root level - just passes data to JS_Tree_Debug_Node
}

View File

@@ -0,0 +1,68 @@
<%--
JS_Tree_Debug_Node (Internal component for JS_Tree_Debug_Component)
Renders a single expandable node in the debug tree.
Not intended for direct use - use JS_Tree_Debug_Component instead.
$data - The object or array to render
$expand_depth - How many levels deep to expand
$label - Optional key/index label for this node
$show_class_names - If true, display class names for named object instances
--%>
<Define:JS_Tree_Debug_Node tag="div" class="js-tree-debug-node">
<%
const class_name = this.args.show_class_names ? JS_Tree_Debug_Node.get_class_name(this.args.data) : null;
const relationships = JS_Tree_Debug_Node.get_object_relationships(this.args.data);
%>
<span class="js-tree-debug-toggle<%= this.is_expanded ? '' : ' js-tree-debug-collapsed' %>" $sid="toggle" @click=this.toggle>
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
</span>
<% if (this.args.label !== null && this.args.label !== undefined) { %>
<span class="js-tree-debug-key"><%= this.args.label %></span><span class="js-tree-debug-colon">: </span>
<% } %>
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? '[' : '{' %></span>
<% if (class_name) { %>
<span class="js-tree-debug-class-badge"><span class="js-tree-debug-class-name"><%= class_name %></span><% if (this.args.data && this.args.data.id !== undefined) { %><span class="js-tree-debug-class-paren">(</span><span class="js-tree-debug-class-id"><%= this.args.data.id %></span><span class="js-tree-debug-class-paren">)</span><% } %></span>
<% } %>
<span class="js-tree-debug-preview-collapsed" $sid="preview_collapsed" style="<%= this.is_expanded ? 'display:none' : '' %>"><%= JS_Tree_Debug_Node.get_preview(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
<div class="js-tree-debug-children" $sid="children" style="<%= this.is_expanded ? '' : 'display:none' %>">
<%-- Regular data entries --%>
<% for (const [key, value] of JS_Tree_Debug_Node.get_entries(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data))) {
const val_type = JS_Tree_Debug_Node.get_type(value);
const is_expandable = val_type === 'object' || val_type === 'array';
%>
<% if (is_expandable) { %>
<JS_Tree_Debug_Node
$data=value
$expand_depth=((this.args.expand_depth ?? 1) - 1)
$label=key
$show_class_names=(this.args.show_class_names ?? false)
/>
<% } else { %>
<div class="js-tree-debug-leaf">
<span class="js-tree-debug-key"><%= key %></span><span class="js-tree-debug-colon">: </span>
<span class="js-tree-debug-value js-tree-debug-<%= val_type %>"><%= JS_Tree_Debug_Node.format_value(value, val_type) %></span>
</div>
<% } %>
<% } %>
<%-- Relationship nodes (lazy-loaded) --%>
<% for (const rel_name of relationships) { %>
<div class="js-tree-debug-node js-tree-debug-relationship" $sid="rel_<%= rel_name %>">
<%
this.handler_toggle_rel = () => this.toggle_relationship(rel_name);
%>
<span class="js-tree-debug-toggle js-tree-debug-collapsed" @click=this.handler_toggle_rel>
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
</span>
<span class="js-tree-debug-key js-tree-debug-rel-key"><%= rel_name %>()</span><span class="js-tree-debug-colon">: </span>
<span class="js-tree-debug-preview-collapsed">...</span>
<div class="js-tree-debug-rel-children js-tree-debug-children" style="display:none">
<div class="js-tree-debug-leaf js-tree-debug-pending">(click to load)</div>
</div>
</div>
<% } %>
</div>
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? ']' : '}' %></span>
</Define:JS_Tree_Debug_Node>

View File

@@ -0,0 +1,257 @@
class JS_Tree_Debug_Node extends Component {
on_create() {
this.is_expanded = (this.args.expand_depth ?? 1) > 0;
// Track relationship loading states: { rel_name: 'pending'|'loading'|'loaded'|'error' }
this.state.rel_states = {};
// Store loaded relationship data: { rel_name: data }
this.state.rel_data = {};
// Store error messages: { rel_name: error_message }
this.state.rel_errors = {};
}
on_ready() {
// Relationships are never auto-loaded - they only load when explicitly expanded
}
toggle() {
this.is_expanded = !this.is_expanded;
this.$sid('children').toggle(this.is_expanded);
this.$sid('toggle').toggleClass('js-tree-debug-collapsed', !this.is_expanded);
this.$sid('preview_collapsed').toggle(!this.is_expanded);
// Note: Relationships are NOT auto-loaded here - they have their own toggle handler
}
/**
* Toggle a relationship node and load its data if not already loaded
*/
toggle_relationship(rel_name) {
const $container = this.$sid('rel_' + rel_name);
const $toggle = $container.find('.js-tree-debug-toggle').first();
const $children = $container.find('.js-tree-debug-rel-children').first();
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
const is_expanded = !$toggle.hasClass('js-tree-debug-collapsed');
if (is_expanded) {
// Collapse
$toggle.addClass('js-tree-debug-collapsed');
$children.hide();
$preview.show();
} else {
// Expand
$toggle.removeClass('js-tree-debug-collapsed');
$children.show();
$preview.hide();
// Load if not already loaded
if (!this.state.rel_states[rel_name] || this.state.rel_states[rel_name] === 'pending') {
this._load_relationship(rel_name);
}
}
}
/**
* Load a relationship and update the UI
*/
async _load_relationship(rel_name) {
// Validate the relationship function exists
const obj = this.args.data;
if (!obj || typeof obj[rel_name] !== 'function') {
return;
}
this.state.rel_states[rel_name] = 'loading';
this._update_relationship_ui(rel_name);
try {
const result = await obj[rel_name]();
this.state.rel_states[rel_name] = 'loaded';
this.state.rel_data[rel_name] = result;
this._update_relationship_ui(rel_name);
} catch (e) {
this.state.rel_states[rel_name] = 'error';
this.state.rel_errors[rel_name] = e.message || 'Error loading relationship';
this._update_relationship_ui(rel_name);
}
}
/**
* Update the UI for a relationship after loading
*/
_update_relationship_ui(rel_name) {
const $container = this.$sid('rel_' + rel_name);
const $children = $container.find('.js-tree-debug-rel-children').first();
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
const state = this.state.rel_states[rel_name];
const data = this.state.rel_data[rel_name];
const error = this.state.rel_errors[rel_name];
// Update preview text
if (state === 'loading') {
$preview.html('<span class="js-tree-debug-loading">loading...</span>');
} else if (state === 'error') {
$preview.html('<span class="js-tree-debug-error">error</span>');
} else if (state === 'loaded') {
const type = JS_Tree_Debug_Node.get_type(data);
$preview.text(JS_Tree_Debug_Node.get_preview(data, type) || JS_Tree_Debug_Node.format_value(data, type));
}
// Update children content
$children.empty();
if (state === 'loading') {
$children.html('<div class="js-tree-debug-leaf js-tree-debug-loading">Loading...</div>');
} else if (state === 'error') {
$children.html('<div class="js-tree-debug-leaf js-tree-debug-error">"' + this._escape_html(error) + '"</div>');
} else if (state === 'loaded') {
this._render_relationship_result($children, data, rel_name);
}
}
/**
* Render the result of a relationship fetch into the container
* Renders entries directly (not wrapped in another node) so user doesn't have to double-expand
*/
_render_relationship_result($container, data, rel_name) {
const type = JS_Tree_Debug_Node.get_type(data);
// Handle null/undefined
if (type === 'null' || type === 'undefined') {
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(no data)</div>');
return;
}
// Handle empty array
if (type === 'array' && data.length === 0) {
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty array)</div>');
return;
}
// Handle empty object
if (type === 'object' && Object.keys(data).length === 0) {
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty object)</div>');
return;
}
// Handle primitive values (shouldn't happen but be safe)
if (type !== 'array' && type !== 'object') {
$container.html('<div class="js-tree-debug-leaf"><span class="js-tree-debug-value js-tree-debug-' + type + '">' +
this._escape_html(JS_Tree_Debug_Node.format_value(data, type)) + '</span></div>');
return;
}
// Render entries directly into container (no wrapper node)
const entries = JS_Tree_Debug_Node.get_entries(data, type);
const expand_depth = Math.max(0, (this.args.expand_depth ?? 1) - 1);
const show_class_names = this.args.show_class_names ?? false;
for (const [key, value] of entries) {
const val_type = JS_Tree_Debug_Node.get_type(value);
const is_expandable = val_type === 'object' || val_type === 'array';
if (is_expandable) {
// Create a node for expandable values
const $node = $('<div>');
$container.append($node);
$node.component('JS_Tree_Debug_Node', {
data: value,
expand_depth: expand_depth,
label: key,
show_class_names: show_class_names
});
} else {
// Render leaf value directly
$container.append(
'<div class="js-tree-debug-leaf">' +
'<span class="js-tree-debug-key">' + this._escape_html(String(key)) + '</span>' +
'<span class="js-tree-debug-colon">: </span>' +
'<span class="js-tree-debug-value js-tree-debug-' + val_type + '">' +
this._escape_html(JS_Tree_Debug_Node.format_value(value, val_type)) +
'</span></div>'
);
}
}
}
_escape_html(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Static helpers for template use
static get_type(val) {
if (val === null) return 'null';
if (val === undefined) return 'undefined';
if (is_array(val)) return 'array';
return typeof val;
}
static get_preview(val, type) {
if (type === 'array') return 'Array(' + val.length + ')';
if (type === 'object') {
const keys = Object.keys(val);
if (keys.length === 0) return '{}';
if (keys.length <= 3) return '{' + keys.join(', ') + '}';
return '{' + keys.slice(0, 3).join(', ') + ', ...}';
}
return '';
}
static get_entries(data, type) {
if (type === 'array') return data.map((v, i) => [i, v]);
return Object.entries(data || {}).sort((a, b) => a[0].localeCompare(b[0]));
}
static format_value(val, type) {
if (type === 'string') return '"' + val + '"';
if (type === 'null') return 'null';
if (type === 'undefined') return 'undefined';
return str(val);
}
/**
* Get the class name of an object if it's a named class instance (not generic Object/Array)
* @param {*} val - Value to check
* @returns {string|null} - Class name or null if generic/primitive
*/
static get_class_name(val) {
if (val === null || val === undefined) return null;
if (Array.isArray(val)) return null;
if (typeof val !== 'object') return null;
const name = val.constructor?.name;
if (!name || name === 'Object') return null;
return name;
}
/**
* Get fetchable relationships for an object
* Returns array of relationship names if object's class has get_relationships()
* @param {*} obj - Object to check
* @returns {string[]} - Array of relationship names, or empty array
*/
static get_object_relationships(obj) {
try {
if (!obj || typeof obj !== 'object') return [];
if (Array.isArray(obj)) return [];
// Check if constructor has get_relationships static method
const ctor = obj.constructor;
if (!ctor || typeof ctor.get_relationships !== 'function') return [];
// Get relationships and validate it returns an array
const relationships = ctor.get_relationships();
if (!Array.isArray(relationships)) return [];
// Filter to only relationships that are actually functions on the object
return relationships.filter(name => {
return typeof name === 'string' && typeof obj[name] === 'function';
});
} catch (e) {
// Any error, just return empty array
return [];
}
}
}

View File

@@ -0,0 +1,150 @@
.js-tree-debug {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: 13px;
line-height: 1.4;
text-align: left;
width: 800px;
max-width: 100%;
height: 250px;
overflow: auto;
resize: both;
padding: 20px;
border: 1px solid #777;
border-radius: 4px;
background: #fafafa;
.js-tree-debug-node {
margin-left: 0;
}
.js-tree-debug-children {
margin-left: 20px;
border-left: 1px solid #e0e0e0;
padding-left: 8px;
}
.js-tree-debug-leaf {
padding: 1px 0;
}
.js-tree-debug-toggle {
display: inline-block;
width: 16px;
cursor: pointer;
user-select: none;
.js-tree-debug-arrow {
font-size: 10px;
color: #666;
transition: transform 0.15s ease; // @SCSS-ANIM-01-EXCEPTION
display: inline-block;
}
&:not(.js-tree-debug-collapsed) .js-tree-debug-arrow {
transform: rotate(90deg);
}
&:hover .js-tree-debug-arrow {
color: #333;
}
}
.js-tree-debug-key {
color: #881391;
}
.js-tree-debug-colon {
color: #666;
}
.js-tree-debug-preview {
color: #666;
}
.js-tree-debug-preview-collapsed {
color: #999;
font-style: italic;
}
.js-tree-debug-bracket-close {
color: #666;
}
// Value type colors
.js-tree-debug-string {
color: #c41a16;
}
.js-tree-debug-number {
color: #1c00cf;
}
.js-tree-debug-boolean {
color: #0d22aa;
}
.js-tree-debug-null,
.js-tree-debug-undefined {
color: #808080;
font-style: italic;
}
.js-tree-debug-value {
word-break: break-word;
}
.js-tree-debug-class-badge {
display: inline-block;
font-size: 10px;
padding: 0 4px;
margin-left: 4px;
border: 1px solid #ccc;
border-radius: 3px;
background: #f5f5f5;
vertical-align: middle;
line-height: 1.4;
.js-tree-debug-class-name {
color: #881391; // Same as keys
}
.js-tree-debug-class-paren {
color: #666; // Same as colons/symbols
}
.js-tree-debug-class-id {
color: #1c00cf; // Same as numbers
}
}
// Relationship nodes (lazy-loaded)
.js-tree-debug-relationship {
.js-tree-debug-rel-key {
color: #0066cc;
font-style: italic;
}
}
// Loading state
.js-tree-debug-loading {
color: #888;
font-style: italic;
}
// Error state
.js-tree-debug-error {
color: #cc0000;
}
// Empty/no data state
.js-tree-debug-empty {
color: #888;
font-style: italic;
}
// Pending (not yet loaded)
.js-tree-debug-pending {
color: #999;
font-style: italic;
}
}

View File

@@ -24,6 +24,8 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
* "C_1": {success: false, error_type: "...", reason: "..."},
* ...
* }
*
* @auth-exempt Auth is handled per-call inside Ajax::internal()
*/
class Ajax_Batch_Controller extends Rsx_Controller_Abstract
{
@@ -34,7 +36,6 @@ class Ajax_Batch_Controller extends Rsx_Controller_Abstract
* @param array $params
* @return \Illuminate\Http\JsonResponse
*/
#[Auth('Permission::anybody()')]
#[Route('/_ajax/_batch', methods: ['POST'])]
public static function batch(Request $request, array $params = [])
{

View File

@@ -0,0 +1,180 @@
<?php
namespace App\RSpade\Core\Api;
use Illuminate\Support\Str;
use App\RSpade\Core\Database\Models\Rsx_System_Model_Abstract;
use App\RSpade\Core\Models\User_Model;
/**
* Api_Key_Model - System model for API key management
*
* API keys are system-level records that authenticate external API requests.
* Keys are tied to users and establish session-like contexts for API access.
*
* Security:
* - Keys are hashed before storage (plaintext never stored)
* - Key prefix stored for identification without exposing full key
* - Keys can be revoked (soft disable) or have expiration dates
*
* @see external_api.txt for full documentation
*
* @property int $id
* @property int $user_id
* @property string $name
* @property string $key_hash
* @property string $key_prefix
* @property int|null $user_role_id
* @property \Carbon\Carbon|null $last_used_at
* @property \Carbon\Carbon|null $expires_at
* @property bool $is_revoked
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class Api_Key_Model extends Rsx_System_Model_Abstract
{
protected $table = '_api_keys';
public static $enums = [];
protected $casts = [
'is_revoked' => 'boolean',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
/**
* Generate a new API key for a user
*
* Returns the plaintext key (only time it's available) and the model.
* The plaintext key must be shown to the user immediately as it cannot
* be recovered after this method returns.
*
* @param int $user_id User ID to create key for
* @param string $name Human-readable key name
* @param string $environment 'live' or 'test'
* @param int|null $user_role_id Optional role override
* @param \Carbon\Carbon|null $expires_at Optional expiration date
* @return array{key: string, model: Api_Key_Model} Plaintext key and saved model
*/
public static function generate(
int $user_id,
string $name,
string $environment = 'live',
?int $user_role_id = null,
?\Carbon\Carbon $expires_at = null
): array {
// Generate random key: rsk_{env}_{32 random chars}
$random = Str::random(32);
$plaintext_key = "rsk_{$environment}_{$random}";
$prefix = "rsk_{$environment}_" . substr($random, 0, 4) . '...';
$model = new self();
$model->user_id = $user_id;
$model->name = $name;
$model->key_hash = hash('sha256', $plaintext_key);
$model->key_prefix = $prefix;
$model->user_role_id = $user_role_id;
$model->expires_at = $expires_at;
$model->is_revoked = false;
$model->save();
return [
'key' => $plaintext_key,
'model' => $model,
];
}
/**
* Find an API key by its plaintext value
*
* Hashes the provided key and looks up by hash.
* Returns null if not found, revoked, or expired.
*
* @param string $plaintext_key The API key from request
* @return Api_Key_Model|null The key model if valid, null otherwise
*/
public static function find_by_key(string $plaintext_key): ?self
{
$hash = hash('sha256', $plaintext_key);
$key = self::where('key_hash', $hash)
->where('is_revoked', false)
->first();
if (!$key) {
return null;
}
// Check expiration
if ($key->expires_at && $key->expires_at->isPast()) {
return null;
}
return $key;
}
/**
* Get all active (non-revoked) keys for a user
*
* @param int $user_id User ID
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function get_for_user(int $user_id)
{
return self::where('user_id', $user_id)
->orderBy('created_at', 'desc')
->get();
}
/**
* Revoke this API key
*
* Soft-disables the key. It remains in the database but can no longer
* be used for authentication.
*/
public function revoke(): void
{
$this->is_revoked = true;
$this->save();
}
/**
* Update last_used_at timestamp
*
* Called when the key is used for authentication.
*/
public function touch_last_used(): void
{
$this->last_used_at = now();
$this->save();
}
/**
* Get the user this key belongs to
*
* @return User_Model|null
*/
public function get_user(): ?User_Model
{
return User_Model::find($this->user_id);
}
/**
* Check if this key is currently valid
*
* @return bool
*/
public function is_valid(): bool
{
if ($this->is_revoked) {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
return true;
}
}

View File

@@ -62,20 +62,10 @@ class RsxBootstrap
* Artisan commands and always_write_lock mode use WRITE lock.
* This can be upgraded to WRITE for exclusive operations.
*
* FPC clients (Playwright spawned for SSR cache generation) skip lock acquisition
* to avoid deadlock with the parent request that spawned them.
*
* @return void
*/
private static function __acquire_application_lock(): void
{
// Skip lock acquisition for FPC clients to avoid deadlock
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
console_debug('CONCURRENCY', 'Skipping application lock for FPC client');
return;
}
$always_write = config('rsx.locking.always_write_lock', false);
// Detect artisan commands by checking if running from CLI and the script name contains 'artisan'

View File

@@ -7,6 +7,8 @@ use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
use App\RSpade\Core\Bundle\Rsx_Module_Bundle_Abstract;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Manifest\Manifest;
@@ -91,6 +93,12 @@ class BundleCompiler
*/
protected array $resolved_includes = [];
/**
* The root module bundle class being compiled
* Used for validation error messages
*/
protected string $root_bundle_class = '';
/**
* Compiled jqhtml files (separated during ordering for special placement)
*/
@@ -129,6 +137,7 @@ class BundleCompiler
// Step 2: Mark the bundle we're compiling as already resolved
$this->resolved_includes[$bundle_class] = true;
$this->root_bundle_class = $bundle_class;
// Step 3: Process required bundles first
$this->_process_required_bundles();
@@ -467,18 +476,73 @@ class BundleCompiler
$this->_process_include_item($bundle_aliases[$alias]);
}
}
// Include custom JS model base class if configured
// This allows users to define application-wide model functionality
$js_model_base_class = config('rsx.js_model_base_class');
if ($js_model_base_class) {
$this->_include_js_model_base_class($js_model_base_class);
}
}
/**
* Include the custom JS model base class file in the bundle
*
* Finds the JS file by class name in the manifest and adds it to the bundle.
* Validates that the class extends Rsx_Js_Model.
*/
protected function _include_js_model_base_class(string $class_name): void
{
// Find the JS file in the manifest by class name
try {
$file_path = Manifest::js_find_class($class_name);
} catch (\RuntimeException $e) {
throw new \RuntimeException(
"JavaScript model base class '{$class_name}' configured in rsx.js_model_base_class not found in manifest.\n" .
"Ensure the class is defined in a .js file within your application (e.g., rsx/lib/{$class_name}.js)"
);
}
// Get metadata to verify it extends Rsx_Js_Model
$metadata = Manifest::get_file($file_path);
$extends = $metadata['extends'] ?? null;
if ($extends !== 'Rsx_Js_Model') {
throw new \RuntimeException(
"JavaScript model base class '{$class_name}' must extend Rsx_Js_Model.\n" .
"Found: extends {$extends}\n" .
"File: {$file_path}"
);
}
// Add the file to the bundle by processing it as a path
$this->_process_include_item($file_path);
}
/**
* Resolve bundle and all its includes
*
* @param string $bundle_class The bundle class to resolve
* @param bool $discovered_via_scan Whether this bundle was discovered via directory scan
*/
protected function _resolve_bundle(string $bundle_class): void
protected function _resolve_bundle(string $bundle_class, bool $discovered_via_scan = false): void
{
// Get bundle definition
if (!method_exists($bundle_class, 'define')) {
throw new Exception("Bundle {$bundle_class} missing define() method");
}
// Validate module bundle doesn't include another module bundle
if (Manifest::php_is_subclass_of($bundle_class, 'Rsx_Module_Bundle_Abstract') &&
$bundle_class !== $this->root_bundle_class) {
Rsx_Module_Bundle_Abstract::validate_include($bundle_class, $this->root_bundle_class);
}
// Validate asset bundles discovered via scan don't have directory paths
if ($discovered_via_scan && Manifest::php_is_subclass_of($bundle_class, 'Rsx_Asset_Bundle_Abstract')) {
Rsx_Asset_Bundle_Abstract::validate_no_directory_scanning($bundle_class, $this->root_bundle_class);
}
$definition = $bundle_class::define();
// Process bundle includes
@@ -732,6 +796,8 @@ class BundleCompiler
/**
* Add all files from a directory
*
* Also auto-discovers Asset Bundles in the directory and processes them.
*/
protected function _add_directory(string $path): void
{
@@ -742,6 +808,9 @@ class BundleCompiler
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Track discovered asset bundles to process after file collection
$discovered_bundles = [];
// Create a recursive directory iterator with filtering
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
@@ -763,7 +832,42 @@ class BundleCompiler
foreach ($iterator as $file) {
if ($file->isFile()) {
$this->_add_file($file->getPathname());
$filepath = $file->getPathname();
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
// For PHP files, check if it's an asset bundle via manifest
if ($extension === 'php') {
$relative_path = str_replace(base_path() . '/', '', $filepath);
// Get file metadata from manifest to check if it's an asset bundle
try {
$file_meta = Manifest::get_file($relative_path);
$class_name = $file_meta['class'] ?? null;
// Use manifest to check if this PHP class is an asset bundle
if ($class_name && Manifest::php_is_subclass_of($class_name, 'Rsx_Asset_Bundle_Abstract')) {
$fqcn = $file_meta['fqcn'] ?? null;
if ($fqcn && !isset($this->resolved_includes[$fqcn])) {
$discovered_bundles[] = $fqcn;
console_debug('BUNDLE', "Auto-discovered asset bundle: {$fqcn}");
}
// Don't add bundle file itself to file list - we'll process it as a bundle
continue;
}
} catch (RuntimeException $e) {
// File not in manifest, just add it normally
}
}
$this->_add_file($filepath);
}
}
// Process discovered asset bundles (marked as discovered via scan)
foreach ($discovered_bundles as $bundle_fqcn) {
if (!isset($this->resolved_includes[$bundle_fqcn])) {
$this->resolved_includes[$bundle_fqcn] = true;
$this->_resolve_bundle($bundle_fqcn, true); // true = discovered via scan
}
}
}
@@ -1059,6 +1163,154 @@ class BundleCompiler
return array_unique($stubs);
}
/**
* Generate concrete model classes for PHP models in the bundle
*
* For each PHP model (subclass of Rsx_Model_Abstract) in the bundle:
* 1. Check if a user-defined JS class with the same name exists
* 2. If user-defined class exists:
* - Validate it extends Base_{ModelName} directly
* - If it exists in manifest but not in bundle, throw error
* 3. If no user-defined class exists:
* - Auto-generate: class ModelName extends Base_ModelName {}
*
* @param array $current_js_files JS files already in the bundle (to check for user classes)
* @return string|null Path to temp file containing generated classes, or null if none needed
*/
protected function _generate_concrete_model_classes(array $current_js_files): ?string
{
$manifest = Manifest::get_full_manifest();
$manifest_files = $manifest['data']['files'] ?? [];
// Get all files from all bundles to find PHP models
$all_bundle_files = [];
foreach ($this->bundle_files as $type => $files) {
if (is_array($files)) {
$all_bundle_files = array_merge($all_bundle_files, $files);
}
}
// Build a set of JS class names currently in the bundle for quick lookup
$js_classes_in_bundle = [];
foreach ($current_js_files as $js_file) {
$relative = str_replace(base_path() . '/', '', $js_file);
if (isset($manifest_files[$relative]['class'])) {
$js_classes_in_bundle[$manifest_files[$relative]['class']] = $relative;
}
}
// Find all PHP models in the bundle
$models_in_bundle = [];
foreach ($all_bundle_files as $file) {
$relative = str_replace(base_path() . '/', '', $file);
// Check if this is a PHP file with a class
if (!isset($manifest_files[$relative]['class'])) {
continue;
}
$class_name = $manifest_files[$relative]['class'];
// Check if this class is a subclass of Rsx_Model_Abstract (but not system models)
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) {
continue;
}
// Skip abstract model classes - only concrete models get JS stubs
if (Manifest::php_is_abstract($class_name)) {
continue;
}
$models_in_bundle[$class_name] = $relative;
}
if (empty($models_in_bundle)) {
return null;
}
console_debug('BUNDLE', 'Found ' . count($models_in_bundle) . ' PHP models in bundle: ' . implode(', ', array_keys($models_in_bundle)));
// Process each model
$generated_classes = [];
$base_class_name = config('rsx.js_model_base_class');
foreach ($models_in_bundle as $model_name => $model_path) {
$expected_base_class = 'Base_' . $model_name;
// Check if user has defined a JS class with this model name
$user_js_class_path = null;
foreach ($manifest_files as $file_path => $meta) {
if (isset($meta['class']) && $meta['class'] === $model_name && isset($meta['extension']) && $meta['extension'] === 'js') {
// Make sure it's not a generated stub
if (!isset($meta['is_model_stub']) && !isset($meta['is_stub'])) {
$user_js_class_path = $file_path;
break;
}
}
}
if ($user_js_class_path) {
// User has defined a JS class for this model - validate it
console_debug('BUNDLE', "Found user-defined JS class for {$model_name} at {$user_js_class_path}");
// Check if it's in the bundle
if (!isset($js_classes_in_bundle[$model_name])) {
throw new RuntimeException(
"PHP model '{$model_name}' is included in bundle (at {$model_path}) " .
"but its custom JavaScript implementation exists at '{$user_js_class_path}' " .
"and is NOT included in the bundle.\n\n" .
"Either:\n" .
"1. Add the JS file's directory to the bundle's include paths, or\n" .
"2. Remove the custom JS implementation to use auto-generated class"
);
}
// Validate it extends the Base_ class directly
$user_meta = $manifest_files[$user_js_class_path] ?? [];
$user_extends = $user_meta['extends'] ?? null;
if ($user_extends !== $expected_base_class) {
throw new RuntimeException(
"JavaScript model class '{$model_name}' at '{$user_js_class_path}' " .
"must extend '{$expected_base_class}' directly.\n" .
"Found: extends " . ($user_extends ?: '(nothing)') . "\n\n" .
"Correct usage:\n" .
"class {$model_name} extends {$expected_base_class} {\n" .
" // Your custom model methods\n" .
"}"
);
}
console_debug('BUNDLE', "Validated {$model_name} extends {$expected_base_class}");
} else {
// No user-defined class - auto-generate one
console_debug('BUNDLE', "Auto-generating concrete class for {$model_name}");
$generated_classes[] = "class {$model_name} extends {$expected_base_class} {}";
}
}
if (empty($generated_classes)) {
return null;
}
// Write all generated classes to a single temp file using standard temp file pattern
$content = "/**\n";
$content .= " * Auto-generated concrete model classes\n";
$content .= " * These classes extend the Base_* stubs to provide usable model classes\n";
$content .= " * when no custom implementation is defined by the developer.\n";
$content .= " */\n\n";
$content .= implode("\n\n", $generated_classes) . "\n";
// Use content hash for idempotent file naming, with recognizable prefix for detection
$hash = substr(md5($content), 0, 8);
$temp_file = storage_path('rsx-tmp/bundle_generated_models_' . $this->bundle_name . '_' . $hash . '.js');
file_put_contents($temp_file, $content);
console_debug('BUNDLE', 'Generated ' . count($generated_classes) . ' concrete model classes');
return $temp_file;
}
/**
* Order JavaScript files by class dependency
*
@@ -1109,6 +1361,29 @@ class BundleCompiler
continue;
}
// Check if this is a JS stub file (not in manifest, needs parsing)
// Stub files are in storage/rsx-build/js-stubs/ or storage/rsx-build/js-model-stubs/
if (str_contains($file, 'storage/rsx-build/js-stubs/') || str_contains($file, 'storage/rsx-build/js-model-stubs/')) {
// Use simple regex extraction - stub files have known format and can't use
// the strict JS parser (stubs may have code after class declaration)
$stub_content = file_get_contents($file);
$stub_metadata = $this->_extract_stub_class_info($stub_content);
if (!empty($stub_metadata['class'])) {
$class_files[] = $file;
$class_info[$file] = [
'class' => $stub_metadata['class'],
'extends' => $stub_metadata['extends'],
'decorators' => [],
'method_decorators' => [],
];
console_debug('BUNDLE_SORT', "Parsed stub file: {$stub_metadata['class']} extends " . ($stub_metadata['extends'] ?? 'nothing'));
} else {
$non_class_files[] = $file;
}
continue;
}
// Get file info from manifest
$relative = str_replace(base_path() . '/', '', $file);
$file_data = $manifest_files[$relative] ?? null;
@@ -1211,6 +1486,35 @@ class BundleCompiler
return $decorators;
}
/**
* Extract class name and extends from JS stub file content
*
* Uses simple regex extraction since stub files have a known format and may
* have code after the class declaration that the strict JS parser rejects.
*
* @param string $content The stub file content
* @return array ['class' => string|null, 'extends' => string|null]
*/
protected function _extract_stub_class_info(string $content): array
{
// Remove single-line comments
$content = preg_replace('#//.*$#m', '', $content);
// Remove multi-line comments (including JSDoc)
$content = preg_replace('#/\*.*?\*/#s', '', $content);
// Match: class ClassName or class ClassName extends ParentClass
// The first match wins - we only care about the class declaration
if (preg_match('/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+extends\s+([A-Za-z_][A-Za-z0-9_]*))?/', $content, $matches)) {
return [
'class' => $matches[1],
'extends' => $matches[2] ?? null,
];
}
return ['class' => null, 'extends' => null];
}
/**
* Topological sort for class dependencies with decorator support
*
@@ -1419,7 +1723,13 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
* Here we:
* 1. Filter to only .js and .css files
* 2. Order JS files by class dependency
* 3. Add framework code (stubs, manifest, runner)
* 3. Add framework code:
* a. JS stubs (Base_* model classes, controller stubs, etc.)
* b. Compiled jqhtml templates
* c. Concrete model classes (auto-generated or validated user-defined)
* d. Manifest definitions (registers all JS classes)
* e. Route definitions
* f. Initialization runner (LAST - starts the application)
* 4. Generate final compiled output
*/
protected function _compile_outputs(array $types_to_compile = []): array
@@ -1456,7 +1766,16 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
// Other extensions are ignored - they should have been processed into JS/CSS
}
// Order JavaScript files by class dependency BEFORE adding framework code
// Add JS stubs to app bundle only (they depend on Rsx_Js_Model which is in app)
// Add them BEFORE dependency ordering so they're properly sorted
if ($type === 'app') {
$stub_files = $this->_get_js_stubs();
foreach ($stub_files as $stub) {
$files['js'][] = $stub;
}
}
// Order JavaScript files by class dependency BEFORE adding other framework code
if (!empty($files['js'])) {
$files['js'] = $this->_order_javascript_files_by_dependency($files['js']);
}
@@ -1482,7 +1801,8 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
}
}
// Add JS stubs and framework code to app JS
// Add framework code to app JS
// Note: JS stubs are already added before dependency ordering above
if ($type === 'app') {
// Add NPM import declarations at the very beginning
if (!empty($this->npm_includes)) {
@@ -1492,19 +1812,19 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
}
}
// ALWAYS get JS stubs for ALL included files that have them
// ANY file type can have a js_stub - controllers, models, custom types, etc.
$stub_files = $this->_get_js_stubs();
foreach ($stub_files as $stub) {
$files['js'][] = $stub;
}
// Add compiled jqhtml files AFTER JS stubs
// Add compiled jqhtml files
// These are JavaScript files generated from .jqhtml templates
foreach ($this->jqhtml_compiled_files as $jqhtml_file) {
$files['js'][] = $jqhtml_file;
}
// Generate concrete model classes for PHP models in the bundle
// This validates user-defined JS model classes and auto-generates missing ones
$concrete_models_file = $this->_generate_concrete_model_classes($files['js']);
if ($concrete_models_file) {
$files['js'][] = $concrete_models_file;
}
// Generate manifest definitions for all JS classes
$manifest_file = $this->_create_javascript_manifest($files['js'] ?? []);
if ($manifest_file) {
@@ -2020,8 +2340,38 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
// Analyze each JavaScript file for class information
foreach ($js_files as $file) {
// Skip temp files
// Skip most temp files, but handle auto-generated model classes
if (str_contains($file, 'storage/rsx-tmp/')) {
// Check if this is the auto-generated model classes file
if (str_contains($file, 'bundle_generated_models_')) {
// Parse simple class declarations: class Foo extends Bar {}
$content = file_get_contents($file);
if (preg_match_all('/class\s+([A-Za-z_][A-Za-z0-9_]*)\s+extends\s+([A-Za-z_][A-Za-z0-9_]*)/', $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$class_definitions[$match[1]] = [
'name' => $match[1],
'extends' => $match[2],
'decorators' => null,
];
}
}
}
continue;
}
// Check if this is a JS stub file (not in PHP manifest, needs direct parsing)
// Stub files are in storage/rsx-build/js-stubs/ or storage/rsx-build/js-model-stubs/
if (str_contains($file, 'storage/rsx-build/js-stubs/') || str_contains($file, 'storage/rsx-build/js-model-stubs/')) {
$stub_content = file_get_contents($file);
$stub_metadata = $this->_extract_stub_class_info($stub_content);
if (!empty($stub_metadata['class'])) {
$class_definitions[$stub_metadata['class']] = [
'name' => $stub_metadata['class'],
'extends' => $stub_metadata['extends'],
'decorators' => null, // Stubs don't have method decorators
];
}
continue;
}

View File

@@ -1,3 +1,26 @@
# Bundle System
## Two-Tier Bundle Architecture
**Module Bundles** (`Rsx_Module_Bundle_Abstract`)
- Top-level entry point bundles rendered on pages
- Can scan directories via `include` paths
- Auto-discovers Asset Bundles in scanned directories
- Cannot include other Module Bundles (fatal error)
- Built via `rsx:bundle:build`
**Asset Bundles** (`Rsx_Asset_Bundle_Abstract`)
- Dependency declaration bundles co-located with components
- NO directory scanning - only CDN assets, NPM modules, direct file paths
- Auto-discovered when Module Bundles scan directories containing them
- Never built standalone - metadata consumed by Module Bundles
- Can use `watch` directories for cache invalidation (e.g., Bootstrap SCSS)
**Auto-Discovery Rules:**
- Asset Bundles discovered via directory scan cannot have directory paths in `include`
- If Asset Bundle needs directory scanning, include it explicitly by class name
- Discovery uses `Manifest::php_is_subclass_of()` (NOT filename patterns)
# Bundle Processor System
## Architecture

View File

@@ -24,8 +24,10 @@ class Core_Bundle extends Rsx_Bundle_Abstract
__DIR__,
'app/RSpade/Core/Js',
'app/RSpade/Core/Data',
'app/RSpade/Core/Database',
'app/RSpade/Core/SPA',
'app/RSpade/Lib',
'app/RSpade/Components',
],
];
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\RSpade\Core\Bundle;
use RuntimeException;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Rsx_Asset_Bundle_Abstract - Base class for dependency declaration bundles
*
* Asset bundles declare external dependencies (CDN assets, npm modules) and are
* automatically discovered when a Module Bundle scans directories containing them.
* They serve as co-located dependency manifests for components.
*
* CAPABILITIES:
* - CDN asset declarations (cdn_assets)
* - NPM module declarations (npm)
* - Watch directories for cache invalidation (watch)
* - Direct file path includes (no directory scanning)
* - Can include other Asset Bundles by class name
* - Config declarations
*
* RESTRICTIONS:
* - NO directory scanning in 'include' array
* - Never built standalone - metadata consumed by Module Bundles
* - Cannot include Module Bundles
*
* USAGE:
* class Tom_Select_Bundle extends Rsx_Asset_Bundle_Abstract {
* public static function define(): array {
* return [
* 'npm' => ['tom-select'],
* 'include' => [
* // Direct file paths only, no directories
* 'rsx/theme/components/inputs/select/tom_select_config.js',
* ],
* ];
* }
* }
*
* For source compilation (like Bootstrap SCSS):
* class Bootstrap5_Src_Bundle extends Rsx_Asset_Bundle_Abstract {
* public static function define(): array {
* return [
* 'include' => [
* 'rsx/theme/vendor/bootstrap_custom.scss', // Direct file
* 'rsx/theme/vendor/bootstrap5/dist/js/bootstrap.bundle.js',
* ],
* 'watch' => [
* 'rsx/theme/vendor/bootstrap5/scss', // Watch for changes
* ],
* 'cdn_assets' => [
* 'css' => [['url' => 'https://cdn.jsdelivr.net/...']],
* ],
* ];
* }
* }
*
* @see Rsx_Module_Bundle_Abstract for top-level compiled bundles
*/
abstract class Rsx_Asset_Bundle_Abstract extends Rsx_Bundle_Abstract
{
/**
* Track whether this bundle was discovered via directory scan
* Used for validation - bundles discovered via scan have stricter rules
*/
protected static bool $_discovered_via_scan = false;
/**
* Validate that this asset bundle's include array contains no directory scans
*
* Called by BundleCompiler when this bundle is discovered via directory scan.
*
* @param string $bundle_class The asset bundle class being validated
* @param string $parent_bundle_class The module bundle that discovered it
* @throws RuntimeException if include array contains directory paths
*/
public static function validate_no_directory_scanning(string $bundle_class, string $parent_bundle_class): void
{
if (!method_exists($bundle_class, 'define')) {
return;
}
$definition = $bundle_class::define();
$include = $definition['include'] ?? [];
foreach ($include as $item) {
if (!is_string($item)) {
continue;
}
// Skip CDN urls, npm: prefixes, /public/ prefixes
if (str_starts_with($item, 'http://') ||
str_starts_with($item, 'https://') ||
str_starts_with($item, 'npm:') ||
str_starts_with($item, 'cdn:') ||
str_starts_with($item, '/public/')) {
continue;
}
// Skip bundle class references (contains no path separators or dots before extension)
if (strpos($item, '/') === false && strpos($item, '.') === false) {
// Could be a bundle class name - that's allowed
continue;
}
// Check if it's a directory path (not a file)
$resolved_path = base_path($item);
// If it resolves to a directory, that's not allowed for discovered asset bundles
if (is_dir($resolved_path)) {
throw new RuntimeException(
"Asset bundle discovered via directory scan cannot have directory paths in 'include'.\n\n" .
"Bundle: {$bundle_class}\n" .
"Discovered by: {$parent_bundle_class}\n" .
"Invalid include: {$item}\n\n" .
"Asset bundles discovered via directory scan can only include:\n" .
" - Direct file paths (e.g., 'rsx/lib/file.js')\n" .
" - CDN assets (cdn_assets key)\n" .
" - NPM modules (npm key)\n" .
" - Watch directories (watch key - for cache invalidation only)\n" .
" - Other Asset Bundles by class name\n\n" .
"If this bundle requires directory scanning, include it explicitly\n" .
"in the parent module bundle's include array:\n\n" .
" class {$parent_bundle_class} extends Rsx_Module_Bundle_Abstract {\n" .
" public static function define(): array {\n" .
" return [\n" .
" 'include' => [\n" .
" '{$bundle_class}', // Explicit include\n" .
" // ... other includes\n" .
" ],\n" .
" ];\n" .
" }\n" .
" }"
);
}
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\RSpade\Core\Bundle;
use RuntimeException;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Rsx_Module_Bundle_Abstract - Base class for top-level module bundles
*
* Module bundles are the entry point bundles that get compiled and rendered
* on pages. They can scan directories and include other bundles (asset bundles
* only - not other module bundles).
*
* CAPABILITIES:
* - Directory scanning via 'include' paths
* - Explicit inclusion of Asset Bundles by class name
* - Auto-discovery of Asset Bundles in scanned directories
* - Gets built via rsx:bundle:build
*
* RESTRICTIONS:
* - Cannot include other Module Bundles (fatal error)
*
* USAGE:
* class Frontend_Bundle extends Rsx_Module_Bundle_Abstract {
* public static function define(): array {
* return [
* 'include' => [
* __DIR__, // Directory scan
* 'rsx/theme', // Directory scan (auto-discovers asset bundles)
* 'Bootstrap5_Src_Bundle', // Explicit asset bundle
* ],
* ];
* }
* }
*
* @see Rsx_Asset_Bundle_Abstract for dependency declaration bundles
*/
abstract class Rsx_Module_Bundle_Abstract extends Rsx_Bundle_Abstract
{
/**
* Validate that this bundle doesn't include other module bundles
*
* Called by BundleCompiler when resolving includes.
*
* @param string $included_bundle_class The bundle class being included
* @param string $parent_bundle_class The module bundle doing the including
* @throws RuntimeException if trying to include another module bundle
*/
public static function validate_include(string $included_bundle_class, string $parent_bundle_class): void
{
// Check if the included bundle is a module bundle
if (is_subclass_of($included_bundle_class, self::class)) {
throw new RuntimeException(
"Module bundle cannot include another module bundle.\n\n" .
"Parent bundle: {$parent_bundle_class}\n" .
"Attempted to include: {$included_bundle_class}\n\n" .
"Module bundles are top-level entry points and cannot be nested.\n" .
"If you need shared code between module bundles, create an Asset Bundle\n" .
"that both module bundles can include.\n\n" .
"Example:\n" .
" class Shared_Assets_Bundle extends Rsx_Asset_Bundle_Abstract {\n" .
" public static function define(): array {\n" .
" return [\n" .
" 'cdn_assets' => [...],\n" .
" 'npm' => [...],\n" .
" ];\n" .
" }\n" .
" }"
);
}
}
}

View File

@@ -26,7 +26,7 @@ The framework provides application-wide middleware hooks via `Main_Abstract`:
These classes are ALWAYS available - never check for their existence:
- `Rsx_Manifest` - Manifest management
- `Rsx_Cache` - Client-side caching
- `Rsx_Storage` - Browser storage (session/local) with scoping
- `Rsx` - Core framework utilities
- All classes in `/app/RSpade/Core/Js/`

View File

@@ -17,10 +17,4 @@ class {{ js_class }} {
static on_app_ready() {
{{ js_class }}.init();
}
// static on_jqhtml_ready() {
// // Called after all JQHTML components have loaded and rendered
// // Use this if you need to interact with JQHTML components
// // Otherwise, use on_app_ready() for most initialization
// }
}

View File

@@ -17,6 +17,8 @@ use App\RSpade\Core\Models\Region_Model;
* Usage from widgets:
* let countries = await Rsx_Reference_Data_Controller.countries();
* let states = await Rsx_Reference_Data_Controller.states({country: 'US'});
*
* @auth-exempt Public reference data - needed for unauthenticated forms (e.g., user signup with state selector)
*/
class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
{

View File

@@ -110,13 +110,8 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
$metadata = isset($manifest_data['data']['files'][$file_path]) ? $manifest_data['data']['files'][$file_path] : [];
// Generate stub filename and paths
$stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js';
// Check if user has created their own JS class
$user_class_exists = static::_check_user_model_class_exists($class_name, $manifest_data);
// Use Base_ prefix if user class exists
$stub_class_name = $user_class_exists ? 'Base_' . $class_name : $class_name;
// Always use Base_ prefix - concrete classes are handled at bundle compilation time
$stub_class_name = 'Base_' . $class_name;
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
@@ -267,26 +262,6 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
return false;
}
/**
* Check if user has created a JavaScript model class
*/
private static function _check_user_model_class_exists(string $model_name, array $manifest_data): bool
{
// Check if there's a JS file with this class name in the manifest
foreach ($manifest_data['data']['files'] as $file_path => $metadata) {
if (isset($metadata['extension']) && $metadata['extension'] === 'js') {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
// Don't consider our own stubs
if (!isset($metadata['is_stub']) && !isset($metadata['is_model_stub'])) {
return true;
}
}
}
}
return false;
}
/**
* Sanitize model name for use as filename
*/
@@ -311,8 +286,19 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Get model instance to introspect
$model = new $fqcn();
// Get relationships
$relationships = $fqcn::get_relationships();
// Get relationships that are Ajax-fetchable
// Only include relationships with BOTH #[Relationship] AND #[Ajax_Endpoint_Model_Fetch]
$all_relationships = $fqcn::get_relationships();
$model_metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_fqcn($fqcn);
$fetchable_relationships = [];
foreach ($all_relationships as $rel_name) {
$method_data = $model_metadata['public_instance_methods'][$rel_name] ?? [];
if (isset($method_data['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$fetchable_relationships[] = $rel_name;
}
}
$relationships = $fetchable_relationships;
// Get enums
$enums = $fqcn::$enums ?? [];
@@ -323,18 +309,22 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
$columns = $manifest_data['data']['models'][$class_name]['columns'];
}
// Determine the base class to extend
// User can configure a custom base class that sits between stubs and Rsx_Js_Model
$js_model_base_class = config('rsx.js_model_base_class');
$extends_class = $js_model_base_class ?: 'Rsx_Js_Model';
// Start building the stub content
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
$content .= " */\n\n";
$content .= " * @Instantiatable\n";
$content .= " */\n";
$content .= "class {$stub_class_name} extends Rsx_Js_Model {\n";
$content .= "class {$stub_class_name} extends {$extends_class} {\n";
// Add static model name for API calls
$content .= " static get name() {\n";
$content .= " return '{$class_name}';\n";
$content .= " }\n\n";
// Add static __MODEL property for PHP model name resolution
$content .= " static __MODEL = '{$class_name}';\n\n";
// Generate enum constants and methods
foreach ($enums as $column => $enum_values) {
@@ -440,25 +430,31 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
$content .= " }\n\n";
}
// Generate static get_relationships() method
$relationships_json = json_encode(array_values($relationships));
$content .= " /**\n";
$content .= " * Get list of relationship names available on this model\n";
$content .= " * @returns {Array} Array of relationship method names\n";
$content .= " */\n";
$content .= " static get_relationships() {\n";
$content .= " return {$relationships_json};\n";
$content .= " }\n\n";
// Generate relationship methods
foreach ($relationships as $relationship) {
$content .= " /**\n";
$content .= " * Fetch {$relationship} relationship\n";
$content .= " * @returns {Promise} Related model instance(s) or false\n";
$content .= " * @returns {Promise} Related model instance(s), null, or empty array\n";
$content .= " */\n";
$content .= " async {$relationship}() {\n";
$content .= " if (!this.id) {\n";
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
$content .= " }\n\n";
$content .= " const response = await $.ajax({\n";
$content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n";
$content .= " method: 'POST',\n";
$content .= " dataType: 'json'\n";
$content .= " });\n\n";
$content .= " if (!response) return false;\n\n";
$content .= " // Convert response to model instance(s)\n";
$content .= " // Framework handles instantiation based on relationship type\n";
$content .= " return response;\n";
$content .= " }\n";
$content .= " return await Orm_Controller.fetch_relationship({\n";
$content .= " model: '{$class_name}',\n";
$content .= " id: this.id,\n";
$content .= " relationship: '{$relationship}'\n";
$content .= " });\n";
$content .= " }\n\n";
}

View File

@@ -188,6 +188,43 @@ abstract class Rsx_Model_Abstract extends Model
return parent::__get($key);
}
/**
* Magic isset for enum properties
*
* Required because PHP's ?? operator calls __isset() before __get().
* Without this, Eloquent's __isset() returns false for enum properties,
* causing ?? to use the default value without ever calling __get().
*
* @param string $key
* @return bool
*/
public function __isset($key)
{
// Check for enum magic properties
if (!empty(static::$enums)) {
foreach (static::$enums as $column => $enum_config) {
// field_enum_val
if ($key == $column . '_enum_val') {
return true;
}
// field_label, field_constant, field_* (any custom enum property)
foreach ($enum_config as $enum_val => $enum_properties) {
foreach ($enum_properties as $prop_name => $prop_value) {
if ($key == $column . '_' . $prop_name) {
// Property exists if current column value matches this enum value
if ($this->$column == $enum_val) {
return true;
}
}
}
}
}
}
return parent::__isset($key);
}
/**
* Magic static method handler for enum methods
*

View File

@@ -0,0 +1,341 @@
<?php
namespace App\RSpade\Core\Database;
use Exception;
use Illuminate\Http\Request;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* ORM Controller for JavaScript model fetch operations
*
* Provides Ajax endpoints for the JavaScript ORM (Rsx_Js_Model) to fetch
* model records. Uses the standard #[Ajax_Endpoint] pattern to ensure
* proper request/response wrapping.
*
* SECURITY NOTICE: This controller is a major data egress point in the system.
* It will be a target for vulnerability scanning and enumeration attacks.
* All methods must be written defensively to prevent information disclosure.
*
* @auth-exempt Auth is handled per-model via the fetch() method which must have #[Ajax_Endpoint_Model_Fetch]
*/
class Orm_Controller extends Rsx_Controller_Abstract
{
/**
* Fetch model record by ID
*
* Called by Rsx_Js_Model.fetch() in JavaScript.
* Model must have #[Ajax_Endpoint_Model_Fetch] attribute on its fetch() method.
*
* SECURITY: This endpoint is a primary target for enumeration attacks.
* Error responses MUST be identical regardless of failure reason to prevent
* attackers from discovering valid class names or system architecture:
* - Class doesn't exist "Model not found"
* - Class exists but isn't a model "Model not found"
* - Model exists but missing attribute "Model not found"
* All failures return the same generic error to prevent information leakage.
*
* @param Request $request
* @param array $params Expected: model (string), id (int)
* @return mixed Model data or false
*/
#[Ajax_Endpoint]
public static function fetch(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
$id = $params['id'] ?? null;
if (!$model_name) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'Model name is required');
}
if ($id === null) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'ID parameter is required');
}
// Look up the PHP model class from manifest
try {
$model_metadata = Manifest::php_get_metadata_by_class($model_name);
} catch (\Exception $e) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model {$model_name} not found");
}
$model_class = $model_metadata['fqcn'] ?? null;
if (!$model_class) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model {$model_name} not found");
}
// Verify it's a subclass of Rsx_Model_Abstract (security check)
// SECURITY: Use same error message as "not found" to prevent enumeration
if (!Manifest::php_is_subclass_of($model_name, 'Rsx_Model_Abstract')) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model {$model_name} not found");
}
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute
// SECURITY EXCEPTION: This error reveals the model exists but lacks the attribute.
// This is acceptable because: (1) attacker already knows model name from client code,
// (2) detailed message is essential for developers diagnosing configuration issues,
// (3) knowing a model lacks fetch() doesn't expose exploitable information.
$has_fetch_attribute = false;
if (isset($model_metadata['public_static_methods']['fetch'])) {
$fetch_method = $model_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$has_fetch_attribute = true;
}
}
if (!$has_fetch_attribute) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, "Model {$model_name} fetch() method missing Ajax_Endpoint_Model_Fetch attribute");
}
$result = $model_class::fetch($id);
// Handle not found / access denied (false or null from fetch())
if ($result === false || $result === null) {
// Check if caller wants null instead of exception
$or_null = $params['or_null'] ?? false;
if ($or_null) {
return null;
}
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found");
}
// Validate array returns include __MODEL for JavaScript hydration
if (is_array($result) && !isset($result['__MODEL'])) {
throw new \App\RSpade\Core\Exception\Rsx_Exception(
"Model {$model_name}::fetch() returned an array without __MODEL property. " .
"JavaScript ORM requires __MODEL for class hydration. " .
"Fix: Call \$model->toArray() on the Eloquent model first, then augment the array before returning. " .
"Example: \$data = \$model->toArray(); \$data['computed_field'] = 'value'; return \$data;"
);
}
return $result;
}
/**
* Fetch related records via a model's relationship method
*
* Called by JavaScript relationship methods in Base_*_Model stubs.
* Validates source model is fetchable, then calls the relationship,
* and passes each related record through its own fetch() for security.
*
* Flow:
* 1. Validate source model has fetch attribute
* 2. Call source model's fetch() to verify access to parent record
* 3. Call relationship method to get related IDs
* 4. For each related record, call its model's fetch() for security
* 5. Return hydrated results (single object or array)
*
* @param Request $request
* @param array $params Expected: model (string), id (int), relationship (string)
* @return mixed Related model data or false
*/
#[Ajax_Endpoint]
public static function fetch_relationship(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
$id = $params['id'] ?? null;
$relationship_name = $params['relationship'] ?? null;
// Validate required parameters
if (!$model_name) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'Model name is required');
}
if ($id === null) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'ID parameter is required');
}
if (!$relationship_name) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION, 'Relationship name is required');
}
// Look up the PHP model class from manifest
try {
$model_metadata = Manifest::php_get_metadata_by_class($model_name);
} catch (\Exception $e) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model not found");
}
$model_class = $model_metadata['fqcn'] ?? null;
if (!$model_class) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model not found");
}
// Verify it's a subclass of Rsx_Model_Abstract
if (!Manifest::php_is_subclass_of($model_name, 'Rsx_Model_Abstract')) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Model not found");
}
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute
$has_fetch_attribute = false;
if (isset($model_metadata['public_static_methods']['fetch'])) {
$fetch_method = $model_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$has_fetch_attribute = true;
}
}
if (!$has_fetch_attribute) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, "Model fetch() not available");
}
// Verify the relationship exists and is fetchable
// Check if method has #[Relationship] attribute
$has_relationship_attr = false;
$has_fetch_attr_on_rel = false;
if (isset($model_metadata['public_instance_methods'][$relationship_name])) {
$rel_method = $model_metadata['public_instance_methods'][$relationship_name];
$has_relationship_attr = isset($rel_method['attributes']['Relationship']);
$has_fetch_attr_on_rel = isset($rel_method['attributes']['Ajax_Endpoint_Model_Fetch']);
}
if (!$has_relationship_attr) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "No such relationship: {$relationship_name}");
}
if (!$has_fetch_attr_on_rel) {
return response_error(
\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED,
"Relationship '{$relationship_name}' does not have the #[Ajax_Endpoint_Model_Fetch] attribute. " .
"Add this attribute to the relationship method before it can be fetched from JavaScript."
);
}
// Fetch the source record using its fetch() method (security check)
// This validates that the current user has access to this record.
$fetch_result = $model_class::fetch($id);
if (!$fetch_result) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found or access denied");
}
// fetch() may return an Eloquent model OR an augmented array.
// If it's an array, we need to verify access was granted and re-fetch
// the actual Eloquent model to call relationship methods on it.
//
// INEFFICIENCY NOTE: When fetch() returns an array, we're hitting the
// database twice - once for fetch() access check, once for the model.
// TODO: Implement Laravel query caching so the second fetch() call
// returns the cached model from the first query, eliminating the
// duplicate database hit.
if (is_array($fetch_result)) {
// Verify the array contains a valid id matching our request
// This confirms fetch() actually returned data for this record
// (not, for example, false cast to array or invalid data)
if (!isset($fetch_result['id']) || $fetch_result['id'] != $id) {
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found or access denied");
}
// Access granted - now fetch the actual Eloquent model for relationships
$source_record = $model_class::find($id);
if (!$source_record) {
// Shouldn't happen if fetch() succeeded, but defensive check
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, "Record not found");
}
} else {
// fetch() returned an Eloquent model directly - use it
$source_record = $fetch_result;
}
// Call the relationship method
$relation = $source_record->$relationship_name();
// Determine if this is a singular or plural relationship
$is_singular = $relation instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo
|| $relation instanceof \Illuminate\Database\Eloquent\Relations\HasOne
|| $relation instanceof \Illuminate\Database\Eloquent\Relations\MorphOne
|| $relation instanceof \Illuminate\Database\Eloquent\Relations\MorphTo;
// Get just the IDs first (efficient single query)
if ($is_singular) {
// For singular relationships, get the foreign key value directly
if ($relation instanceof \Illuminate\Database\Eloquent\Relations\MorphTo) {
$related_id = $source_record->{$relation->getForeignKeyName()};
$related_type = $source_record->{$relation->getMorphType()};
if (!$related_id || !$related_type) {
return null;
}
// For morphTo, we need to resolve the related model class
$related_class = $related_type;
} else {
$related_id = $relation->getQuery()->getQuery()->first()?->id ?? null;
if (!$related_id) {
return null;
}
$related_class = get_class($relation->getRelated());
}
// Get simple class name for fetch
$related_class_parts = explode('\\', $related_class);
$related_model_name = end($related_class_parts);
// Check if related model has fetch() with attribute
try {
$related_metadata = Manifest::php_get_metadata_by_class($related_model_name);
} catch (\Exception $e) {
return null;
}
$related_has_fetch = false;
if (isset($related_metadata['public_static_methods']['fetch'])) {
$fetch_method = $related_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$related_has_fetch = true;
}
}
if (!$related_has_fetch) {
return null;
}
// Fetch through the security filter
$related_fqcn = $related_metadata['fqcn'];
return $related_fqcn::fetch($related_id);
} else {
// For plural relationships, get all IDs efficiently
$related_ids = $relation->pluck('id')->toArray();
if (empty($related_ids)) {
return [];
}
// Get the related model class
$related_class = get_class($relation->getRelated());
$related_class_parts = explode('\\', $related_class);
$related_model_name = end($related_class_parts);
// Check if related model has fetch() with attribute
try {
$related_metadata = Manifest::php_get_metadata_by_class($related_model_name);
} catch (\Exception $e) {
return [];
}
$related_has_fetch = false;
if (isset($related_metadata['public_static_methods']['fetch'])) {
$fetch_method = $related_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$related_has_fetch = true;
}
}
if (!$related_has_fetch) {
return [];
}
// Fetch each record through its security filter
// TODO: Optimize with batch prefetch cache (see model_fetch.txt TODO)
$related_fqcn = $related_metadata['fqcn'];
$results = [];
foreach ($related_ids as $related_id) {
$record = $related_fqcn::fetch($related_id);
if ($record) {
$results[] = $record;
}
}
return $results;
}
}
}

View File

@@ -11,6 +11,8 @@ use App\RSpade\Core\Debug\Debugger;
*
* Handles AJAX requests from JavaScript for console_debug and error logging.
* Delegates to the Debugger utility class for actual implementation.
*
* @auth-exempt Dev tool for logging client-side debug info - must work for unauthenticated states (e.g., login page debugging). Controlled by config flags.
*/
class Debugger_Controller extends Rsx_Controller_Abstract
{

View File

@@ -17,6 +17,8 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
*
* Thin wrapper that routes /_ajax/:controller/:action requests to the Ajax class.
* The actual logic is consolidated in Ajax::handle_browser_request() for better organization.
*
* @auth-exempt Routes requests to #[Ajax_Endpoint] methods - each endpoint has its own auth checks
*/
class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
{
@@ -25,7 +27,6 @@ class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
*
* Routes /_ajax/:controller/:action to Ajax::handle_browser_request()
*/
#[Auth('Permission::anybody()')]
#[Route('/_ajax/:controller/:action', methods: ['POST'])]
public static function dispatch(Request $request, array $params = [])
{
@@ -51,223 +52,4 @@ class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
return Ajax::is_ajax_response_mode();
}
/**
* Handle model fetch requests from JavaScript ORM
*
* Routes /_fetch/:model to the model's fetch() method
* Model must have #[Ajax_Endpoint_Model_Fetch] annotation on its fetch() method
*/
#[Auth('Permission::anybody()')]
#[Route('/_fetch/:model', methods: ['POST'])]
public static function fetch_model(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
if (!$model_name) {
return response()->json(['error' => 'Model name is required'], 400);
}
// Look up the model class from manifest
try {
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$model_class = null;
// Find the model class in manifest
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_class = $metadata['fqcn'] ?? null;
break;
}
}
if (!$model_class) {
return response()->json(['error' => "Model {$model_name} not found"], 404);
}
// Check if model extends Rsx_Model_Abstract using manifest
$extends = $model_metadata['extends'] ?? null;
$is_rsx_model = false;
// Check direct extension
if ($extends === 'Rsx_Model_Abstract') {
$is_rsx_model = true;
} else {
// Check indirect extension via manifest
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($extending_models as $extending_model) {
if ($extending_model['class'] === $model_name) {
$is_rsx_model = true;
break;
}
}
}
if (!$is_rsx_model) {
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
}
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute using manifest
$model_metadata = null;
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_metadata = $metadata;
break;
}
}
if (!$model_metadata) {
return response()->json(['error' => "Model {$model_name} metadata not found in manifest"], 500);
}
// Check if fetch method exists and has the attribute
$has_fetch_attribute = false;
if (isset($model_metadata['public_static_methods']['fetch'])) {
$fetch_method = $model_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes'])) {
// Check if Ajax_Endpoint_Model_Fetch attribute exists
// Attributes are stored as associative array with attribute name as key
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$has_fetch_attribute = true;
}
}
}
if (!$has_fetch_attribute) {
return response()->json(['error' => "Model {$model_name} fetch() method missing Ajax_Endpoint_Model_Fetch attribute"], 403);
}
// Get the ID parameter
$id = $request->input('id');
if ($id === null) {
return response()->json(['error' => 'ID parameter is required'], 400);
}
// Handle arrays by calling fetch() for each ID
if (is_array($id)) {
$results = [];
foreach ($id as $single_id) {
$model = $model_class::fetch($single_id);
if ($model !== false) {
// Convert to array if it's an object
if (is_object($model) && method_exists($model, 'toArray')) {
$results[] = $model->toArray();
} else {
$results[] = $model;
}
}
}
return response()->json($results);
}
// Single ID - call fetch() directly
$result = $model_class::fetch($id);
if ($result === false) {
return response()->json(false);
}
// Convert to array for JSON response
if (is_object($result) && method_exists($result, 'toArray')) {
return response()->json($result->toArray());
}
// Return as-is if not an object with toArray
return response()->json($result);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Handle model relationship fetch requests
*
* Routes /_fetch_rel/:model/:id/:relationship to fetch relationship data
* Model must have the relationship defined
*/
#[Auth('Permission::anybody()')]
#[Route('/_fetch_rel/:model/:id/:relationship', methods: ['POST'])]
public static function fetch_relationship(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
$id = $params['id'] ?? null;
$relationship = $params['relationship'] ?? null;
if (!$model_name || !$id || !$relationship) {
return response()->json(['error' => 'Model name, ID, and relationship are required'], 400);
}
try {
// Look up the model class from manifest
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$model_class = null;
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_class = $metadata['fqcn'] ?? null;
break;
}
}
if (!$model_class) {
return response()->json(['error' => "Model {$model_name} not found"], 404);
}
// Check if model extends Rsx_Model_Abstract using manifest
$extends = $model_metadata['extends'] ?? null;
$is_rsx_model = false;
// Check direct extension
if ($extends === 'Rsx_Model_Abstract') {
$is_rsx_model = true;
} else {
// Check indirect extension via manifest
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($extending_models as $extending_model) {
if ($extending_model['class'] === $model_name) {
$is_rsx_model = true;
break;
}
}
}
if (!$is_rsx_model) {
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
}
// Fetch the base model
$model = $model_class::find($id);
if (!$model) {
return response()->json(['error' => "Record with ID {$id} not found"], 404);
}
// Check if relationship exists using get_relationships()
$relationships = $model_class::get_relationships();
if (!in_array($relationship, $relationships)) {
return response()->json(['error' => "Relationship {$relationship} not found on model {$model_name}"], 404);
}
// Call the relationship method directly and get the results
if (!method_exists($model, $relationship)) {
return response()->json(['error' => "Relationship method {$relationship} does not exist on model {$model_name}"], 500);
}
// Load the relationship and return the data
$result = $model->$relationship;
// Handle different result types
if ($result instanceof \Illuminate\Database\Eloquent\Collection) {
return response()->json($result->map->toArray());
} elseif ($result instanceof \Illuminate\Database\Eloquent\Model) {
return response()->json($result->toArray());
} elseif ($result === null) {
return response()->json(null);
}
return response()->json(['error' => "Unable to load relationship {$relationship}"], 500);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
}

View File

@@ -273,12 +273,6 @@ class Dispatcher
$handler_method = $route_match['method'];
$params = $route_match['params'] ?? [];
// Check for SSR Full Page Cache (FPC) before any processing
$fpc_response = static::__check_ssr_fpc($url, $handler_class, $handler_method, $request);
if ($fpc_response !== null) {
return static::__transform_response($fpc_response, $original_method);
}
// Merge parameters with correct priority order:
// 1. Extra parameters (usually empty, lowest priority)
// 2. GET parameters (from query string)
@@ -301,43 +295,9 @@ class Dispatcher
// Load and validate handler class
static::__load_handler_class($handler_class);
// Check controller pre_dispatch Auth attributes
$pre_dispatch_requires = Manifest::get_pre_dispatch_requires($handler_class);
foreach ($pre_dispatch_requires as $require_attr) {
$result = static::__check_require($require_attr, $request, $params, $handler_class, 'pre_dispatch');
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
// Check route method Auth attributes
$route_requires = $route_match['require'] ?? [];
// Validate that at least one Auth exists (either on route or pre_dispatch)
if (empty($route_requires) && empty($pre_dispatch_requires)) {
throw new RuntimeException(
"Route method '{$handler_class}::{$handler_method}' is missing required #[\\Auth] attribute.\n\n" .
"All routes must specify access control using #[\\Auth('Permission::method()')].\n\n" .
"Examples:\n" .
" #[\\Auth('Permission::anybody()')] // Public access\n" .
" #[\\Auth('Permission::authenticated()')] // Must be logged in\n" .
" #[\\Auth('Permission::has_role(\"admin\")')] // Custom check with args\n\n" .
"Alternatively, add #[\\Auth] to pre_dispatch() to apply to all routes in this controller.\n\n" .
"To create a permission method, add to rsx/permission.php:\n" .
" public static function custom_check(Request \$request, array \$params): mixed {\n" .
" return RsxAuth::check(); // true = allow, false = deny\n" .
" }"
);
}
foreach ($route_requires as $require_attr) {
$result = static::__check_require($require_attr, $request, $params, $handler_class, $handler_method);
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
// Permission checks are now handled manually in controller pre_dispatch() methods
// and within individual route methods. See: php artisan rsx:man auth
// A code quality rule (rsx:check) verifies auth checks exist.
// Call pre_dispatch hooks
$pre_dispatch_result = static::__call_pre_dispatch($handler_class, $handler_method, $params, $request);
@@ -1034,422 +994,4 @@ class Dispatcher
throw new RuntimeException($error_msg);
}
}
/**
* Check a Auth attribute and execute the permission check
*
* @param array $require_attr The Auth attribute arguments
* @param Request $request
* @param array $params
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @return mixed|null Returns response to halt dispatch, or null to continue
* @throws RuntimeException on parsing or execution errors
*/
protected static function __check_require(array $require_attr, Request $request, array $params, string $handler_class, string $handler_method)
{
// Extract parameters - first arg is callable string, rest are named params
$callable_str = $require_attr[0] ?? null;
$message = $require_attr['message'] ?? null;
$redirect = $require_attr['redirect'] ?? null;
$redirect_to = $require_attr['redirect_to'] ?? null;
if (!$callable_str) {
throw new RuntimeException(
"Auth attribute on {$handler_class}::{$handler_method} is missing callable string.\n" .
"Expected: #[Auth('Permission::method()')]\n" .
"Got: " . json_encode($require_attr)
);
}
if ($redirect && $redirect_to) {
throw new RuntimeException(
"Auth attribute on {$handler_class}::{$handler_method} cannot specify both 'redirect' and 'redirect_to'.\n" .
"Use either redirect: '/path' OR redirect_to: ['Controller', 'action']"
);
}
// Parse callable string - FATAL if parsing fails
[$class, $method, $args] = static::__parse_require_callable($callable_str, $handler_class, $handler_method);
// Verify permission class and method exist
if (!class_exists($class)) {
throw new RuntimeException(
"Permission class '{$class}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
"Make sure the class exists and is loaded by the manifest."
);
}
if (!method_exists($class, $method)) {
throw new RuntimeException(
"Permission method '{$class}::{$method}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
"Add this method to {$class}:\n" .
" public static function {$method}(Request \$request, array \$params): mixed {\n" .
" return true; // or false to deny\n" .
" }"
);
}
// Call permission method
try {
$result = $class::$method($request, $params, ...$args);
} catch (\Throwable $e) {
throw new RuntimeException(
"Permission check '{$class}::{$method}' threw exception for {$handler_class}::{$handler_method}:\n" .
$e->getMessage(),
0,
$e
);
}
// Handle result
if ($result === true || $result === null) {
return null; // Pass - continue to next check or route
}
if ($result instanceof \Symfony\Component\HttpFoundation\Response) {
return $result; // Custom response from permission method
}
// Permission failed - detect if Ajax context
$is_ajax = ($handler_class === 'App\\RSpade\\Core\\Dispatch\\Ajax_Endpoint_Controller') ||
str_starts_with($request->path(), '_ajax/') ||
str_starts_with($request->path(), '_fetch/');
if ($is_ajax) {
// Ajax context - return JSON error, ignore redirect parameters
return response()->json([
'success' => false,
'error' => $message ?? 'Permission denied',
'error_type' => 'permission_denied'
], 403);
}
// Regular HTTP context - handle redirect/message
if ($redirect_to) {
if (!is_array($redirect_to) || count($redirect_to) < 1) {
throw new RuntimeException(
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
);
}
$action = $redirect_to[0];
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
$action .= '::' . $redirect_to[1];
}
$url = Rsx::Route($action);
if ($message) {
Rsx::flash_error($message);
}
return redirect($url);
}
if ($redirect) {
if ($message) {
Rsx::flash_error($message);
}
return redirect($redirect);
}
// Default: throw 403 Forbidden
throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, $message ?? 'Forbidden');
}
/**
* Parse Auth callable string into class, method, and args
*
* Supports formats:
* - Permission::method()
* - Permission::method(3)
* - Permission::method("string")
* - Permission::method(1, "arg2", 3)
*
* FATAL on parse failure
*
* @param string $str Callable string
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @return array [class, method, args]
* @throws RuntimeException on parse failure
*/
protected static function __parse_require_callable(string $str, string $handler_class, string $handler_method): array
{
// Pattern: ClassName::method_name(args)
if (!preg_match('/^([A-Za-z_][A-Za-z0-9_\\\\]*)::([a-z_][a-z0-9_]*)\((.*)\)$/i', $str, $matches)) {
throw new RuntimeException(
"Failed to parse Auth callable on {$handler_class}::{$handler_method}.\n\n" .
"Invalid format: '{$str}'\n\n" .
"Expected format: 'ClassName::method_name()' or 'ClassName::method_name(arg1, arg2)'\n\n" .
"Examples:\n" .
" Permission::anybody()\n" .
" Permission::authenticated()\n" .
" Permission::has_role(\"admin\")\n" .
" Permission::has_permission(3, \"write\")"
);
}
$class = $matches[1];
$method = $matches[2];
$args_str = trim($matches[3]);
// Resolve class name if not fully qualified
if (strpos($class, '\\') === false) {
// Try to find the class using manifest discovery
try {
$metadata = Manifest::php_get_metadata_by_class($class);
if (isset($metadata['fqcn'])) {
$class = $metadata['fqcn'];
}
} catch (\RuntimeException $e) {
// Class not found in manifest - leave as-is and let class_exists check fail later with better error
}
}
$args = [];
if ($args_str !== '') {
$args = static::__parse_args($args_str, $handler_class, $handler_method, $str);
}
return [$class, $method, $args];
}
/**
* Parse argument list from Auth callable
*
* Supports: integers, quoted strings, simple values
* FATAL on parse failure
*
* @param string $args_str Argument list string
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @param string $full_callable For error messages
* @return array Parsed arguments
* @throws RuntimeException on parse failure
*/
protected static function __parse_args(string $args_str, string $handler_class, string $handler_method, string $full_callable): array
{
$args = [];
$current_arg = '';
$in_quotes = false;
$quote_char = null;
$escaped = false;
for ($i = 0; $i < strlen($args_str); $i++) {
$char = $args_str[$i];
if ($escaped) {
$current_arg .= $char;
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if (!$in_quotes && ($char === '"' || $char === "'")) {
$in_quotes = true;
$quote_char = $char;
continue;
}
if ($in_quotes && $char === $quote_char) {
$in_quotes = false;
$quote_char = null;
continue;
}
if (!$in_quotes && $char === ',') {
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
$current_arg = '';
continue;
}
$current_arg .= $char;
}
if ($in_quotes) {
throw new RuntimeException(
"Failed to parse Auth arguments on {$handler_class}::{$handler_method}.\n\n" .
"Unclosed quote in: '{$full_callable}'\n\n" .
"Make sure all quoted strings are properly closed."
);
}
if ($current_arg !== '') {
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
}
return $args;
}
/**
* Convert argument string to appropriate type
*
* @param string $value
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @param string $full_callable For error messages
* @return mixed
*/
protected static function __convert_arg_value(string $value, string $handler_class, string $handler_method, string $full_callable)
{
if ($value === '') {
throw new RuntimeException(
"Empty argument in Auth on {$handler_class}::{$handler_method}.\n" .
"Callable: '{$full_callable}'"
);
}
// Integer
if (preg_match('/^-?\d+$/', $value)) {
return (int) $value;
}
// Float
if (preg_match('/^-?\d+\.\d+$/', $value)) {
return (float) $value;
}
// Boolean
if ($value === 'true') {
return true;
}
if ($value === 'false') {
return false;
}
// Null
if ($value === 'null') {
return null;
}
// Everything else is a string
return $value;
}
/**
* Check for SSR Full Page Cache and serve if available
*
* @param string $url
* @param string $handler_class
* @param string $handler_method
* @param Request $request
* @return Response|null Returns response if FPC should be served, null otherwise
*/
protected static function __check_ssr_fpc(string $url, string $handler_class, string $handler_method, Request $request)
{
console_debug('SSR_FPC', "FPC check started for {$url}");
// Check if SSR FPC is enabled
if (!config('rsx.ssr_fpc.enabled', false)) {
console_debug('SSR_FPC', 'FPC disabled in config');
return null;
}
// Check if this is the FPC client (prevent infinite loop)
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
console_debug('SSR_FPC', 'Skipping FPC - is FPC client');
return null;
}
// Check if user has a session (only serve FPC to users without sessions)
if (\App\RSpade\Core\Session\Session::has_session()) {
console_debug('SSR_FPC', 'Skipping FPC - user has session');
return null;
}
console_debug('SSR_FPC', 'User has no session, continuing...');
// FEATURE DISABLED: SSR Full Page Cache is not yet complete
// This feature will be finished at a later point
// See docs.dev/SSR_FPC.md for implementation details and current state
// Disable by always returning false for Static_Page check
$has_static_page = false;
if (!$has_static_page) {
console_debug('SSR_FPC', 'SSR FPC feature is disabled - will be completed later');
return null;
}
// Check if user is authenticated (only serve FPC to unauthenticated users)
// TODO: Should use has_session() instead of is_logged_in(), but using is_logged_in() for now to simplify debugging
if (\App\RSpade\Core\Session\Session::is_logged_in()) {
return null;
}
// Strip GET parameters from URL for cache key
$clean_url = parse_url($url, PHP_URL_PATH) ?: $url;
// Get build key from manifest
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
// Generate Redis cache key
$url_hash = sha1($clean_url);
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
// Check if cache exists
try {
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
if (!$cache_data) {
// Cache doesn't exist - generate it
console_debug('SSR_FPC', "Cache miss for {$clean_url}, generating...");
\Illuminate\Support\Facades\Artisan::call('rsx:ssr_fpc:create', [
'url' => $clean_url
]);
// Read the newly generated cache
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
if (!$cache_data) {
throw new \RuntimeException("Failed to generate SSR FPC cache for route: {$clean_url}");
}
}
// Parse cache data
$cache = json_decode($cache_data, true);
if (!$cache) {
throw new \RuntimeException("Invalid SSR FPC cache data for route: {$clean_url}");
}
// Check ETag for 304 response
$client_etag = $request->header('If-None-Match');
if ($client_etag && $client_etag === $cache['etag']) {
return response('', 304)
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('Cache-Control', app()->environment('production') ? 'public, max-age=300' : 'no-cache, must-revalidate');
}
// Determine cache control header
$cache_control = app()->environment('production')
? 'public, max-age=300' // 5 min in production
: 'no-cache, must-revalidate'; // 0s in dev
// Handle redirect response
if ($cache['code'] >= 300 && $cache['code'] < 400 && $cache['redirect']) {
return response('', $cache['code'])
->header('Location', $cache['redirect'])
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('Cache-Control', $cache_control);
}
// Serve static HTML
console_debug('SSR_FPC', "Serving cached page for {$clean_url}");
return response($cache['page_dom'], $cache['code'])
->header('Content-Type', 'text/html; charset=UTF-8')
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('X-FPC-Debug', 'served-from-cache')
->header('Cache-Control', $cache_control);
} catch (\Exception $e) {
// Log error and let request proceed normally
\Illuminate\Support\Facades\Log::error('SSR FPC error: ' . $e->getMessage());
throw new \RuntimeException('SSR FPC generation failed: ' . $e->getMessage(), 0, $e);
}
}
}

View File

@@ -15,12 +15,14 @@ use App\RSpade\Core\Session\Session;
*
* Unified controller for file attachment operations: upload, download, viewing, and thumbnails.
*
* @auth-exempt Auth is handled via event hooks (file.upload.authorize, file.download.authorize, etc.)
*
* ================================================================================================
* UPLOAD ENDPOINT
* ================================================================================================
*
* POST /_upload
* AUTH: Permission::anybody() - Authorization is handled via event hooks
* AUTH: Handled via file.upload.authorize event hook
*
* REQUEST PARAMETERS:
* - file (required) - The file to upload
@@ -947,7 +949,6 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
* @return Response PNG image data
*/
#[Route('/_icon_by_extension/:extension', methods: ['GET'])]
#[Auth('Permission::anybody()')]
public static function icon_by_extension(Request $request, array $params = [])
{
$extension = $params['extension'] ?? '';

View File

@@ -14,8 +14,9 @@ class Ajax {
static ERROR_AUTH_REQUIRED = 'auth_required';
static ERROR_FATAL = 'fatal';
static ERROR_GENERIC = 'generic';
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
static ERROR_PHP_EXCEPTION = 'php_exception'; // Client-generated (PHP exception with file/line/backtrace)
/**
* Initialize Ajax system
@@ -208,7 +209,8 @@ class Ajax {
resolve(processed_value);
} else {
// Handle error responses
const error_code = response.error_code || Ajax.ERROR_GENERIC;
// Server may use error_code or error_type
const error_code = response.error_code || response.error_type || Ajax.ERROR_GENERIC;
const reason = response.reason || 'An error occurred';
const metadata = response.metadata || {};
@@ -217,13 +219,26 @@ class Ajax {
error.code = error_code;
error.metadata = metadata;
// Handle fatal errors specially
// Handle fatal errors specially - detect PHP exceptions
if (error_code === Ajax.ERROR_FATAL) {
const fatal_error_data = response.error || {};
error.message = fatal_error_data.error || 'Fatal error occurred';
error.metadata = response.error;
console.error('Ajax error response from server:', response.error);
// Check if this is a PHP exception (has file, line, error, backtrace)
if (fatal_error_data.file && fatal_error_data.line && fatal_error_data.error) {
error.code = Ajax.ERROR_PHP_EXCEPTION;
error.message = fatal_error_data.error;
error.metadata = {
file: fatal_error_data.file,
line: fatal_error_data.line,
error: fatal_error_data.error,
backtrace: fatal_error_data.backtrace || []
};
console.error('PHP Exception:', fatal_error_data.error, 'at', fatal_error_data.file + ':' + fatal_error_data.line);
} else {
error.message = fatal_error_data.error || 'Fatal error occurred';
error.metadata = response.error;
console.error('Ajax error response from server:', response.error);
}
// Log to server
Debugger.log_error({
@@ -375,7 +390,8 @@ class Ajax {
});
} else {
// Handle error
const error_code = call_response.error_code || Ajax.ERROR_GENERIC;
// Server may use error_code or error_type
const error_code = call_response.error_code || call_response.error_type || Ajax.ERROR_GENERIC;
const error_message = call_response.reason || 'Unknown error occurred';
const metadata = call_response.metadata || {};
@@ -383,6 +399,28 @@ class Ajax {
error.code = error_code;
error.metadata = metadata;
// Handle fatal errors specially - detect PHP exceptions
if (error_code === Ajax.ERROR_FATAL) {
const fatal_error_data = call_response.error || {};
// Check if this is a PHP exception (has file, line, error, backtrace)
if (fatal_error_data.file && fatal_error_data.line && fatal_error_data.error) {
error.code = Ajax.ERROR_PHP_EXCEPTION;
error.message = fatal_error_data.error;
error.metadata = {
file: fatal_error_data.file,
line: fatal_error_data.line,
error: fatal_error_data.error,
backtrace: fatal_error_data.backtrace || []
};
console.error('PHP Exception:', fatal_error_data.error, 'at', fatal_error_data.file + ':' + fatal_error_data.line);
} else {
error.message = fatal_error_data.error || 'Fatal error occurred';
error.metadata = call_response.error;
console.error('Ajax error response from server:', call_response.error);
}
}
pending_call.is_error = true;
pending_call.error = error;

View File

@@ -1,210 +0,0 @@
// Simple key value cache. Can only store 5000 entries, will reset after 5000 entries.
// Todo: keep local cache concept the same, replace global cache concept with the nov 2019 version of
// session cache. Use a session key & build key to track cache keys so cached values only last until user logs out.
// review session code to ensure that session key *always* rotates on logout. Make session id a protected value.
class Rsx_Cache {
static on_core_define() {
Core_Cache._caches = {
global: {},
instance: {},
};
Core_Cache._caches_set = 0;
}
// Alias for get_instance
static get(key) {
return Rsx_Cache.get_instance(key);
}
// Returns from the pool of cached data for this 'instance'. An instance
// in this case is a virtual page load / navigation in the SPA. Call Main.lib.reset() to reset.
// Returns null on failure
static get_instance(key) {
if (Main.debug('no_api_cache')) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
if (typeof Core_Cache._caches.instance[key_encoded] != undef) {
return JSON.parse(Core_Cache._caches.instance[key_encoded]);
}
return null;
}
// Returns null on failure
// Returns a cached value from global cache (unique to page load, survives reset())
static get_global(key) {
if (Main.debug('no_api_cache')) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
if (typeof Core_Cache._caches.global[key_encoded] != undef) {
return JSON.parse(Core_Cache._caches.global[key_encoded]);
}
return null;
}
// Sets a value in instance and global cache (not shared between browser tabs)
static set(key, value) {
if (Main.debug('no_api_cache')) {
return;
}
if (value === null) {
return;
}
if (value.length > 64 * 1024) {
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
return;
}
let key_encoded = Rsx_Cache._encodekey(key);
Core_Cache._caches.global[key_encoded] = JSON.stringify(value);
Core_Cache._caches.instance[key_encoded] = JSON.stringify(value);
// Debugger.console_debug("CACHE", "Set", key, value);
Core_Cache._caches_set++;
// Reset cache after 5000 items set
if (Core_Cache._caches_set > 5000) {
// Get an accurate count
Core_Cache._caches_set = count(Core_Cache._caches.global);
if (Core_Cache._caches_set > 5000) {
Core_Cache._caches = {
global: {},
instance: {},
};
Core_Cache._caches_set = 0;
}
}
}
// Returns null on failure
// Returns a cached value from session cache (shared between browser tabs)
static get_session(key) {
if (Main.debug('no_api_cache')) {
return null;
}
if (!Rsx_Cache._supportsStorage()) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
let rs = sessionStorage.getItem(key_encoded);
if (!empty(rs)) {
return JSON.parse(rs);
} else {
return null;
}
}
// Sets a value in session cache (shared between browser tabs)
static set_session(key, value, _tryagain = true) {
if (Main.debug('no_api_cache')) {
return;
}
if (value.length > 64 * 1024) {
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
return;
}
if (!Rsx_Cache._supportsStorage()) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
try {
sessionStorage.removeItem(key_encoded);
sessionStorage.setItem(key_encoded, JSON.stringify(value));
} catch (e) {
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
sessionStorage.clear();
if (_tryagain) {
Core_Cache.set_session(key, value, false);
}
}
}
}
static _reset() {
Core_Cache._caches.instance = {};
}
/**
* For given key of any type including an object, return a string representing
* the key that the cached value should be stored as in sessionstorage
*/
static _encodekey(key) {
const prefix = 'cache_';
// Session reimplement
// var prefix = "cache_" + Spa.session().user_id() + "_";
if (is_string(key) && key.length < 150 && key.indexOf(' ') == -1) {
return prefix + Manifest.build_key() + '_' + key;
} else {
return prefix + hash([Manifest.build_key(), key]);
}
}
// Determines if sessionStorage is supported in the browser;
// result is cached for better performance instead of being run each time.
// Feature detection is based on how Modernizr does it;
// it's not straightforward due to FF4 issues.
// It's not run at parse-time as it takes 200ms in Android.
// Code from https://github.com/pamelafox/lscache/blob/master/lscache.js, Apache License Pamelafox
static _supportsStorage() {
let key = '__cachetest__';
let value = key;
if (Rsx_Cache.__supportsStorage !== undefined) {
return Rsx_Cache.__supportsStorage;
}
// some browsers will throw an error if you try to access local storage (e.g. brave browser)
// hence check is inside a try/catch
try {
if (!sessionStorage) {
return false;
}
} catch (ex) {
return false;
}
try {
sessionStorage.setItem(key, value);
sessionStorage.removeItem(key);
Rsx_Cache.__supportsStorage = true;
} catch (e) {
// If we hit the limit, and we don't have an empty sessionStorage then it means we have support
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
Rsx_Cache.__supportsStorage = true; // just maxed it out and even the set test failed.
} else {
Rsx_Cache.__supportsStorage = false;
}
}
return Rsx_Cache.__supportsStorage;
}
// Check to set if the error is us dealing with being out of space
static _isOutOfSpace(e) {
return e && (e.name === 'QUOTA_EXCEEDED_ERR' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || e.name === 'QuotaExceededError');
}
}

View File

@@ -10,13 +10,10 @@
* // Fetch single record
* const user = await User_Model.fetch(123);
*
* // Fetch multiple records
* const users = await User_Model.fetch([1, 2, 3]);
*
* // Create instance with data
* const user = new User_Model({id: 1, name: 'John'});
*
* @Instantiatable
* @Instantiatable
*/
class Rsx_Js_Model {
/**
@@ -42,40 +39,58 @@ class Rsx_Js_Model {
* The backend model must have a fetch() method with the
* #[Ajax_Endpoint_Model_Fetch] annotation to be callable.
*
* Throws an error if the record is not found or access is denied.
* Use fetch_or_null() if you want null instead of an exception.
*
* @param {number|Array} id - Single ID or array of IDs to fetch
* @returns {Promise} - Single model instance, array of instances, or false
* @returns {Promise} - Single model instance or array of instances
* @throws {Error} - If record not found or access denied
*/
static async fetch(id) {
const CurrentClass = this;
// Get the model class name from the current class
const modelName = CurrentClass.name;
// Get the PHP model name from the static __MODEL property
// This allows Base_Project_Model to correctly identify as "Project_Model" to PHP
const modelName = CurrentClass.__MODEL || CurrentClass.name;
const response = await $.ajax({
url: `/_fetch/${modelName}`,
method: 'POST',
data: { id: id },
dataType: 'json',
});
// Use the standard Ajax endpoint pattern via Orm_Controller
const response = await Orm_Controller.fetch({ model: modelName, id: id });
// Handle response based on type
if (response === false) {
return false;
}
// Use _instantiate_models_recursive to handle ORM instantiation
// This will automatically detect __MODEL properties and create appropriate instances
return Rsx_Js_Model._instantiate_models_recursive(response);
// Response is already hydrated by Ajax layer (Ajax.js calls _instantiate_models_recursive)
// Just return the response directly
return response;
}
/**
* Get the model class name
* Fetch record from the backend model, returning null if not found
*
* Same as fetch() but returns null instead of throwing an error
* when the record doesn't exist or access is denied.
*
* Use this when you're not sure if the record exists and want
* to handle the "not found" case gracefully without try/catch.
*
* @param {number} id - ID to fetch
* @returns {Promise} - Model instance or null if not found
*/
static async fetch_or_null(id) {
const CurrentClass = this;
const modelName = CurrentClass.__MODEL || CurrentClass.name;
// Pass or_null flag to get null instead of exception
const response = await Orm_Controller.fetch({ model: modelName, id: id, or_null: true });
return response;
}
/**
* Get the PHP model class name
* Used internally for API calls
*
* @returns {string} The class name
* @returns {string} The PHP model class name
*/
static getModelName() {
const CurrentClass = this;
return CurrentClass.name;
return CurrentClass.__MODEL || CurrentClass.name;
}
/**
@@ -131,20 +146,12 @@ class Rsx_Js_Model {
* Recursively instantiate ORM models in response data
*
* Looks for objects with __MODEL property and instantiates the appropriate
* JavaScript model class if it exists in the global scope.
* JavaScript model class from the Manifest registry.
*
* @param {*} data - The data to process (can be any type)
* @returns {*} The data with ORM objects instantiated
*/
static _instantiate_models_recursive(data) {
// __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.
// PHP models add "__MODEL": "ClassName" to JSON, JavaScript uses it to create proper instances.
// This provides typed model objects instead of plain JSON, with methods and type checking.
// This recursive processor scans all API response data looking for __MODEL markers.
// When found, it attempts to instantiate the appropriate JavaScript model class,
// converting {__MODEL: "User_Model", id: 1, name: "John"} into new User_Model({...}).
// Works recursively through arrays and nested objects to handle complex data structures.
// Handle null/undefined
if (data === null || data === undefined) {
return data;
@@ -159,12 +166,12 @@ class Rsx_Js_Model {
if (typeof data === 'object') {
// Check if this object has a __MODEL property
if (data.__MODEL && typeof data.__MODEL === 'string') {
// Try to find the model class in the global scope
const ModelClass = window[data.__MODEL];
// Look up the model class in the Manifest registry
const ModelClass = Manifest.get_class_by_name(data.__MODEL);
// If the model class exists and extends Rsx_Js_Model, instantiate it
// Dynamic model resolution requires checking class existence - @JS-DEFENSIVE-01-EXCEPTION
if (ModelClass && ModelClass.prototype instanceof Rsx_Js_Model) {
if (ModelClass && Manifest.js_is_subclass_of(ModelClass, Rsx_Js_Model)) {
return new ModelClass(data);
}
}

View File

@@ -591,6 +591,21 @@ class Manifest
return $lineage;
}
/**
* Check if a class name corresponds to a PHP model class (exists in models index)
*
* This is used by the JS model system to recognize PHP model class names that may
* appear in JS inheritance chains but don't exist as JS classes in the manifest.
* PHP models like "Project_Model" generate JS stubs during bundle compilation.
*
* @param string $class_name The class name to check
* @return bool True if this is a PHP model class name
*/
public static function is_php_model_class(string $class_name): bool
{
return isset(static::$data['data']['models'][$class_name]);
}
/**
* Check if a class is a subclass of another by traversing the inheritance chain
*
@@ -625,6 +640,13 @@ class Manifest
$visited[] = $current_class;
// HACK #1 - JS Model shortcut: When checking against Rsx_Js_Model, if we encounter
// a PHP model class name (like "Project_Model"), we know it's a model that will have
// a generated Base_ stub extending Rsx_Js_Model. Return true immediately.
if ($superclass === 'Rsx_Js_Model' && static::is_php_model_class($current_class)) {
return true;
}
// Find the current class in the manifest
if (!isset(static::$data['data']['js_classes'][$current_class])) {
return false;

View File

@@ -6,16 +6,21 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use App\RSpade\Core\Database\Models\Rsx_Site_Model_Abstract;
use App\RSpade\Core\Models\Login_User_Model;
use App\RSpade\Core\Models\Site_Model;
use App\RSpade\Core\Models\User_Permission_Model;
use App\RSpade\Core\Models\User_Profile_Model;
/**
* User_Model - Site-specific user profile
* User_Model - Site-specific user profile with role-based access control
*
* Represents a user's profile within a specific site/organization.
* A single login identity (Login_User_Model) can have multiple User_Model records
* for different sites, allowing different names, roles, and profiles per organization.
*
* Contains: first_name, last_name, phone, site_id, role_id, user_role_id
* References: login_user_id Login_User_Model (authentication identity)
* ACL System:
* - Primary role (role_id) grants base permissions
* - Supplementary permissions (user_permissions table) can GRANT or DENY specific permissions
* - Resolution: DISABLED check DENY override GRANT override role default
*
* See: php artisan rsx:man acls
*/
@@ -45,83 +50,130 @@ use App\RSpade\Core\Models\User_Profile_Model;
* @method static mixed role_id_enum_ids()
* @property-read mixed $role_id_constant
* @property-read mixed $role_id_label
* @method static mixed user_role_id_enum()
* @method static mixed user_role_id_enum_select()
* @method static mixed user_role_id_enum_ids()
* @property-read mixed $user_role_id_constant
* @property-read mixed $user_role_id_label
* @property-read mixed $user_role_id_order
* @property-read mixed $role_id_permissions
* @property-read mixed $role_id_can_admin_roles
* @property-read mixed $role_id_selectable
* @mixin \Eloquent
*/
class User_Model extends Rsx_Site_Model_Abstract
{
/** __AUTO_GENERATED: */
const ROLE_OWNER = 1;
const ROLE_ADMIN = 2;
const ROLE_MEMBER = 3;
const ROLE_VIEWER = 4;
const USER_ROLE_READ_ONLY = 1;
const USER_ROLE_STANDARD = 2;
const USER_ROLE_ADMIN = 3;
const USER_ROLE_BILLING_ADMIN = 4;
const USER_ROLE_ROOT_ADMIN = 5;
const ROLE_DEVELOPER = 100;
const ROLE_ROOT_ADMIN = 200;
const ROLE_SITE_OWNER = 300;
const ROLE_SITE_ADMIN = 400;
const ROLE_MANAGER = 500;
const ROLE_USER = 600;
const ROLE_VIEWER = 700;
const ROLE_DISABLED = 800;
/** __/AUTO_GENERATED */
// Invitation status constants
// =========================================================================
// ROLE CONSTANTS (lower ID = higher privilege, 100-based for future expansion)
// =========================================================================
// =========================================================================
// PERMISSION CONSTANTS
// =========================================================================
// =========================================================================
// INVITATION STATUS CONSTANTS
// =========================================================================
const INVITATION_PENDING = 'pending';
const INVITATION_ACCEPTED = 'accepted';
const INVITATION_EXPIRED = 'expired';
// Permission constants - must match integer values used in $enums['role_id']['permissions']
const PERM_MANAGE_SITES_ROOT = 1;
const PERM_MANAGE_SITE_BILLING = 2;
const PERM_MANAGE_SITE_SETTINGS = 3;
const PERM_MANAGE_SITE_USERS = 4;
const PERM_VIEW_USER_ACTIVITY = 5;
const PERM_EDIT_DATA = 6;
const PERM_VIEW_DATA = 7;
const PERM_API_ACCESS = 8;
const PERM_DATA_EXPORT = 9;
use SoftDeletes;
/**
* Enum field definitions
* Cached supplementary permissions for this user
* @var array|null
*/
protected $_supplementary_permissions = null;
/**
* Enum field definitions with ACL permissions and can_admin_roles
*
* NOTE: Cannot use self:: constants in static property initialization (PHP limitation).
* Values must match the ROLE_* and PERM_* constants defined above.
*
* @var array
*/
public static $enums = [
'role_id' => [
1 => [
'constant' => 'ROLE_OWNER',
'label' => 'Owner',
// ROLE_DEVELOPER = 100
100 => [
'constant' => 'ROLE_DEVELOPER',
'label' => 'Developer',
'permissions' => [1, 2, 3, 4, 5, 6, 7], // All core PERM_* (1-7)
'can_admin_roles' => [200, 300, 400, 500, 600, 700, 800], // All roles below
'selectable' => false, // Developer assigned by system only
],
2 => [
'constant' => 'ROLE_ADMIN',
'label' => 'Admin',
// ROLE_ROOT_ADMIN = 200
200 => [
'constant' => 'ROLE_ROOT_ADMIN',
'label' => 'Root Admin',
'permissions' => [1, 2, 3, 4, 5, 6, 7], // All core PERM_* (1-7)
'can_admin_roles' => [300, 400, 500, 600, 700, 800], // All roles below
'selectable' => false, // Root admin assigned by system only
],
3 => [
'constant' => 'ROLE_MEMBER',
'label' => 'Member',
// ROLE_SITE_OWNER = 300
300 => [
'constant' => 'ROLE_SITE_OWNER',
'label' => 'Site Owner',
'permissions' => [2, 3, 4, 5, 6, 7], // BILLING(2) through VIEW(7)
'can_admin_roles' => [400, 500, 600, 700, 800], // Site Admin and below
],
4 => [
// ROLE_SITE_ADMIN = 400
400 => [
'constant' => 'ROLE_SITE_ADMIN',
'label' => 'Site Admin',
'permissions' => [3, 4, 5, 6, 7], // SETTINGS(3) through VIEW(7)
'can_admin_roles' => [500, 600, 700, 800], // Manager and below
],
// ROLE_MANAGER = 500
500 => [
'constant' => 'ROLE_MANAGER',
'label' => 'Manager',
'permissions' => [5, 6, 7], // ACTIVITY(5), EDIT(6), VIEW(7)
'can_admin_roles' => [600, 700, 800], // User and below
],
// ROLE_USER = 600
600 => [
'constant' => 'ROLE_USER',
'label' => 'User',
'permissions' => [6, 7], // EDIT(6), VIEW(7)
'can_admin_roles' => [],
],
// ROLE_VIEWER = 700
700 => [
'constant' => 'ROLE_VIEWER',
'label' => 'Viewer',
'permissions' => [7], // VIEW(7) only
'can_admin_roles' => [],
],
],
'user_role_id' => [
1 => [
'constant' => 'USER_ROLE_READ_ONLY',
'label' => 'Read Only',
'order' => 1,
],
2 => [
'constant' => 'USER_ROLE_STANDARD',
'label' => 'Standard',
'order' => 2,
],
3 => [
'constant' => 'USER_ROLE_ADMIN',
'label' => 'Admin',
'order' => 3,
],
4 => [
'constant' => 'USER_ROLE_BILLING_ADMIN',
'label' => 'Billing Admin',
'order' => 4,
],
5 => [
'constant' => 'USER_ROLE_ROOT_ADMIN',
'label' => 'Root Admin',
'order' => 5,
// ROLE_DISABLED = 800
800 => [
'constant' => 'ROLE_DISABLED',
'label' => 'Disabled',
'permissions' => [],
'can_admin_roles' => [],
],
],
];
@@ -133,22 +185,9 @@ class User_Model extends Rsx_Site_Model_Abstract
*/
protected $table = 'users';
/**
* Column metadata for special handling
*
* @var array
*/
protected $columnMeta = [
'role_id' => [
'type' => 'enum',
'values' => [
1 => 'owner',
2 => 'admin',
3 => 'member',
4 => 'viewer',
],
],
];
// =========================================================================
// RELATIONSHIPS
// =========================================================================
/**
* Get the login user (authentication identity)
@@ -183,6 +222,155 @@ class User_Model extends Rsx_Site_Model_Abstract
return $this->hasOne(User_Profile_Model::class, 'user_id');
}
/**
* Get supplementary permissions
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
#[Relationship]
public function supplementary_permissions()
{
return $this->hasMany(User_Permission_Model::class, 'user_id');
}
// =========================================================================
// ACL METHODS
// =========================================================================
/**
* Check if user has a specific permission
*
* Resolution order:
* 1. DISABLED role = deny all
* 2. Supplementary DENY = deny
* 3. Supplementary GRANT = grant
* 4. Role default permissions = grant if included
* 5. Deny
*
* @param int $permission Permission constant (PERM_*)
* @return bool
*/
public function has_permission(int $permission): bool
{
// Disabled users have no permissions
if ($this->role_id === self::ROLE_DISABLED) {
return false;
}
// Check supplementary DENY (overrides everything)
if ($this->has_supplementary_deny($permission)) {
return false;
}
// Check supplementary GRANT
if ($this->has_supplementary_grant($permission)) {
return true;
}
// Check role default permissions
$role_permissions = $this->role_id_permissions ?? [];
return in_array($permission, $role_permissions, true);
}
/**
* Check if user can administer users with the given role
*
* Prevents privilege escalation - users can only assign roles
* at or below their own permission level.
*
* @param int $role_id Role constant (ROLE_*)
* @return bool
*/
public function can_admin_role(int $role_id): bool
{
$can_admin = $this->role_id_can_admin_roles ?? [];
return in_array($role_id, $can_admin, true);
}
/**
* Check if user has at least the specified role level
*
* "At least" means same or higher privilege (lower role_id number).
*
* @param int $role_id Role constant (ROLE_*)
* @return bool
*/
public function has_role(int $role_id): bool
{
// Lower role_id = higher privilege
return $this->role_id <= $role_id;
}
// =========================================================================
// SUPPLEMENTARY PERMISSION METHODS
// =========================================================================
/**
* Load supplementary permissions for this user (cached per request)
*
* @return array ['grants' => [permission_ids], 'denies' => [permission_ids]]
*/
protected function _load_supplementary_permissions(): array
{
if ($this->_supplementary_permissions !== null) {
return $this->_supplementary_permissions;
}
$this->_supplementary_permissions = [
'grants' => [],
'denies' => [],
];
// Load from user_permissions table
$permissions = User_Permission_Model::where('user_id', $this->id)->get();
foreach ($permissions as $perm) {
if ($perm->is_grant) {
$this->_supplementary_permissions['grants'][] = $perm->permission_id;
} else {
$this->_supplementary_permissions['denies'][] = $perm->permission_id;
}
}
return $this->_supplementary_permissions;
}
/**
* Check if user has explicit GRANT for permission
*
* @param int $permission Permission constant
* @return bool
*/
public function has_supplementary_grant(int $permission): bool
{
$supplementary = $this->_load_supplementary_permissions();
return in_array($permission, $supplementary['grants'], true);
}
/**
* Check if user has explicit DENY for permission
*
* @param int $permission Permission constant
* @return bool
*/
public function has_supplementary_deny(int $permission): bool
{
$supplementary = $this->_load_supplementary_permissions();
return in_array($permission, $supplementary['denies'], true);
}
/**
* Clear cached supplementary permissions (call after modifying)
*/
public function clear_supplementary_cache(): void
{
$this->_supplementary_permissions = null;
}
// =========================================================================
// ACCESSORS
// =========================================================================
/**
* Get the full name of the user
*
@@ -213,17 +401,9 @@ class User_Model extends Rsx_Site_Model_Abstract
return 'Unknown User';
}
/**
* Get the role name
*
* @return string|null
*/
public function get_role_name()
{
$roles = $this->get_enum_values('role_id');
return $roles[$this->role_id] ?? null;
}
// =========================================================================
// STATUS METHODS
// =========================================================================
/**
* Check if user is active in this site
@@ -235,37 +415,6 @@ class User_Model extends Rsx_Site_Model_Abstract
return $this->is_enabled && !$this->trashed();
}
/**
* Check if user has a specific role
*
* @param string $role_name
* @return bool
*/
public function has_role($role_name)
{
return $this->get_role_name() === $role_name;
}
/**
* Check if user is an owner
*
* @return bool
*/
public function is_owner()
{
return $this->has_role('owner');
}
/**
* Check if user is an admin or owner
*
* @return bool
*/
public function is_admin()
{
return $this->has_role('admin') || $this->has_role('owner');
}
/**
* Scope to only get enabled site users
*
@@ -281,20 +430,12 @@ class User_Model extends Rsx_Site_Model_Abstract
* Scope to get users with a specific role
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int|string $role
* @param int $role_id Role constant
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeWithRole($query, $role)
public function scopeWithRole($query, int $role_id)
{
if (is_string($role)) {
$roles = $this->get_enum_values('role_id');
$role_id = array_search($role, $roles);
if ($role_id !== false) {
$role = $role_id;
}
}
return $query->where('role_id', $role);
return $query->where('role_id', $role_id);
}
/**

View File

@@ -0,0 +1,151 @@
<?php
namespace App\RSpade\Core\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use App\RSpade\Core\Models\User_Model;
/**
* _AUTO_GENERATED_
* @property integer $id
* @property integer $user_id
* @property integer $permission_id
* @property boolean $is_grant
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @mixin \Eloquent
*/
class User_Permission_Model extends Rsx_Model_Abstract
{
protected $table = 'user_permissions';
protected $fillable = []; // No mass assignment - always explicit
/**
* Enum field definitions (none for this simple model)
* @var array
*/
public static $enums = [];
// =========================================================================
// STATIC MANAGEMENT METHODS
// =========================================================================
/**
* Grant a permission to a user
*
* @param int $user_id User ID
* @param int $permission_id Permission constant
* @return User_Permission_Model
*/
public static function grant(int $user_id, int $permission_id): User_Permission_Model
{
// Remove any existing entry first (could be DENY)
static::where('user_id', $user_id)
->where('permission_id', $permission_id)
->delete();
$perm = new static();
$perm->user_id = $user_id;
$perm->permission_id = $permission_id;
$perm->is_grant = true;
$perm->save();
// Clear cache on user if loaded
static::_clear_user_cache($user_id);
return $perm;
}
/**
* Deny a permission to a user
*
* @param int $user_id User ID
* @param int $permission_id Permission constant
* @return User_Permission_Model
*/
public static function deny(int $user_id, int $permission_id): User_Permission_Model
{
// Remove any existing entry first (could be GRANT)
static::where('user_id', $user_id)
->where('permission_id', $permission_id)
->delete();
$perm = new static();
$perm->user_id = $user_id;
$perm->permission_id = $permission_id;
$perm->is_grant = false;
$perm->save();
// Clear cache on user if loaded
static::_clear_user_cache($user_id);
return $perm;
}
/**
* Remove a supplementary permission (revert to role default)
*
* @param int $user_id User ID
* @param int $permission_id Permission constant
* @return bool True if removed, false if not found
*/
public static function remove(int $user_id, int $permission_id): bool
{
$deleted = static::where('user_id', $user_id)
->where('permission_id', $permission_id)
->delete();
// Clear cache on user if loaded
static::_clear_user_cache($user_id);
return $deleted > 0;
}
/**
* Get all supplementary permissions for a user
*
* @param int $user_id User ID
* @return array ['grants' => [permission_ids], 'denies' => [permission_ids]]
*/
public static function for_user(int $user_id): array
{
$result = [
'grants' => [],
'denies' => [],
];
$permissions = static::where('user_id', $user_id)->get();
foreach ($permissions as $perm) {
if ($perm->is_grant) {
$result['grants'][] = $perm->permission_id;
} else {
$result['denies'][] = $perm->permission_id;
}
}
return $result;
}
/**
* Clear user's supplementary permission cache
*
* @param int $user_id User ID
*/
protected static function _clear_user_cache(int $user_id): void
{
// Find user instance and clear cache if it exists
// This is best-effort - the user might not be loaded
// Permission checks will reload from DB on next access
}
// =========================================================================
// RELATIONSHIPS
// =========================================================================
#[Relationship]
public function user()
{
return $this->belongsTo(User_Model::class, 'user_id');
}
}

View File

@@ -0,0 +1,3 @@
<Define:Default_Layout>
<div $sid="content"></div>
</Define:Default_Layout>

View File

@@ -0,0 +1,9 @@
/**
* Default_Layout - Minimal passthrough layout for actions without explicit @layout
*
* This layout provides the required $sid="content" element but adds no visual structure.
* Used when an action doesn't specify any @layout decorator.
*/
class Default_Layout extends Spa_Layout {
// No custom behavior - just provides the content container
}

View File

@@ -192,7 +192,10 @@ class Spa {
/**
* Match URL to a route and extract parameters
* Returns: { action_class, args, layout } or null
* Returns: { action_class, args, layouts } or null
*
* layouts is an array of layout class names for sublayout chain support.
* First element = outermost layout, last = innermost (closest to action).
*/
static match_url_to_route(url) {
// Parse URL to get path and query params
@@ -210,10 +213,11 @@ class Spa {
// Try exact match first
const exact_pattern = '/' + path;
if (Spa.routes[exact_pattern]) {
const action_class = Spa.routes[exact_pattern];
return {
action_class: Spa.routes[exact_pattern],
action_class: action_class,
args: parsed.query_params,
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
layouts: action_class._spa_layouts || ['Default_Layout'],
};
}
@@ -226,11 +230,12 @@ class Spa {
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
// This matches the PHP Dispatcher behavior where route params override GET params
const args = { ...parsed.query_params, ...match };
const action_class = Spa.routes[pattern];
return {
action_class: Spa.routes[pattern],
action_class: action_class,
args: args,
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
layouts: action_class._spa_layouts || ['Default_Layout'],
};
}
}
@@ -593,7 +598,7 @@ class Spa {
console_debug('Spa', 'Route match:', {
action_class: route_match?.action_class?.name,
args: route_match?.args,
layout: route_match?.layout,
layouts: route_match?.layouts,
});
// Check if action's @spa() attribute matches current SPA bootstrap
@@ -647,8 +652,8 @@ class Spa {
Spa.path = parsed.path;
Spa.params = route_match.args;
// Get layout name and action info
const layout_name = route_match.layout;
// Get layout chain and action info
const target_layouts = route_match.layouts;
const action_class = route_match.action_class;
const action_name = action_class.name;
@@ -658,43 +663,12 @@ class Spa {
path: parsed.path,
params: route_match.args,
action: action_name,
layout: layout_name,
layouts: target_layouts,
history_mode: opts.history
});
// Check if we need a new layout
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
// Stop old layout if exists (auto-stops children)
if (Spa.layout) {
await Spa.layout.trigger('unload');
Spa.layout.stop();
}
// Clear spa-root and create new layout
// Note: We target #spa-root instead of body to preserve global UI containers
// (Flash_Alert, modals, tooltips, etc. that append to body)
const $spa_root = $('#spa-root');
if (!$spa_root.length) {
throw new Error('[Spa] #spa-root element not found - check Spa_App.blade.php');
}
$spa_root.empty();
$spa_root.attr('class', '');
// Create layout using component system
Spa.layout = $spa_root.component(layout_name, {}).component();
// Wait for layout to be ready
await Spa.layout.ready();
console_debug('Spa', `Created layout: ${layout_name}`);
} else {
// Wait for layout to finish previous action if still loading
await Spa.layout.ready();
}
// Tell layout to run the action
Spa.layout._set_action(action_name, route_match.args, url);
await Spa.layout._run_action();
// Resolve layout chain - find divergence point and reuse matching layouts
await Spa._resolve_layout_chain(target_layouts, action_name, route_match.args, url);
// Scroll Restoration #1: Immediate (after action starts)
// This occurs synchronously after the action component is created
@@ -722,7 +696,7 @@ class Spa {
// making the target scroll position accessible. The first restoration happens before
// this content renders, so we need a second attempt after the page is fully ready.
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
console_debug('Spa', `Rendered ${action_name} with ${target_layouts.length} layout(s)`);
} catch (error) {
console.error('[Spa] Dispatch error:', error);
// TODO: Better error handling - show error UI to user
@@ -744,6 +718,189 @@ class Spa {
}
}
/**
* Resolve layout chain - find divergence point and reuse matching layouts
*
* Walks down from the top-level layout comparing current DOM chain to target chain.
* Reuses layouts that match, destroys from the first mismatch point down,
* then creates new layouts/action from that point.
*
* @param {string[]} target_layouts - Array of layout class names (outermost first)
* @param {string} action_name - The action class name to render at the bottom
* @param {object} args - URL parameters for the action
* @param {string} url - The full URL being navigated to
*/
static async _resolve_layout_chain(target_layouts, action_name, args, url) {
// Build target chain: layouts + action at the end
const target_chain = [...target_layouts, action_name];
console_debug('Spa', 'Resolving layout chain:', target_chain);
// Find the divergence point by walking the current DOM
let $current_container = $('#spa-root');
if (!$current_container.length) {
throw new Error('[Spa] #spa-root element not found - check Spa_App.blade.php');
}
let divergence_index = 0;
let reusable_layouts = [];
// Walk down current chain, checking each level against target
for (let i = 0; i < target_chain.length; i++) {
const target_name = target_chain[i];
const is_last = (i === target_chain.length - 1);
// Check if current container has a component with target class name
// jqhtml adds class names to component root elements automatically
const $existing = $current_container.children().first();
if ($existing.length && $existing.hasClass(target_name)) {
// Match found - can potentially reuse this level
const existing_component = $existing.component();
if (is_last) {
// This is the action level - actions are never reused, always replaced
divergence_index = i;
break;
}
// This is a layout level - reuse it
reusable_layouts.push(existing_component);
divergence_index = i + 1;
// Move to next level - look in this layout's $content
const $content = existing_component.$sid ? existing_component.$sid('content') : null;
if (!$content || !$content.length) {
// Layout doesn't have content area - can't go deeper
break;
}
$current_container = $content;
} else {
// No match - divergence point found
divergence_index = i;
break;
}
}
console_debug('Spa', `Divergence at index ${divergence_index}, reusing ${reusable_layouts.length} layouts`);
// Destroy everything from divergence point down
if (divergence_index === 0) {
// Complete replacement - destroy top-level layout
if (Spa.layout) {
await Spa.layout.trigger('unload');
Spa.layout.stop();
Spa.layout = null;
}
$current_container = $('#spa-root');
$current_container.empty();
Spa._clear_container_attributes($current_container);
} else {
// Partial replacement - clear from the reusable layout's $content
const last_reusable = reusable_layouts[reusable_layouts.length - 1];
$current_container = last_reusable.$sid('content');
$current_container.empty();
Spa._clear_container_attributes($current_container);
}
// Create new layouts/action from divergence point
for (let i = divergence_index; i < target_chain.length; i++) {
const component_name = target_chain[i];
const is_last = (i === target_chain.length - 1);
console_debug('Spa', `Creating ${is_last ? 'action' : 'layout'}: ${component_name}`);
// Create component
const component = $current_container.component(component_name, is_last ? args : {}).component();
// Wait for it to be ready
await component.ready();
if (is_last) {
// This is the action
Spa.action = component;
} else {
// This is a layout
if (i === 0) {
// Top-level layout
Spa.layout = component;
}
// Move container to this layout's $content for next iteration
$current_container = component.$sid('content');
if (!$current_container || !$current_container.length) {
throw new Error(`[Spa] Layout ${component_name} must have an element with $sid="content"`);
}
}
}
// Propagate on_action to all layouts in the chain
// All layouts receive the same action info (final action's url, name, args)
const layouts_for_on_action = Spa._collect_all_layouts();
for (const layout of layouts_for_on_action) {
// Set action reference before calling on_action so layouts can access it
layout.action = Spa.action;
if (layout.on_action) {
layout.on_action(url, action_name, args);
}
layout.trigger('action');
}
console_debug('Spa', `Rendered ${action_name} with ${target_layouts.length} layout(s)`);
}
/**
* Collect all layouts from Spa.layout down through nested $content elements
* @returns {Array} Array of layout instances from top to bottom
*/
static _collect_all_layouts() {
const layouts = [];
let current = Spa.layout;
while (current && current instanceof Spa_Layout) {
layouts.push(current);
// Look for nested layout in $content
const $content = current.$sid ? current.$sid('content') : null;
if (!$content || !$content.length) break;
const $child = $content.children().first();
if (!$child.length) break;
const child_component = $child.component();
if (child_component && child_component instanceof Spa_Layout) {
current = child_component;
} else {
break;
}
}
return layouts;
}
/**
* Clear all attributes except id from a container element
* Called before loading new content to ensure clean state
* @param {jQuery} $container - The container element to clear
*/
static _clear_container_attributes($container) {
if (!$container || !$container.length) return;
const el = $container[0];
const attrs_to_remove = [];
for (const attr of el.attributes) {
if (attr.name !== 'id' && attr.name !== 'data-id') {
attrs_to_remove.push(attr.name);
}
}
for (const attr_name of attrs_to_remove) {
el.removeAttribute(attr_name);
}
}
/**
* Fatal error when trying to navigate to unknown route on current URL
* This shouldn't happen - prevents infinite redirect loops

View File

@@ -30,16 +30,26 @@ function route(pattern) {
/**
* @decorator
* Define which layout this action renders within
* Define which layout(s) this action renders within
*
* Multiple @layout decorators create a chain of nested layouts (sublayouts).
* First decorator = outermost layout, subsequent = progressively nested.
* Each layout persists independently during navigation if unchanged.
*
* Usage:
* @layout('Frontend_Layout')
* class Contacts_Index_Action extends Spa_Action { }
* @layout('Frontend_Layout') // Outermost (header/footer)
* @layout('Settings_Layout') // Nested inside Frontend_Layout
* class Settings_Profile_Action extends Spa_Action { }
*/
function layout(layout_name) {
return function (target) {
// Store layout name on the class
target._spa_layout = layout_name;
// Store layout names as array for sublayout chain support
// Use unshift because decorators execute bottom-up, so we prepend
// each layout to get correct order (outermost first in array)
if (!target._spa_layouts) {
target._spa_layouts = [];
}
target._spa_layouts.unshift(layout_name);
return target;
};
}

View File

@@ -1,24 +1,27 @@
/**
* Spa_Layout - Base class for Spa layouts
*
* Layouts provide the persistent wrapper (header, nav, footer) around actions.
* They render directly to body and contain a content area where actions render.
* Layouts provide the persistent wrapper (header, nav, footer) around actions or sublayouts.
* They render to their parent's $content area and contain their own $content for children.
*
* Sublayouts: Multiple @layout decorators create a chain of nested layouts.
* Each layout persists independently - if navigating between pages with the same
* outer layout but different inner layouts, only the differing parts are recreated.
*
* Requirements:
* - Must have an element with $id="content" where actions will render
* - Persists across action navigations (only re-created when layout changes)
* - Must have an element with $sid="content" where children (sublayouts or actions) render
* - Persists across navigations as long as it remains in the layout chain
*
* Lifecycle events triggered for actions:
* - before_action_init, action_init
* - before_action_render, action_render
* - before_action_ready, action_ready
* Properties set by Spa:
* - this.action - Reference to the current action (bottom of chain)
*
* Hook methods that can be overridden:
* - on_action(url, action_name, args) - Called when new action is set
* - on_action(url, action_name, args) - Called when any action is dispatched
* All layouts in the chain receive this with the final action's info.
*/
class Spa_Layout extends Component {
on_create() {
console.log('Layout create!');
console_debug('Spa_Layout', `${this.constructor.name} created`);
}
/**
@@ -75,133 +78,6 @@ class Spa_Layout extends Component {
return div.innerHTML;
}
/**
* Set which action should be rendered
* Called by Spa.dispatch() - stores action info for _run_action()
*
* @param {string} action_name - Name of the action class
* @param {object} args - URL parameters and query params
* @param {string} url - The full URL being dispatched to
*/
_set_action(action_name, args, url) {
this._pending_action_name = action_name;
this._pending_action_args = args;
this._pending_action_url = url;
}
/**
* Execute the pending action - stop old action, create new one
* Called by Spa.dispatch() after _set_action()
*/
async _run_action() {
const action_name = this._pending_action_name;
const args = this._pending_action_args;
const url = this._pending_action_url;
// Get content container
console.log('[Spa_Layout] Looking for content element...');
console.log('[Spa_Layout] this.$id available?', typeof this.$id);
console.log('[Spa_Layout] this.$ exists?', !!this.$);
const $content = this.$content();
console.log('[Spa_Layout] $content result:', $content);
console.log('[Spa_Layout] $content.length:', $content?.length);
if (!$content || !$content.length) {
// TODO: Better error handling - show error UI instead of just console
console.error(`[Spa_Layout] Layout ${this.constructor.name} must have an element with $id="content"`);
console.error(
'[Spa_Layout] Available elements in this.$:',
this.$.find('[data-id]')
.toArray()
.map((el) => el.getAttribute('data-id'))
);
throw new Error(`Layout ${this.constructor.name} must have an element with $id="content"`);
}
// Stop old action (jqhtml auto-stops when .component() replaces)
// Clear content area
$content.empty();
// Mark action as loading for exception display logic
this._action_is_loading = true;
try {
// Get the action class to check for @title decorator
const action_class = Manifest.get_class_by_name(action_name);
// Update page title if @title decorator is present (optional), clear if not
if (action_class._spa_title) {
document.title = action_class._spa_title;
} else {
document.title = '';
}
// Create new action component
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
console.warn(args);
const action = $content.component(action_name, args).component();
// Store reference
Spa.action = action;
this.action = action;
// Call on_action hook (can be overridden by subclasses)
this.on_action(url, action_name, args);
this.trigger('action');
// Setup event forwarding from action to layout
// Action triggers 'init' -> Layout triggers 'action_init'
this._setup_action_events(action);
// Wait for action to be ready
await action.ready();
// Mark action as done loading
this._action_is_loading = false;
} catch (error) {
// Mark action as done loading (even though it failed)
this._action_is_loading = false;
// Action lifecycle failed - log and trigger event
console.error('[Spa_Layout] Action lifecycle failed:', error);
// Disable SPA so forward navigation becomes full page loads
// (Back/forward still work as SPA to allow user to navigate away)
if (typeof Spa !== 'undefined') {
Spa.disable();
}
// Trigger global exception event
// Exception_Handler will decide how to display (layout or flash alert)
// Pass payload with exception (no meta.source = already logged above)
if (typeof Rsx !== 'undefined') {
Rsx.trigger('unhandled_exception', { exception: error, meta: {} });
}
// Don't re-throw - allow navigation to continue working
}
}
/**
* Setup event listeners on action to forward to layout
* @private
*/
_setup_action_events(action) {
const events = ['before_init', 'init', 'before_render', 'render', 'before_ready', 'ready'];
events.forEach((event) => {
action.on(event, () => {
// Trigger corresponding layout event with 'action_' prefix
const layout_event = event.replace('before_', 'before_action_').replace(/^(?!before)/, 'action_');
this.trigger(layout_event, action);
});
});
}
/**
* Hook called when a new action is set
* Override this in subclasses to react to action changes.
@@ -223,6 +99,6 @@ class Spa_Layout extends Component {
}
on_ready() {
console.log('layout ready!');
console_debug('Spa_Layout', `${this.constructor.name} ready`);
}
}

View File

@@ -135,15 +135,6 @@ class Session extends Rsx_System_Model_Abstract
return php_sapi_name() === 'cli';
}
/**
* Check if requester is a playwright fpc client
* @return bool
*/
private static function __is_fpc_client(): bool
{
return isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1';
}
/**
* Initialize session from cookie or request
* Loads existing session but does not create new one
@@ -159,7 +150,7 @@ class Session extends Rsx_System_Model_Abstract
self::$_has_init = true;
// CLI mode: do nothing
if (self::__is_cli() || self::__is_fpc_client()) {
if (self::__is_cli()) {
return;
}
@@ -215,8 +206,8 @@ class Session extends Rsx_System_Model_Abstract
}
self::$_has_activate = true;
// CLI & FPC mode: do nothing
if (self::__is_cli() || self::__is_fpc_client()) {
// CLI mode: do nothing
if (self::__is_cli()) {
return;
}

View File

@@ -19,6 +19,33 @@ use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
*/
class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
{
/**
* Authentication check - requires developer role or CLI access
*
* This is a dev tool that generates test data. Access is restricted to:
* 1. CLI users (artisan commands, tests)
* 2. Authenticated users with ROLE_DEVELOPER
*/
public static function pre_dispatch(Request $request, array $params = [])
{
// Allow CLI access (artisan commands, tests)
if (app()->runningInConsole()) {
return null;
}
// Require authentication
if (!\App\RSpade\Core\Session\Session::is_logged_in()) {
return response_unauthorized();
}
// Require developer role
if (!Permission::has_role(\App\RSpade\Core\Models\User_Model::ROLE_DEVELOPER)) {
return response_unauthorized('Developer access required');
}
return null;
}
private static $first_names = null;
private static $last_names = null;
private static $cities = null;

View File

@@ -1300,6 +1300,70 @@ function handle_resolve_class_service($data) {
}
}
// Final fallback: Check for Base_* model stubs (auto-generated at bundle time)
// These don't exist as JS classes but their corresponding PHP models do
if (str_starts_with($identifier, 'Base_')) {
$model_name = substr($identifier, 5); // Strip 'Base_' prefix
$model_data = $find_php_class($model_name);
if ($model_data) {
// Check if this is a subclass of Rsx_Model_Abstract
$is_model = false;
$current_class = $model_name;
$visited = [];
while ($current_class) {
if (in_array($current_class, $visited)) break;
$visited[] = $current_class;
$class_data = $find_php_class($current_class);
if (!$class_data) break;
$extends = $class_data['extends'] ?? null;
if (!$extends) break;
// Normalize extends (strip namespace)
$extends_parts = explode('\\', $extends);
$extends_simple = end($extends_parts);
if ($extends_simple === 'Rsx_Model_Abstract') {
$is_model = true;
break;
}
$current_class = $extends_simple;
}
if ($is_model) {
$file_path = $model_data['file'];
$line_number = 1;
$absolute_path = IDE_BASE_PATH . '/' . $file_path;
// Try to find the fetch method line number if it exists
if (file_exists($absolute_path)) {
$content = file_get_contents($absolute_path);
$lines = explode("\n", $content);
foreach ($lines as $index => $line) {
if (preg_match('/^\s*(public\s+)?(static\s+)?function\s+fetch\s*\(/', $line)) {
$line_number = $index + 1;
break;
}
}
}
json_response([
'found' => true,
'type' => 'base_model_stub',
'file' => $file_path,
'line' => $line_number,
'identifier' => $identifier,
'resolved_model' => $model_name,
]);
}
}
}
// Nothing found after trying all types
json_response([
'found' => false,

View File

@@ -172,7 +172,6 @@ class Jqhtml_Integration {
if (is_top_level) {
(async () => {
await Promise.all(promises);
await Rsx._rsx_call_all_classes('on_jqhtml_ready');
Rsx.trigger('jqhtml_ready');
})();
return;

View File

@@ -12,7 +12,7 @@
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
z-index: 1200; // Flash alerts layer - see rsx:man zindex
display: flex;
flex-direction: column;
align-items: center; // Center alerts horizontally

View File

@@ -1071,6 +1071,42 @@ function response_error(string $error_code, $metadata = null)
return new \App\RSpade\Core\Response\Error_Response($error_code, $metadata);
}
/**
* Create an unauthorized error response
*
* Context-aware response:
* - Ajax requests: Returns JSON {success: false, error_code: 'unauthorized'}
* - Web requests (not logged in): Eventually redirects to login (see wishlist 1.7.1)
* - Web requests (logged in): Renders 403 error page or throws HttpException
*
* Use this when user is authenticated but lacks permission for an action,
* OR when user is not authenticated and needs to be.
*
* @param string|null $message Custom error message (optional)
* @return \App\RSpade\Core\Response\Error_Response
*/
function response_unauthorized(?string $message = null)
{
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED, $message);
}
/**
* Create a not found error response
*
* Context-aware response:
* - Ajax requests: Returns JSON {success: false, error_code: 'not_found'}
* - Web requests: Renders 404 error page or throws HttpException
*
* Use this when a requested resource does not exist.
*
* @param string|null $message Custom error message (optional)
* @return \App\RSpade\Core\Response\Error_Response
*/
function response_not_found(?string $message = null)
{
return response_error(\App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND, $message);
}
/**
* Check if the current request is from a loopback IP address
*

598
app/RSpade/man/acls.txt Executable file
View File

@@ -0,0 +1,598 @@
ACLS(7) RSpade Developer Manual ACLS(7)
NAME
acls - Role-based access control with supplementary permissions
SYNOPSIS
// Check if current user has a permission
Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)
// Check if current user has at least a certain role
Permission::has_role(User_Model::ROLE_SITE_ADMIN)
// Check on specific user instance
$user->has_permission(User_Model::PERM_EDIT_DATA)
// Check if user can administer another user's role
$user->can_admin_role($target_user->role_id)
DESCRIPTION
RSpade provides a role-based access control (RBAC) system where:
1. Users have a primary role on their site membership (users.role_id)
2. Roles grant a predefined set of permissions
3. Supplementary permissions can GRANT or DENY specific permissions per-user
4. Permission checks resolve: role grants → DENY override → GRANT override
Key design principles:
Identity vs Membership
login_users = identity (email, password, one per person)
users = site membership (role, permissions, many per login_user)
Roles and permissions attach to site memberships (users table),
not login identities. One person can have different roles on
different sites.
Integer Constants
All roles and permissions are integer constants, not strings.
This provides type safety, IDE autocompletion, and works with
the RSX enum system for magic properties.
Hierarchical Roles
Roles are hierarchical. Higher roles inherit all permissions
from lower roles. Role IDs are ordered by privilege level
(lower ID = more privilege).
Supplementary Permissions
Individual users can have permissions granted or denied beyond
their role defaults. DENY always wins over role grants. GRANT
adds permissions the role doesn't provide.
ARCHITECTURE
Database Tables
users (site membership)
role_id Primary role for this site membership
... Other membership fields
user_permissions (supplementary)
user_id FK to users
permission_id Which permission constant
is_grant 1 = GRANT, 0 = DENY
Permission Resolution Order
1. Check if user role is DISABLED → deny all
2. Check user_permissions for explicit DENY → deny if found
3. Check user_permissions for explicit GRANT → allow if found
4. Check role's default permissions → allow if included
5. Deny (permission not granted)
Role Hierarchy
ID Constant Label Can Admin Roles
-- -------- ----- ---------------
1 ROLE_ROOT_ADMIN Root Admin 2,3,4,5,6,7
2 ROLE_SITE_OWNER Site Owner 3,4,5,6,7
3 ROLE_SITE_ADMIN Site Admin 4,5,6,7
4 ROLE_MANAGER Manager 5,6,7
5 ROLE_USER User (none)
6 ROLE_VIEWER Viewer (none)
7 ROLE_DISABLED Disabled (none)
"Can Admin Roles" means a user with that role can create, edit,
or change the role of users with the listed role IDs. This
prevents privilege escalation (admin can't create root admin).
PERMISSIONS
Core Permissions (granted by role)
ID Constant Granted By Default To
-- -------- ---------------------
1 PERM_MANAGE_SITES_ROOT Root Admin only
2 PERM_MANAGE_SITE_BILLING Site Owner+
3 PERM_MANAGE_SITE_SETTINGS Site Admin+
4 PERM_MANAGE_SITE_USERS Site Admin+
5 PERM_VIEW_USER_ACTIVITY Manager+
6 PERM_EDIT_DATA User+
7 PERM_VIEW_DATA Viewer+
Supplementary Permissions (not granted by any role by default)
ID Constant Purpose
-- -------- -------
8 PERM_API_ACCESS Allow API key creation/usage
9 PERM_DATA_EXPORT Allow bulk data export
Role-Permission Matrix
Permission Root Owner Admin Mgr User View Dis
---------- ---- ----- ----- --- ---- ---- ---
MANAGE_SITES_ROOT X
MANAGE_SITE_BILLING X X
MANAGE_SITE_SETTINGS X X X
MANAGE_SITE_USERS X X X
VIEW_USER_ACTIVITY X X X X
EDIT_DATA X X X X X
VIEW_DATA X X X X X X
API_ACCESS - - - - - -
DATA_EXPORT - - - - - -
Legend: X = granted by role, - = must be granted individually
MODEL IMPLEMENTATION
User_Model Definition
class User_Model extends Rsx_Model_Abstract
{
// Role constants
const ROLE_ROOT_ADMIN = 1;
const ROLE_SITE_OWNER = 2;
const ROLE_SITE_ADMIN = 3;
const ROLE_MANAGER = 4;
const ROLE_USER = 5;
const ROLE_VIEWER = 6;
const ROLE_DISABLED = 7;
// Permission constants
const PERM_MANAGE_SITES_ROOT = 1;
const PERM_MANAGE_SITE_BILLING = 2;
const PERM_MANAGE_SITE_SETTINGS = 3;
const PERM_MANAGE_SITE_USERS = 4;
const PERM_VIEW_USER_ACTIVITY = 5;
const PERM_EDIT_DATA = 6;
const PERM_VIEW_DATA = 7;
const PERM_API_ACCESS = 8;
const PERM_DATA_EXPORT = 9;
public static $enums = [
'role_id' => [
self::ROLE_ROOT_ADMIN => [
'constant' => 'ROLE_ROOT_ADMIN',
'label' => 'Root Admin',
'permissions' => [
self::PERM_MANAGE_SITES_ROOT,
self::PERM_MANAGE_SITE_BILLING,
self::PERM_MANAGE_SITE_SETTINGS,
self::PERM_MANAGE_SITE_USERS,
self::PERM_VIEW_USER_ACTIVITY,
self::PERM_EDIT_DATA,
self::PERM_VIEW_DATA,
],
'can_admin_roles' => [2,3,4,5,6,7],
],
// ... additional roles
],
];
public function has_permission(int $permission): bool
{
if ($this->role_id === self::ROLE_DISABLED) {
return false;
}
// Check supplementary DENY (overrides everything)
if ($this->has_supplementary_deny($permission)) {
return false;
}
// Check supplementary GRANT
if ($this->has_supplementary_grant($permission)) {
return true;
}
// Check role default permissions
return in_array($permission, $this->role_permissions ?? []);
}
public function can_admin_role(int $role_id): bool
{
return in_array($role_id, $this->role_can_admin_roles ?? []);
}
}
Magic Properties (via enum system)
$user->role_label // "Site Admin"
$user->role_permissions // [3,4,5,6,7]
$user->role_can_admin_roles // [4,5,6,7]
PERMISSION CLASS API
Static Methods (use current session user)
Permission::has_permission(int $permission): bool
Check if current logged-in user has permission.
Returns false if not logged in or no site selected.
if (Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) {
// Show user management UI
}
Permission::has_role(int $role_id): bool
Check if current user has at least the specified role.
"At least" means same or higher privilege (lower role_id).
if (Permission::has_role(User_Model::ROLE_SITE_ADMIN)) {
// User is Site Admin or higher (Owner, Root)
}
Permission::get_user(): ?User_Model
Get current user's site membership record.
Returns null if not logged in or no site selected.
$user = Permission::get_user();
if ($user && $user->has_permission(User_Model::PERM_EDIT_DATA)) {
// ...
}
Instance Methods (on User_Model)
$user->has_permission(int $permission): bool
Check if this specific user has permission.
$user->can_admin_role(int $role_id): bool
Check if user can create/edit users with given role.
$user->has_supplementary_grant(int $permission): bool
Check if user has explicit GRANT for permission.
$user->has_supplementary_deny(int $permission): bool
Check if user has explicit DENY for permission.
ROUTE PROTECTION
Using #[Auth] Attribute
Standard permission checks work with #[Auth]:
#[Route('/settings/users')]
#[Auth('Permission::authenticated()')]
public static function users(Request $request, array $params = [])
{
// Manual permission check inside route
if (!Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) {
return response_error(Ajax::ERROR_UNAUTHORIZED);
}
// ...
}
Custom Permission Methods
Define reusable permission checks in rsx/permission.php:
class Permission
{
public static function can_manage_users(): bool
{
return self::has_permission(User_Model::PERM_MANAGE_SITE_USERS);
}
public static function can_edit(): bool
{
return self::has_permission(User_Model::PERM_EDIT_DATA);
}
}
Usage in routes:
#[Route('/users/create')]
#[Auth('Permission::can_manage_users()')]
public static function create(Request $request, array $params = [])
{
// Guaranteed to have PERM_MANAGE_SITE_USERS
}
SUPPLEMENTARY PERMISSIONS
Purpose
Supplementary permissions allow per-user exceptions to role defaults:
- GRANT: Give a permission the role doesn't include
- DENY: Remove a permission the role normally includes
Common use cases:
- Grant API access to specific users regardless of role
- Deny export access to a user who normally has it
- Temporary elevated permissions during onboarding
Database Table
user_permissions
id BIGINT PRIMARY KEY
user_id BIGINT NOT NULL (FK to users)
permission_id INT NOT NULL
is_grant TINYINT(1) NOT NULL (1=GRANT, 0=DENY)
created_at TIMESTAMP
updated_at TIMESTAMP
UNIQUE KEY (user_id, permission_id)
Management API
// Grant a permission
User_Permission_Model::grant($user_id, User_Model::PERM_API_ACCESS);
// Deny a permission
User_Permission_Model::deny($user_id, User_Model::PERM_DATA_EXPORT);
// Remove supplementary (revert to role default)
User_Permission_Model::remove($user_id, User_Model::PERM_API_ACCESS);
// Get all supplementary permissions for user
$supplementary = User_Permission_Model::for_user($user_id);
Resolution Priority
DENY always wins. Order of precedence:
1. Explicit DENY → permission denied
2. Explicit GRANT → permission granted
3. Role default → permission granted if in role
4. Not granted → permission denied
Example: User is Site Admin (has PERM_MANAGE_SITE_USERS by role)
- No supplementary → has permission (from role)
- GRANT added → has permission (redundant but harmless)
- DENY added → NO permission (DENY overrides role)
- Both GRANT and DENY → NO permission (DENY wins)
EXAMPLES
Example 1: Check Permission in Controller
#[Ajax_Endpoint]
#[Auth('Permission::authenticated()')]
public static function delete_user(Request $request, array $params = [])
{
if (!Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)) {
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot manage users');
}
$target_id = $params['user_id'];
$target = User_Model::find($target_id);
// Check can admin this user's role
$current = Permission::get_user();
if (!$current->can_admin_role($target->role_id)) {
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot modify this user');
}
$target->delete();
return ['success' => true];
}
Example 2: Conditional UI Based on Permissions
// In controller, pass permissions to view
return rsx_view('Settings_Users', [
'can_create' => Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS),
'can_export' => Permission::has_permission(User_Model::PERM_DATA_EXPORT),
]);
// In jqhtml template
<% if (this.args.can_create) { %>
<button @click=this.create_user>Add User</button>
<% } %>
Example 3: Role Assignment Validation
#[Ajax_Endpoint]
public static function update_user_role(Request $request, array $params = [])
{
$target = User_Model::find($params['user_id']);
$new_role_id = $params['role_id'];
$current = Permission::get_user();
// Can't change own role
if ($target->id === $current->id) {
return response_error(Ajax::ERROR_VALIDATION, 'Cannot change own role');
}
// Must be able to admin both current and new role
if (!$current->can_admin_role($target->role_id)) {
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot modify this user');
}
if (!$current->can_admin_role($new_role_id)) {
return response_error(Ajax::ERROR_UNAUTHORIZED, 'Cannot assign this role');
}
$target->role_id = $new_role_id;
$target->save();
return ['success' => true];
}
Example 4: Grant Supplementary Permission
// Admin grants API access to a regular user
$user = User_Model::find($user_id);
// Verify current user can admin this user
if (!Permission::get_user()->can_admin_role($user->role_id)) {
throw new Exception('Unauthorized');
}
User_Permission_Model::grant($user->id, User_Model::PERM_API_ACCESS);
// User now has API access despite being a regular User role
Example 5: Check Multiple Permissions
// User needs EITHER permission
$can_view = Permission::has_permission(User_Model::PERM_VIEW_DATA)
|| Permission::has_permission(User_Model::PERM_EDIT_DATA);
// User needs BOTH permissions
$can_admin = Permission::has_permission(User_Model::PERM_MANAGE_SITE_SETTINGS)
&& Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS);
ADDING NEW PERMISSIONS
1. Add constant to User_Model:
const PERM_NEW_FEATURE = 10;
2. Add to role definitions in $enums if role should grant it:
self::ROLE_SITE_ADMIN => [
'permissions' => [
// ... existing
self::PERM_NEW_FEATURE,
],
],
3. Run rsx:migrate:document_models to regenerate stubs
4. Use in code:
if (Permission::has_permission(User_Model::PERM_NEW_FEATURE)) {
// ...
}
ADDING NEW ROLES
1. Add constant (maintain hierarchy order):
const ROLE_SUPERVISOR = 4; // Between Admin and Manager
const ROLE_MANAGER = 5; // Renumber if needed
// ...
2. Add to $enums with permissions and can_admin_roles:
self::ROLE_SUPERVISOR => [
'constant' => 'ROLE_SUPERVISOR',
'label' => 'Supervisor',
'permissions' => [
self::PERM_VIEW_USER_ACTIVITY,
self::PERM_EDIT_DATA,
self::PERM_VIEW_DATA,
],
'can_admin_roles' => [5,6,7],
],
3. Update can_admin_roles for roles above:
self::ROLE_SITE_ADMIN => [
'can_admin_roles' => [4,5,6,7], // Add new role ID
],
4. Run migration if role_id column needs updating
5. Run rsx:migrate:document_models
FRAMEWORK IMPLEMENTATION DETAILS
This section is for framework developers modifying the ACL system.
Core Files
rsx/models/user_model.php
Role and permission constants
$enums definition with role metadata
has_permission(), can_admin_role() methods
Supplementary permission lookup methods
rsx/permission.php
Permission class with static helper methods
has_permission(), has_role(), get_user()
Custom permission methods for #[Auth]
rsx/models/user_permission_model.php
Supplementary permissions CRUD
grant(), deny(), remove() static methods
Session Integration
Permission::get_user() retrieves current site membership:
1. Get login_user_id from Session::get_user_id()
2. Get site_id from Session::get_site_id()
3. Query users WHERE login_user_id AND site_id
4. Cache result for request duration
Caching Strategy
Supplementary permissions are loaded once per request:
1. First has_permission() call loads all user_permissions
2. Stored in User_Model instance property
3. Subsequent checks use cached data
4. No cache invalidation needed (request-scoped)
Enum Integration
The $enums system provides magic properties:
$user->role_permissions // Array from enum definition
$user->role_can_admin_roles // Array from enum definition
$user->role_label // String label
These are resolved via Rsx_Model_Abstract::__get()
FUTURE ENHANCEMENTS
Attribute-Based Permission Checks
Future goal: Declare permissions directly in route attributes.
#[Route('/settings/users')]
#[Auth('Permission::authenticated()')]
#[RequiresPermission(User_Model::PERM_MANAGE_SITE_USERS)]
public static function users(...)
This section will be replaced with implementation details when
attribute-based permission checking is complete.
Custom Filters
Future goal: Allow permission modifications based on context.
Use cases:
- Site-level feature toggles (site disables user management)
- Subscription limits (free plan removes export permission)
- Time-based access (permission valid only during hours)
- Feature flags (A/B testing permission-gated features)
Architecture concept:
Role Permissions
Custom Filters (modify permission set)
Supplementary GRANT/DENY
Final Permission Decision
Filters would be registered callbacks that receive the permission
set and context, returning modified permissions. This allows
dynamic permission modification without changing role definitions.
This section will be replaced with implementation details when
custom filters are implemented.
MIGRATION FROM LEGACY
If upgrading from a system without ACLs:
1. Add role_id column to users table (default ROLE_USER)
2. Create user_permissions table
3. Assign appropriate roles to existing users
4. Add Permission checks to sensitive routes
5. Test with rsx:debug --user_id= to verify
SEE ALSO
auth - Authentication system (login, sessions, invitations)
enums - Enum system for role/permission metadata
routing - Route protection with #[Auth] attribute
session - Session management and user context
RSpade 1.0 November 2024 ACLS(7)

View File

@@ -285,7 +285,7 @@ CLIENT-SIDE IMPLEMENTATION
Rsx.render_error(error, '#error_container');
// Display in form's error container (for Rsx_Form use form.render_error())
Rsx.render_error(error, this.$id('error'));
Rsx.render_error(error, this.$sid('error'));
Handles all error types:
- fatal: Shows file:line and full message

View File

@@ -793,85 +793,119 @@ API REFERENCE
CONTROLLER PATTERNS
Route Protection:
Authentication Philosophy:
Use #[Auth] attribute on routes to require authentication:
RSX uses manual authentication checks rather than declarative attributes.
This approach ensures developers explicitly handle auth at the code level,
making permission logic visible and traceable in the actual method code.
#[Route('/dashboard')]
#[Auth('Permission::authenticated()')]
public static function dashboard(Request $request, array $params = [])
A code quality rule (PHP-AUTH-01) verifies that all endpoints have auth
checks, either in the method body or in the controller's pre_dispatch().
Controller-Level Authentication (Recommended):
Add auth check to pre_dispatch() to protect all endpoints in a controller:
class My_Controller extends Rsx_Controller_Abstract
{
// User guaranteed to be logged in
$user = RsxAuth::user();
}
Built-in permission methods:
Permission::anybody() Allow all (public route)
Permission::authenticated() Require login
Custom permissions in rsx/permission.php:
class Permission
{
public static function is_admin(): bool|Response
public static function pre_dispatch(Request $request, array $params = [])
{
if (!RsxAuth::check()) {
return false;
if (!Session::is_logged_in()) {
return response_unauthorized();
}
return null;
}
$user_id = RsxAuth::id();
$site_id = Session::get_site_id();
#[Route('/dashboard')]
public static function dashboard(Request $request, array $params = [])
{
// User guaranteed to be logged in (checked in pre_dispatch)
$user = Session::get_user();
}
$user = User_Model::where('login_user_id', $user_id)
->where('site_id', $site_id)
->first();
return $user && $user->role_id <= 2; // OWNER or ADMIN
#[Ajax_Endpoint]
public static function save_data(Request $request, array $params = [])
{
// Also protected by pre_dispatch
}
}
Usage in routes:
Method-Level Authentication:
#[Route('/admin/users')]
#[Auth('Permission::is_admin()')]
public static function admin_users(Request $request, array $params = [])
{
// User is logged in AND is admin
}
Ajax Endpoint Authentication:
Ajax endpoints check authentication same as regular routes:
Add auth check at the start of individual methods:
#[Ajax_Endpoint]
#[Auth('Permission::authenticated()')]
public static function save_settings(Request $request, array $params = [])
{
$user_id = RsxAuth::id();
if (!Session::is_logged_in()) {
return response_unauthorized();
}
// Process request
}
On authentication failure:
Regular routes: Redirect to /login
Ajax endpoints: Return JSON: {success: false, error_type: "permission_denied"}
Response Helpers:
Manual Authentication in Main.php:
response_unauthorized(?string $message = null)
Returns context-aware unauthorized response:
- Ajax: JSON {success: false, error_code: 'unauthorized'}
- Web: Redirect to login or render 403 page
Use pre_dispatch() for application-wide auth logic:
response_not_found(?string $message = null)
Returns context-aware not found response:
- Ajax: JSON {success: false, error_code: 'not_found'}
- Web: Render 404 page
public static function pre_dispatch(Request $request, array $params = [])
{
// Require login for all /app/* routes
if (str_starts_with($request->path(), 'app/')) {
if (!RsxAuth::check()) {
return redirect('/login');
}
}
Permission Checks:
return null;
Use Permission class helpers for role/permission-based access:
if (!Permission::has_permission(User_Model::PERM_EDIT_DATA)) {
return response_unauthorized('Insufficient permissions');
}
if (!Permission::has_role(User_Model::ROLE_MANAGER)) {
return response_unauthorized('Manager access required');
}
Public Endpoints (Auth Exempt):
For public endpoints, add @auth-exempt comment to class docblock:
/**
* Login controller
*
* @auth-exempt Login routes are public by design
*/
class Login_Controller extends Rsx_Controller_Abstract
{
#[Route('/login')]
public static function index(Request $request, array $params = [])
{
// No auth check needed - marked exempt
}
}
Or exempt individual methods:
/**
* @auth-exempt Public webhook endpoint
*/
#[Ajax_Endpoint]
public static function webhook(Request $request, array $params = [])
{
// Process webhook
}
Code Quality Enforcement:
The PHP-AUTH-01 rule in rsx:check verifies:
- All #[Route], #[SPA], #[Ajax_Endpoint] methods have auth checks
- Check can be in pre_dispatch() OR method body
- @auth-exempt comment exempts from requirement
Run: php artisan rsx:check
SINGLE-SITE APPLICATIONS
Overview:

View File

@@ -4,23 +4,35 @@ NAME
Bundle - RSX asset compilation and management system
SYNOPSIS
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
// Module Bundle (page entry point)
use App\RSpade\Core\Bundle\Rsx_Module_Bundle_Abstract;
class My_Bundle extends Rsx_Bundle_Abstract
class My_Bundle extends Rsx_Module_Bundle_Abstract
{
public static function define(): array
{
return [
'include' => [
'jquery', // Module alias
'Bootstrap5_Bundle', // Bundle class
'rsx/app/myapp', // Directory
'rsx/lib/utils.js', // Specific file
'bootstrap5', // Module alias
'Quill_Bundle', // Asset bundle (explicit)
'rsx/theme', // Directory (auto-discovers asset bundles)
__DIR__, // Module directory
],
];
}
}
// Asset Bundle (dependency declaration, auto-discovered)
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
class Tom_Select_Bundle extends Rsx_Asset_Bundle_Abstract
{
public static function define(): array
{
return ['npm' => ['tom-select']];
}
}
// Render in Blade
{!! My_Bundle::render() !!}
@@ -64,51 +76,123 @@ DESCRIPTION
- Zero configuration SCSS compilation
CREATING A BUNDLE
1. Extend Rsx_Bundle_Abstract
2. Implement define() method
3. Return configuration array with 'include' key
CREATING A MODULE BUNDLE (page entry point):
1. Extend Rsx_Module_Bundle_Abstract
2. Implement define() method
3. Return configuration array with 'include' key
Example:
class Dashboard_Bundle extends Rsx_Bundle_Abstract
{
public static function define(): array
Example:
class Dashboard_Bundle extends Rsx_Module_Bundle_Abstract
{
return [
'include' => [
'jquery',
'lodash',
'bootstrap5',
'rsx/app/dashboard',
],
'config' => [
'api_version' => '2.0',
],
];
public static function define(): array
{
return [
'include' => [
'bootstrap5',
'rsx/app/dashboard',
],
'config' => [
'api_version' => '2.0',
],
];
}
}
}
CREATING AN ASSET BUNDLE (dependency declaration):
1. Extend Rsx_Asset_Bundle_Abstract
2. Place alongside components that need the dependency
3. Declare NPM modules, CDN assets, or direct file paths
Example:
class Chart_JS_Bundle extends Rsx_Asset_Bundle_Abstract
{
public static function define(): array
{
return [
'cdn_assets' => [
'js' => [
['url' => 'https://cdn.jsdelivr.net/npm/chart.js'],
],
],
];
}
}
BUNDLE TYPES
RSX has two types of bundles with distinct purposes:
MODULE BUNDLES (Rsx_Module_Bundle_Abstract)
Top-level bundles that get compiled and rendered on pages.
- Can scan directories via 'include' paths
- Can explicitly include Asset Bundles by class name
- Auto-discovers Asset Bundles in scanned directories
- Gets built via rsx:bundle:build
- Cannot include other Module Bundles
Example:
class Frontend_Bundle extends Rsx_Module_Bundle_Abstract {
public static function define(): array {
return [
'include' => [
__DIR__, // Directory scan
'rsx/theme', // Directory scan (auto-discovers asset bundles)
'Quill_Bundle', // Explicit asset bundle
],
];
}
}
ASSET BUNDLES (Rsx_Asset_Bundle_Abstract)
Dependency declaration bundles co-located with components.
- NO directory scanning - only direct file paths
- Declares CDN assets, NPM modules, watch directories
- Auto-discovered when Module Bundles scan directories
- Never built standalone - metadata consumed by Module Bundles
- Can include other Asset Bundles by class name
Example:
// /rsx/theme/components/inputs/select/Tom_Select_Bundle.php
class Tom_Select_Bundle extends Rsx_Asset_Bundle_Abstract {
public static function define(): array {
return [
'npm' => ['tom-select'],
];
}
}
AUTO-DISCOVERY
When a Module Bundle scans a directory, Asset Bundles found within
are automatically processed. Their CDN assets, NPM modules, and
config are merged into the parent bundle.
This allows components to declare their own dependencies without
requiring explicit inclusion in every Module Bundle that uses them.
Discovered Asset Bundles cannot have directory scan paths in their
'include' array. If an Asset Bundle needs directory scanning, it
must be explicitly included by class name in the parent Module Bundle.
BUNDLE PLACEMENT
Bundles exist ONLY at the top-level module directory. Never create
bundles in subdirectories or submodules.
MODULE BUNDLES exist at top-level module directories:
CORRECT:
/rsx/app/login/login_bundle.php ✓ Top-level module
/rsx/app/dashboard/dashboard_bundle.php ✓ Top-level module
/rsx/app/frontend/frontend_bundle.php ✓ Top-level module
/rsx/app/login/login_bundle.php - Module Bundle
/rsx/app/frontend/frontend_bundle.php - Module Bundle
INCORRECT:
/rsx/app/login/accept_invite/accept_invite_bundle.php ✗ Subdirectory
/rsx/app/frontend/settings/settings_bundle.php ✗ Submodule
/rsx/app/frontend/users/edit/edit_bundle.php ✗ Feature
ASSET BUNDLES live alongside their components:
WHY TOP-LEVEL ONLY:
Including __DIR__ in a top-level bundle automatically includes all
files in that module directory and all subdirectories recursively.
This provides complete coverage without needing multiple bundles.
CORRECT:
/rsx/theme/components/inputs/select/Tom_Select_Bundle.php
/rsx/theme/bootstrap5_src_bundle.php
MODULE BUNDLE COVERAGE:
Including __DIR__ in a Module Bundle automatically includes all
files in that directory and subdirectories recursively, while
auto-discovering any Asset Bundles found.
Example:
// /rsx/app/login/login_bundle.php
class Login_Bundle extends Rsx_Bundle_Abstract
class Login_Bundle extends Rsx_Module_Bundle_Abstract
{
public static function define(): array
{
@@ -124,16 +208,9 @@ BUNDLE PLACEMENT
/rsx/app/login/login_controller.php
/rsx/app/login/login_index.blade.php
/rsx/app/login/login_index.js
/rsx/app/login/accept_invite/accept_invite_controller.php
/rsx/app/login/accept_invite/accept_invite.blade.php
/rsx/app/login/accept_invite/accept_invite.js
/rsx/app/login/accept_invite/create_account.blade.php
/rsx/app/login/accept_invite/create_account.js
/rsx/app/login/accept_invite/...
/rsx/app/login/signup/...
... and all other files in subdirectories
The __DIR__ constant resolves to the bundle's directory, making it
self-referential and automatically including all module content.
... and all files in subdirectories
INCLUDE TYPES
Module Aliases

View File

@@ -159,7 +159,7 @@ TROUBLESHOOTING
Console_debug not showing:
- Check CONSOLE_DEBUG_ENABLED=true
- Verify filter settings
- Use php artisan rsx:debug --console-log
- Use php artisan rsx:debug --console
Bundles not updating:
- Clear bundle cache in storage/rsx-build/bundles

View File

@@ -5,17 +5,24 @@ NAME
SYNOPSIS
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use App\RSpade\Core\Session\Session;
class User_Controller extends Rsx_Controller_Abstract
{
#[Auth('Permission::authenticated()')]
public static function pre_dispatch(Request $request, array $params = [])
{
if (!Session::is_logged_in()) {
return response_unauthorized();
}
return null;
}
#[Route('/users', methods: ['GET'])]
public static function index(Request $request, array $params = [])
{
return rsx_view('User_List');
}
#[Auth('Permission::authenticated()')]
#[Ajax_Endpoint]
public static function get_profile(Request $request, array $params = [])
{
@@ -98,95 +105,73 @@ ROUTE PARAMETERS
$name = $request->input('name');
$email = $request->post('email');
REQUIRE ATTRIBUTE
#[Auth(callable, message, redirect, redirect_to)]
REQUIRED on all routes. Defines access control check.
callable: 'Class::method()' string to execute
message: Optional error message
redirect: Optional URL to redirect on failure (HTTP only)
redirect_to: Optional ['Controller', 'action'] (HTTP only)
AUTHENTICATION
All routes MUST have at least one #[Auth] attribute, either on:
- The route method itself
- The controller's pre_dispatch() method (applies to all routes)
- Both (pre_dispatch Require runs first, then route Require)
RSX uses manual authentication checks rather than declarative attributes.
Auth checks are placed directly in controller code for visibility.
Multiple #[Auth] attributes are supported - all must pass.
A code quality rule (PHP-AUTH-01) verifies all endpoints have auth checks,
either in the method body or controller's pre_dispatch().
Permission Method Contract:
public static function method_name(Request $request, array $params, ...$args): mixed
Controller-Wide Authentication (Recommended):
Returns:
- true or null: Allow access
- false: Deny access
- Response: Custom response (overrides default handling)
Examples:
// Public access
#[Auth('Permission::anybody()')]
#[Route('/')]
public static function index(Request $request, array $params = [])
class Dashboard_Controller extends Rsx_Controller_Abstract
{
return rsx_view('Landing');
}
// Authenticated users only
#[Auth('Permission::authenticated()',
message: 'Please log in',
redirect: '/login')]
#[Route('/dashboard')]
public static function dashboard(Request $request, array $params = [])
{
return rsx_view('Dashboard');
}
// Redirect using controller/action
#[Auth('Permission::authenticated()',
message: 'Login required',
redirect_to: ['Login_Index_Controller', 'show_login'])]
#[Route('/profile')]
public static function profile(Request $request, array $params = [])
{
return rsx_view('Profile');
}
// Permission with arguments
#[Auth('Permission::has_role("admin")')]
#[Route('/admin')]
public static function admin_panel(Request $request, array $params = [])
{
return rsx_view('Admin_Panel');
}
// Multiple requirements
#[Auth('Permission::authenticated()')]
#[Auth('Permission::has_permission("edit_users")')]
#[Route('/users/edit')]
public static function edit_users(Request $request, array $params = [])
{
return rsx_view('User_Edit');
}
// Controller-wide requirement
class Admin_Controller extends Rsx_Controller_Abstract
{
#[Auth('Permission::has_role("admin")',
message: 'Admin access required',
redirect: '/')]
public static function pre_dispatch(Request $request, array $params = [])
{
if (!Session::is_logged_in()) {
return response_unauthorized();
}
return null;
}
// All routes in this controller require admin role
#[Route('/admin/users')]
public static function users(Request $request, array $params = [])
#[Route('/dashboard')]
public static function index(Request $request, array $params = [])
{
return rsx_view('Admin_Users');
// Auth checked in pre_dispatch
return rsx_view('Dashboard');
}
}
Creating Permission Methods (rsx/permission.php):
Method-Level Authentication:
#[Route('/admin')]
public static function admin_panel(Request $request, array $params = [])
{
if (!Session::is_logged_in()) {
return response_unauthorized();
}
if (!Permission::has_role(User_Model::ROLE_ADMIN)) {
return response_unauthorized('Admin access required');
}
return rsx_view('Admin_Panel');
}
Response Helpers:
response_unauthorized(?string $message = null)
Context-aware: JSON for Ajax, redirect/403 for web
response_not_found(?string $message = null)
Context-aware: JSON for Ajax, 404 page for web
Public Endpoints:
Mark public endpoints with @auth-exempt in class docblock:
/**
* @auth-exempt Public landing page
*/
class Landing_Controller extends Rsx_Controller_Abstract
{
#[Route('/')]
public static function index(Request $request, array $params = [])
{
return rsx_view('Landing');
}
}
Permission Helpers (rsx/permission.php):
class Permission extends Permission_Abstract
{
public static function anybody(Request $request, array $params): mixed
@@ -208,23 +193,21 @@ REQUIRE ATTRIBUTE
}
}
AJAX ENDPOINTS AND REQUIRE
Ajax endpoints also require #[Auth] attributes.
For Ajax endpoints, redirect parameters are ignored and JSON errors returned:
AJAX ENDPOINTS AND AUTHENTICATION
Ajax endpoints use the same auth pattern as routes.
The pre_dispatch() check covers all methods in the controller:
#[Auth('Permission::authenticated()',
message: 'Login required')]
#[Ajax_Endpoint]
public static function get_data(Request $request, array $params = [])
{
// Auth checked in pre_dispatch
return ['data' => 'value'];
}
On failure, returns:
On response_unauthorized(), Ajax returns:
{
"success": false,
"error": "Login required",
"error_type": "permission_denied"
"error_code": "unauthorized"
}
HTTP Status: 403 Forbidden
@@ -296,7 +279,7 @@ PRE_DISPATCH HOOK
public static function pre_dispatch(Request $request, array $params = [])
{
// Check authentication
if (!RsxAuth::check()) {
if (!Session::is_logged_in()) {
return redirect('/login');
}
@@ -322,11 +305,11 @@ RESPONSE TYPES
return response()->json(['key' => 'value']);
AUTHENTICATION
Use RsxAuth in pre_dispatch:
Use Session:: in pre_dispatch:
public static function pre_dispatch(Request $request, array $params = [])
{
if (!RsxAuth::check()) {
if (!Session::is_logged_in()) {
if ($request->ajax()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
@@ -436,26 +419,25 @@ TROUBLESHOOTING
- Run php artisan rsx:routes to list all
- Clear cache: php artisan rsx:clean
Missing #[Auth] attribute error:
- Add #[Auth('Permission::anybody()')] to route method
- OR add #[Auth] to pre_dispatch() for controller-wide access
- Rebuild manifest: php artisan rsx:manifest:build
PHP-AUTH-01 code quality error:
- Add auth check to pre_dispatch() (recommended)
- OR add auth check at start of method
- OR add @auth-exempt comment for public endpoints
- Run: php artisan rsx:check
Permission denied (403):
- Check permission method logic returns true
- Verify Session::is_logged_in() for authenticated routes
- Add message parameter for clearer errors
- Check permission method exists in rsx/permission.php
Permission denied / Unauthorized:
- Check Session::is_logged_in() returns true
- Verify Permission helpers in rsx/permission.php
- Check user has required role/permission
JavaScript stub missing:
- Ensure Ajax_Endpoint attribute present
- Ensure #[Auth] attribute present on Ajax method
- Rebuild manifest: php artisan rsx:manifest:build
- Check storage/rsx-build/js-stubs/
Authentication issues:
- Implement pre_dispatch hook
- Use Permission::authenticated() in Require
- Implement pre_dispatch() with Session::is_logged_in() check
- Return response_unauthorized() on failure
- Verify session configuration
SEE ALSO

570
app/RSpade/man/crud.txt Executable file
View File

@@ -0,0 +1,570 @@
CRUD(3) RSX Framework Manual CRUD(3)
NAME
crud - Standard CRUD implementation pattern for RSpade applications
SYNOPSIS
A complete CRUD (Create, Read, Update, Delete) implementation consists of:
Directory Structure:
rsx/app/frontend/{feature}/
{feature}_controller.php # Ajax endpoints
list/
{Feature}_Index_Action.js # List page action
{Feature}_Index_Action.jqhtml # List page template
{feature}_datagrid.php # DataGrid backend
{feature}_datagrid.jqhtml # DataGrid template
view/
{Feature}_View_Action.js # Detail page action
{Feature}_View_Action.jqhtml # Detail page template
edit/
{Feature}_Edit_Action.js # Add/Edit page action
{Feature}_Edit_Action.jqhtml # Add/Edit page template
Model:
rsx/models/{feature}_model.php # With fetch() method
DESCRIPTION
This document describes the standard pattern for implementing CRUD
functionality in RSpade SPA applications. The pattern provides:
- Consistent file organization across all features
- DataGrid for listing with sorting, filtering, pagination
- Model.fetch() for loading single records
- Rsx_Form for add/edit with automatic Ajax submission
- Server-side validation with field-level errors
- Three-state loading pattern (loading/error/content)
- Single action class handling both add and edit modes
DIRECTORY STRUCTURE
Each CRUD feature uses three subdirectories:
list/ - Index page with DataGrid listing all records
view/ - Detail page showing single record
edit/ - Combined add/edit form (dual-route action)
The controller sits at the feature root and provides Ajax endpoints
for all operations (datagrid_fetch, save, delete, restore).
MODEL SETUP
Fetchable Model
To load records from JavaScript, the model needs a fetch() method
with the #[Ajax_Endpoint_Model_Fetch] attribute:
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
{
$record = static::withTrashed()->find($id);
if (!$record) {
return false;
}
return [
'id' => $record->id,
'name' => $record->name,
// Include all fields needed by view/edit pages
// Add computed fields for display
'status_label' => ucfirst($record->status),
'created_at_formatted' => $record->created_at->format('M d, Y'),
'created_at_human' => $record->created_at->diffForHumans(),
];
}
Key points:
- Use withTrashed() if soft deletes should be viewable
- Return false (not null) when record not found
- Include computed fields needed for display (labels, badges, formatted dates)
- The attribute enables Model.fetch(id) in JavaScript
See model_fetch(3) for complete documentation.
FEATURE CONTROLLER
The controller provides Ajax endpoints for all CRUD operations:
class Frontend_Clients_Controller extends Rsx_Controller_Abstract
{
#[Auth('Permission::anybody()')]
public static function pre_dispatch(Request $request, array $params = [])
{
return null;
}
// DataGrid data endpoint
#[Auth('Permission::anybody()')]
#[Ajax_Endpoint]
public static function datagrid_fetch(Request $request, array $params = [])
{
return Clients_DataGrid::fetch($params);
}
// Save (create or update)
#[Auth('Permission::anybody()')]
#[Ajax_Endpoint]
public static function save(Request $request, array $params = [])
{
// Validation
$errors = [];
if (empty($params['name'])) {
$errors['name'] = 'Name is required';
}
if (!empty($errors)) {
return response_error(Ajax::ERROR_VALIDATION, $errors);
}
// Create or update
$id = $params['id'] ?? null;
if ($id) {
$record = Client_Model::find($id);
if (!$record) {
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
}
} else {
$record = new Client_Model();
}
// Set fields explicitly (no mass assignment)
$record->name = $params['name'];
$record->email = $params['email'] ?? null;
// ... all fields ...
$record->save();
Flash_Alert::success($id ? 'Updated successfully' : 'Created successfully');
return [
'id' => $record->id,
'redirect' => Rsx::Route('Clients_View_Action', $record->id),
];
}
// Soft delete
#[Auth('Permission::anybody()')]
#[Ajax_Endpoint]
public static function delete(Request $request, array $params = [])
{
$record = Client_Model::find($params['id']);
if (!$record) {
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
}
$record->delete();
return ['message' => 'Deleted successfully'];
}
// Restore soft-deleted record
#[Auth('Permission::anybody()')]
#[Ajax_Endpoint]
public static function restore(Request $request, array $params = [])
{
$record = Client_Model::withTrashed()->find($params['id']);
if (!$record) {
return response_error(Ajax::ERROR_NOT_FOUND, 'Client not found');
}
if (!$record->trashed()) {
return response_error(Ajax::ERROR_VALIDATION, ['message' => 'Not deleted']);
}
$record->restore();
return ['message' => 'Restored successfully'];
}
}
Validation Pattern
Return field-level errors that Rsx_Form can display:
$errors = [];
if (empty($params['name'])) {
$errors['name'] = 'Name is required';
}
if (!empty($params['email']) && !filter_var($params['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Invalid email address';
}
if (!empty($errors)) {
return response_error(Ajax::ERROR_VALIDATION, $errors);
}
The errors array keys must match form field names. Rsx_Form
automatically displays these errors next to the corresponding fields.
LIST PAGE (INDEX)
Action Class (list/{Feature}_Index_Action.js)
@route('/clients')
@layout('Frontend_Spa_Layout')
@spa('Frontend_Spa_Controller::index')
@title('Clients - RSX')
class Clients_Index_Action extends Spa_Action {
full_width = true; // DataGrid pages typically use full width
async on_load() {
// DataGrid loads its own data - nothing to do here
}
}
Template (list/{Feature}_Index_Action.jqhtml)
<Define:Clients_Index_Action>
<Page>
<Page_Header>
<Page_Header_Left>
<Page_Title>Clients</Page_Title>
</Page_Header_Left>
<Page_Header_Right>
<a href="<%= Rsx.Route('Clients_Edit_Action') %>" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> New
</a>
</Page_Header_Right>
</Page_Header>
<Clients_DataGrid />
</Page>
</Define:Clients_Index_Action>
DATAGRID
Backend Class (list/{feature}_datagrid.php)
Extend DataGrid_Abstract and implement build_query():
class Clients_DataGrid extends DataGrid_Abstract
{
protected static array $sortable_columns = [
'id', 'name', 'city', 'created_at',
];
protected static function build_query(array $params): Builder
{
$query = Client_Model::query();
// Apply search filter
if (!empty($params['filter'])) {
$filter = $params['filter'];
$query->where(function ($q) use ($filter) {
$q->where('name', 'LIKE', "%{$filter}%")
->orWhere('city', 'LIKE', "%{$filter}%");
});
}
return $query;
}
// Optional: transform records after fetch
protected static function transform_records(array $records, array $params): array
{
foreach ($records as &$record) {
$record['full_address'] = $record['city'] . ', ' . $record['state'];
}
return $records;
}
}
Template (list/{feature}_datagrid.jqhtml)
Extend DataGrid_Abstract and define columns and row template:
<Define:Clients_DataGrid
extends="DataGrid_Abstract"
$data_source=Frontend_Clients_Controller.datagrid_fetch
$sort="id"
$order="desc"
$per_page=15
class="card DataGrid">
<#DG_Card_Header>
<Card_Title>Client List</Card_Title>
<Card_Header_Right>
<Search_Input $sid="filter_input" $placeholder="Search..." />
</Card_Header_Right>
</#DG_Card_Header>
<#DG_Table_Header>
<tr>
<th data-sortby="id">ID</th>
<th data-sortby="name">Name</th>
<th data-sortby="created_at">Created</th>
<th>Actions</th>
</tr>
</#DG_Table_Header>
<#row>
<tr data-href="<%= Rsx.Route('Clients_View_Action', row.id) %>">
<td><%= row.id %></td>
<td><%= row.name %></td>
<td><%= new Date(row.created_at).toLocaleDateString() %></td>
<td>
<div class="btn-group btn-group-sm">
<a class="btn btn-outline-primary" href="<%= Rsx.Route('Clients_View_Action', row.id) %>">
<i class="bi bi-eye"></i>
</a>
<a class="btn btn-outline-secondary" href="<%= Rsx.Route('Clients_Edit_Action', row.id) %>">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
</#row>
</Define:Clients_DataGrid>
Key attributes:
- $data_source: Controller method that returns data
- $sort/$order: Default sort column and direction
- $per_page: Records per page
- data-sortby: Makes column header clickable for sorting
- data-href: Makes entire row clickable
VIEW PAGE (DETAIL)
Action Class (view/{Feature}_View_Action.js)
Uses Model.fetch() and three-state pattern:
@route('/clients/view/:id')
@layout('Frontend_Spa_Layout')
@spa('Frontend_Spa_Controller::index')
@title('Client Details')
class Clients_View_Action extends Spa_Action {
on_create() {
this.data.client = { name: '', tags: [] }; // Stub
this.data.error_data = null;
this.data.loading = true;
}
async on_load() {
try {
this.data.client = await Client_Model.fetch(this.args.id);
} catch (e) {
this.data.error_data = e;
}
this.data.loading = false;
}
}
Template (view/{Feature}_View_Action.jqhtml)
Three-state template pattern:
<Define:Clients_View_Action>
<Page>
<% if (this.data.loading) { %>
<Loading_Spinner $message="Loading..." />
<% } else if (this.data.error_data) { %>
<Universal_Error_Page_Component
$error_data="<%= this.data.error_data %>"
$record_type="Client"
$back_label="Go back to Clients"
$back_url="<%= Rsx.Route('Clients_Index_Action') %>"
/>
<% } else { %>
<Page_Header>
<Page_Header_Left>
<Page_Title><%= this.data.client.name %></Page_Title>
</Page_Header_Left>
<Page_Header_Right>
<a href="<%= Rsx.Route('Clients_Edit_Action', this.data.client.id) %>" class="btn btn-primary btn-sm">
<i class="bi bi-pencil"></i> Edit
</a>
</Page_Header_Right>
</Page_Header>
<!-- Display fields using this.data.client.* -->
<div class="card card-body">
<label class="text-muted small">Name</label>
<div class="fw-bold"><%= this.data.client.name %></div>
</div>
<% } %>
</Page>
</Define:Clients_View_Action>
See view_action_patterns(3) for detailed documentation.
EDIT PAGE (ADD/EDIT COMBINED)
Dual Route Pattern
A single action handles both add and edit via two @route decorators:
@route('/clients/add')
@route('/clients/edit/:id')
The action detects mode by checking for this.args.id:
- Add mode: this.args.id is undefined
- Edit mode: this.args.id contains the record ID
Action Class (edit/{Feature}_Edit_Action.js)
@route('/clients/add')
@route('/clients/edit/:id')
@layout('Frontend_Spa_Layout')
@spa('Frontend_Spa_Controller::index')
@title('Client')
class Clients_Edit_Action extends Spa_Action {
on_create() {
this.data.is_edit = !!this.args.id;
// Form data stub with defaults
this.data.form_data = {
name: '',
email: '',
status: 'active',
// ... all fields with defaults
};
// Dropdown options
this.data.status_options = {
active: 'Active',
inactive: 'Inactive',
};
this.data.error_data = null;
this.data.loading = this.data.is_edit; // Only load in edit mode
}
async on_load() {
if (!this.data.is_edit) {
return; // Add mode - nothing to load
}
try {
const record = await Client_Model.fetch(this.args.id);
// Populate form_data from record
this.data.form_data = {
id: record.id,
name: record.name,
email: record.email,
status: record.status || 'active',
// ... map all fields
};
} catch (e) {
this.data.error_data = e;
}
this.data.loading = false;
}
}
Template (edit/{Feature}_Edit_Action.jqhtml)
<Define:Clients_Edit_Action>
<Page>
<% if (this.data.loading) { %>
<Loading_Spinner $message="Loading..." />
<% } else if (this.data.error_data) { %>
<Universal_Error_Page_Component ... />
<% } else { %>
<Page_Header>
<Page_Title><%= this.data.is_edit ? 'Edit Client' : 'Add Client' %></Page_Title>
</Page_Header>
<Rsx_Form
$data="<%= JSON.stringify(this.data.form_data) %>"
$controller="Frontend_Clients_Controller"
$method="save">
<% if (this.data.is_edit) { %>
<Form_Hidden_Field $name="id" />
<% } %>
<Form_Field $name="name" $label="Name" $required=true>
<Text_Input />
</Form_Field>
<Form_Field $name="status" $label="Status">
<Select_Input $options="<%= JSON.stringify(this.data.status_options) %>" />
</Form_Field>
<button type="submit" class="btn btn-primary">Save</button>
</Rsx_Form>
<% } %>
</Page>
</Define:Clients_Edit_Action>
RSX_FORM
The Rsx_Form component provides Ajax form submission with automatic
error handling. Required attributes:
$data - JSON string of initial form values
$controller - Controller class name
$method - Ajax endpoint method name
Form Fields
<Form_Field $name="fieldname" $label="Label" $required=true>
<Text_Input />
</Form_Field>
The $name must match:
- The key in $data JSON
- The key in server-side $params
- The key in validation $errors array
Hidden Fields
For edit mode, include the record ID:
<% if (this.data.is_edit) { %>
<Form_Hidden_Field $name="id" />
<% } %>
Available Input Components
- Text_Input ($type: text, email, url, password, number, textarea)
- Select_Input ($options: array or object)
- Checkbox_Input ($label: checkbox text)
- Phone_Text_Input (formatted phone input)
- Country_Select_Input, State_Select_Input
- File_Input (for uploads)
Validation Display
When the server returns response_error(Ajax::ERROR_VALIDATION, $errors),
Rsx_Form automatically displays errors next to matching fields.
Success Handling
When the server returns a redirect URL, Rsx_Form navigates there:
return [
'redirect' => Rsx::Route('Clients_View_Action', $record->id),
];
See forms_and_widgets(3) for custom form components.
TESTING
Test each page with rsx:debug:
php artisan rsx:debug /clients --console
php artisan rsx:debug /clients/view/1 --console
php artisan rsx:debug /clients/add --console
php artisan rsx:debug /clients/edit/1 --console
Test Ajax endpoints:
php artisan rsx:ajax Frontend_Clients_Controller datagrid_fetch
php artisan rsx:ajax Frontend_Clients_Controller save --args='{"name":"Test"}'
QUICK REFERENCE
Files for a "clients" feature:
rsx/app/frontend/clients/
frontend_clients_controller.php
list/
Clients_Index_Action.js
Clients_Index_Action.jqhtml
clients_datagrid.php
clients_datagrid.jqhtml
view/
Clients_View_Action.js
Clients_View_Action.jqhtml
edit/
Clients_Edit_Action.js
Clients_Edit_Action.jqhtml
rsx/models/
client_model.php (with fetch())
Routes:
/clients - List (Clients_Index_Action)
/clients/view/:id - View (Clients_View_Action)
/clients/add - Add (Clients_Edit_Action)
/clients/edit/:id - Edit (Clients_Edit_Action)
Ajax Endpoints:
Frontend_Clients_Controller.datagrid_fetch - DataGrid data
Frontend_Clients_Controller.save - Create/update
Frontend_Clients_Controller.delete - Soft delete
Frontend_Clients_Controller.restore - Restore deleted
JavaScript Data Loading:
const record = await Client_Model.fetch(id);
SEE ALSO
model_fetch(3), view_action_patterns(3), forms_and_widgets(3),
spa(3), controller(3), module_organization(3)
RSX Framework 2025-11-23 CRUD(3)

View File

@@ -111,11 +111,11 @@ users
Schema: email, password_hash, name, site_id (FK), active status
login_users
Purpose: Authentication session/token tracking
Managed by: RsxAuth system
Records: Active login sessions with 365-day persistence
Usage: Developers query for "active sessions", implement logout-all
Schema: user_id (FK), session_token, ip_address, last_activity, expires_at
Purpose: Authentication identity tracking
Managed by: Session system
Records: Login credentials with 365-day session persistence
Usage: Developers query via Session::get_login_user()
Schema: email, password_hash, display_name, last_login, is_active
user_profiles
Purpose: Extended user profile information
@@ -281,7 +281,7 @@ Q: Would changing the schema break the developer-facing API?
NO → System table (underscore prefix) - implementation detail
Examples:
- User login sessions? System (_login_sessions) - API is RsxAuth methods
- User login sessions? System (_login_sessions) - API is Session:: methods
- User accounts? Core (users) - developers extend and query this
- Search index? System (_search_indexes) - API is Search::query()
- Client records? Application (clients) - business domain entity

View File

@@ -110,16 +110,23 @@ PHP CONSTANTS
JAVASCRIPT ACCESS
The manifest system generates JavaScript stub classes with enum support:
The framework generates JavaScript stub classes with full enum support.
See: php artisan rsx:man model_fetch (JAVASCRIPT CLASS ARCHITECTURE)
Constants
User_Model.STATUS_ACTIVE // 1
User_Model.STATUS_INACTIVE // 2
Static Constants
Project_Model.STATUS_ACTIVE // 2
Project_Model.STATUS_PLANNING // 1
Static Methods
User_Model.status_id_enum_val() // Full enum definitions
User_Model.status_id_enum_select() // Filtered for dropdowns
User_Model.status_id_label_list() // All labels keyed by value
Project_Model.status_enum_val() // Full enum definitions
Project_Model.status_enum_select() // Filtered for dropdowns
Project_Model.status_label_list() // All labels keyed by value
Instance Properties (after fetch)
const project = await Project_Model.fetch(1);
project.status // 2 (raw value)
project.status_label // "Active"
project.status_badge // "bg-success"
AJAX/JSON EXPORT

148
app/RSpade/man/external_api.txt Executable file
View File

@@ -0,0 +1,148 @@
EXTERNAL API
============
RSpade provides a system-level API key authentication mechanism for external API
access. API keys are managed as a system table (_api_keys) with an opinionated
interface - developers interact with the Session class rather than API key
records directly.
ARCHITECTURE
------------
API keys authenticate requests and establish a session context automatically.
The system handles:
- Key validation and lookup
- User context establishment (API key -> user -> session)
- Rate limiting (future)
- Usage tracking
Developers never interact with API key records directly. The Session class
provides the interface for checking authentication context regardless of
whether authentication came from a browser session or API key.
DATABASE SCHEMA
---------------
System table: _api_keys (not exposed to developers)
id BIGINT PRIMARY KEY
user_id BIGINT NOT NULL (FK to users)
name VARCHAR(255) - Human-readable key name
key_hash VARCHAR(255) - Hashed API key (never store plaintext)
key_prefix VARCHAR(16) - First chars for identification (e.g., "rsk_...")
user_role_id BIGINT NULL - Optional role override (see ROLE OVERRIDE below)
last_used_at DATETIME NULL
expires_at DATETIME NULL
is_revoked BOOLEAN DEFAULT FALSE
created_at DATETIME
updated_at DATETIME
Keys are tied to users (user_id). A user can have multiple API keys.
KEY FORMAT
----------
API keys use the format: rsk_{environment}_{random}
rsk_live_a1b2c3d4e5f6... (production)
rsk_test_x7y8z9a0b1c2... (development/test)
The prefix (rsk_live_, rsk_test_) is stored in key_prefix for identification.
Only the hash of the full key is stored.
ROLE OVERRIDE
-------------
The user_role_id column allows API keys to have reduced permissions compared
to the user's actual role. This enables creating restricted-access keys.
Rules:
- NULL: Key inherits user's actual role
- Set value: Key uses specified role IF user has permission to assign it
Role assignment follows ACL rules - a user cannot create an API key with
higher privileges than their own role allows. Even if a privileged role ID
is set on an API key record, the system will not grant access beyond what
the user themselves has.
Example: A "Member" user cannot create an API key with "Admin" privileges.
If such a record exists (e.g., from direct DB manipulation), the system
ignores the elevated role and uses the user's actual permissions.
This behavior depends on the ACL system implementation (see: acls.txt).
AUTHENTICATION FLOW (PLANNED)
-----------------------------
1. Client sends request with API key in header:
Authorization: Bearer rsk_live_a1b2c3d4...
2. System extracts key, hashes it, looks up in _api_keys
3. If valid and not revoked/expired:
- Load associated user
- Establish session context (user_id, site_id, role)
- Apply role override if set
- Update last_used_at
4. Request proceeds with user context available via Session class
5. Controller code uses Session::user(), Session::check() as normal -
no awareness needed of API vs browser authentication
USAGE (PLANNED)
---------------
For API consumers:
curl -H "Authorization: Bearer rsk_live_xxx" https://example.com/api/endpoint
For developers checking auth context:
// Works identically for browser sessions and API keys
if (Session::check()) {
$user = Session::user();
// ... handle authenticated request
}
// Check if current request is API-authenticated
if (Session::is_api_request()) {
// ... API-specific logic if needed
}
RATE LIMITING (FUTURE)
----------------------
Planned features:
- Per-key rate limits
- Configurable limits per role/plan
- Usage tracking and analytics
- Automatic throttling with appropriate HTTP responses
IP WHITELISTING (FUTURE)
------------------------
Planned features:
- Optional IP restrictions per key
- CIDR notation support
- Automatic rejection of requests from non-whitelisted IPs
KEY MANAGEMENT UI
-----------------
Users manage their API keys via Settings > API Keys:
- View existing keys (name, prefix, created, last used)
- Generate new keys (name required)
- Revoke keys (soft delete via is_revoked flag)
- Copy key to clipboard (full key shown only on creation)
The full API key is shown only once at creation time. Users must copy it
immediately as the system only stores the hash.
SEE ALSO
--------
acls.txt - Access control and role system
session.txt - Session management

View File

@@ -211,12 +211,12 @@ WIDGET INTERFACE
val() {
// Getter - return current value
if (arguments.length === 0) {
return this.$id('input').val();
return this.$sid('input').val();
}
// Setter - update value
else {
this.data.value = value || '';
this.$id('input').val(this.data.value);
this.$sid('input').val(this.data.value);
}
}
@@ -505,7 +505,7 @@ CREATING CUSTOM WIDGETS
<Define:Rating_Input class="Widget">
<div class="rating">
<% for (let i = 1; i <= 5; i++) { %>
<i $id="star_<%= i %>"
<i $sid="star_<%= i %>"
class="bi bi-star<%= this.data.value >= i ? '-fill' : '' %>"
data-rating="<%= i %>"></i>
<% } %>

View File

@@ -209,7 +209,7 @@ EXAMPLES
<% if (this.args.show_price === "true") { %>
<p class="price">$<%= this.data.product.price %></p>
<% } %>
<button $id="cart_btn">Add to Cart</button>
<button $sid="cart_btn">Add to Cart</button>
<% } %>
</Define:Product_Card>

View File

@@ -300,21 +300,21 @@ Your form component must:
- Extend Jqhtml_Component
- Implement vals() method for getting/setting values
- Use standard form HTML with name attributes
- Include error container: <div $id="error_container"></div>
- Include error container: <div $sid="error_container"></div>
Example form component (my_form.jqhtml):
<Define:My_Form tag="div">
<div $id="error_container"></div>
<div $sid="error_container"></div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" $id="name_input" name="name">
<input type="text" class="form-control" $sid="name_input" name="name">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" $id="email_input" name="email">
<input type="email" class="form-control" $sid="email_input" name="email">
</div>
</Define:My_Form>
@@ -334,14 +334,14 @@ Example form component class (my_form.js):
vals(values) {
if (values) {
// Setter
this.$id('name_input').val(values.name || '');
this.$id('email_input').val(values.email || '');
this.$sid('name_input').val(values.name || '');
this.$sid('email_input').val(values.email || '');
return null;
} else {
// Getter
return {
name: this.$id('name_input').val(),
email: this.$id('email_input').val()
name: this.$sid('name_input').val(),
email: this.$sid('email_input').val()
};
}
}
@@ -985,7 +985,7 @@ Modal Won't Close
- Use Modal.close() to force close
Validation Errors Not Showing
- Ensure form has <div $id="error_container"></div>
- Ensure form has <div $sid="error_container"></div>
- Verify field name attributes match error keys
- Check that fields are wrapped in .form-group containers
- Use Form_Utils.apply_form_errors(form.$, errors)

View File

@@ -99,12 +99,11 @@ Models can opt-in to client-side fetching by implementing fetch() with
JavaScript usage:
const product = await Product_Model.fetch(1);
const products = await Product_Model.fetch([1, 2, 3]);
console.log(product.status_label); // Enum properties populated
console.log(Product_Model.STATUS_ACTIVE); // Static enum constants
Framework automatically splits array IDs into individual fetch() calls for
security (no mass fetching).
See: php artisan rsx:man model_fetch
Returns instantiated JS model class with enum properties and optional custom
methods. See: php artisan rsx:man model_fetch
RELATIONSHIPS
@@ -349,6 +348,11 @@ Columns:
- Use BIGINT for all integers, TINYINT(1) for booleans only
- All text columns use UTF-8 (utf8mb4_unicode_ci collation)
TODO
- Real-time notifications: Broadcast model changes to connected clients
- Revision tracking: Auto-increment revision column via database trigger on update
SEE ALSO
php artisan rsx:man model_fetch - Ajax ORM fetch system

View File

@@ -1,3 +1,5 @@
MODEL_FETCH(3) RSX Framework Manual MODEL_FETCH(3)
NAME
model_fetch - RSX Ajax ORM with secure model fetching from JavaScript
@@ -20,6 +22,22 @@ DESCRIPTION
- Individual authorization checks for each record
- Automatic JavaScript stub generation
STATUS (as of 2025-11-23)
The Model Fetch system is fully implemented for application development MVP.
All core functionality documented in this manual is production-ready:
Implemented:
- fetch() and fetch_or_null() methods
- Lazy relationship loading (belongsTo, hasMany, morphTo, etc.)
- Enum properties on instances ({column}_{field} pattern)
- Static enum constants and accessor methods
- Automatic model hydration from Ajax responses
- JavaScript class hierarchy with Base_* stubs
- Authorization patterns and security model
Future enhancements (documented below under FUTURE DEVELOPMENT) will be
additive and non-breaking to the existing API.
SECURITY MODEL
Explicit Opt-In:
Models must deliberately implement fetch() with the attribute.
@@ -33,9 +51,9 @@ SECURITY MODEL
Models can filter sensitive fields before returning data.
Complete control over what data JavaScript receives.
No Mass Fetching:
Framework splits array requests into individual fetch calls.
Prevents bulk data extraction without individual authorization.
Single Record Fetching:
Each fetch() call retrieves exactly one record.
See FUTURE DEVELOPMENT for planned batch fetching.
IMPLEMENTING FETCHABLE MODELS
Required Components:
@@ -43,7 +61,12 @@ IMPLEMENTING FETCHABLE MODELS
2. Add #[Ajax_Endpoint_Model_Fetch] attribute
3. Accept exactly one parameter: $id (single ID only)
4. Implement authorization checks
5. Return model object or false
5. Return model object, custom array, or false
Return Value Options:
- Model object: Serialized via to_export_array(), includes __MODEL for hydration
- Custom array: Full control over data shape, include computed/formatted fields
- false: Record not found or unauthorized (MUST be false, not null)
Basic Implementation:
use Ajax_Endpoint_Model_Fetch;
@@ -54,7 +77,7 @@ IMPLEMENTING FETCHABLE MODELS
public static function fetch($id)
{
// Authorization check
if (!RsxAuth::check()) {
if (!Session::is_logged_in()) {
return false;
}
@@ -70,7 +93,7 @@ IMPLEMENTING FETCHABLE MODELS
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
{
$user = RsxAuth::user();
$user = Session::get_user();
if (!$user) {
return false;
}
@@ -95,7 +118,7 @@ IMPLEMENTING FETCHABLE MODELS
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
{
if (!RsxAuth::check()) {
if (!Session::is_logged_in()) {
return false;
}
@@ -113,98 +136,180 @@ IMPLEMENTING FETCHABLE MODELS
}
}
JAVASCRIPT USAGE
Single Record Fetching:
// Fetch single record
const product = await Product_Model.fetch(1);
if (product) {
console.log(product.name);
console.log(product.price);
}
// Handle fetch failure
const order = await Order_Model.fetch(999);
if (!order) {
console.log('Order not found or access denied');
}
Multiple Record Fetching:
// Framework automatically splits array into individual calls
const products = await Product_Model.fetch([1, 2, 3]);
products.forEach(product => {
if (product) {
console.log(product.name);
}
});
// Mixed results (some succeed, some fail authorization)
const orders = await Order_Model.fetch([101, 102, 103]);
const validOrders = orders.filter(order => order !== false);
Error Handling:
try {
const user = await User_Model.fetch(userId);
if (user) {
updateUserInterface(user);
} else {
showAccessDeniedMessage();
}
} catch (error) {
console.error('Fetch failed:', error);
showErrorMessage();
}
ARRAY HANDLING
Framework Behavior:
When JavaScript passes an array to fetch(), the framework:
1. Splits array into individual IDs
2. Calls fetch() once for each ID
3. Collects results maintaining array order
4. Returns array with same length (false for failed fetches)
Implementation Rules:
- NEVER use is_array($id) checks in fetch() method
- Always handle exactly one ID parameter
- Framework handles array splitting automatically
- Results maintain original array order
Example Results:
// JavaScript call
const results = await Product_Model.fetch([1, 2, 999]);
// Results array (999 not found or unauthorized)
[
{id: 1, name: "Product A"}, // Successful fetch
{id: 2, name: "Product B"}, // Successful fetch
false // Failed fetch
]
STUB GENERATION
Automatic JavaScript Stubs:
The framework automatically generates JavaScript stub classes
for models with #[Ajax_Endpoint_Model_Fetch] attributes.
Stub Class Generation:
// Generated stub for Product_Model
class Product_Model {
static async fetch(id) {
// Generated implementation calls PHP fetch() method
return await Rsx._internal_api_call('Product_Model', 'fetch', {id});
}
}
Bundle Integration:
Stubs are automatically included in JavaScript bundles when
models are discovered in the bundle's include paths.
AUTHORIZATION PATTERNS
User-Specific Access:
class User_Profile_Model extends Rsx_Model
Custom Array Return (recommended for CRUD pages):
class Client_Model extends Rsx_Model
{
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
{
$current_user = RsxAuth::user();
$client = static::withTrashed()->find($id);
if (!$client) {
return false;
}
// Return formatted array with computed fields
return [
'id' => $client->id,
'name' => $client->name,
'status' => $client->status,
// Computed display fields
'status_label' => ucfirst($client->status),
'status_badge' => match($client->status) {
'active' => 'bg-success',
'inactive' => 'bg-secondary',
default => 'bg-warning'
},
// Formatted dates
'created_at_formatted' => $client->created_at->format('M d, Y'),
'created_at_human' => $client->created_at->diffForHumans(),
// Related data
'region_name' => $client->region_name(),
'country_name' => $client->country_name(),
];
}
}
Note: When returning an array, the JavaScript side receives the plain
object (not a hydrated model instance). This is intentional - it gives
full control over the data shape for view/edit pages.
JAVASCRIPT USAGE
Single Record Fetching:
// fetch() throws if record not found - no need to check for null/false
const project = await Project_Model.fetch(1);
console.log(project.name);
console.log(project.status_label); // Enum properties populated
// fetch() errors are caught by Universal_Error_Page_Component automatically
// in SPA actions, or can be caught with try/catch if needed
Fetch With Null Fallback:
// Use fetch_or_null() when you want graceful handling of missing records
const order = await Order_Model.fetch_or_null(999);
if (!order) {
console.log('Order not found or access denied');
return;
}
// order is guaranteed to exist here
When to Use Each:
- fetch() - View/edit pages where record MUST exist (throws on not found)
- fetch_or_null() - Optional lookups where missing is valid (returns null)
Enum Properties on Instances:
const project = await Project_Model.fetch(1);
// All enum helper properties from PHP are available
console.log(project.status_id); // 2 (raw value)
console.log(project.status_id_label); // "Active"
console.log(project.status_id_badge); // "bg-success"
Static Enum Constants:
// Constants available on the class
if (project.status_id === Project_Model.STATUS_ACTIVE) {
console.log('Project is active');
}
// Get all enum values for dropdowns
const statusOptions = Project_Model.status_id_enum_select();
// {1: "Planning", 2: "Active", 3: "On Hold", ...}
// Get full enum config
const statusConfig = Project_Model.status_id_enum_val();
// {1: {label: "Planning", badge: "bg-info"}, ...}
Error Handling:
// In SPA actions, errors bubble up to Universal_Error_Page_Component
// No try/catch needed - just call fetch() and use the result
async on_load() {
this.data.user = await User_Model.fetch(this.args.id);
// If we get here, user exists and we have access
}
// For explicit error handling outside SPA context:
try {
const user = await User_Model.fetch(userId);
updateUserInterface(user);
} catch (error) {
if (error.code === Ajax.ERROR_NOT_FOUND) {
showNotFoundMessage();
} else {
showErrorMessage(error.message);
}
}
JAVASCRIPT CLASS ARCHITECTURE
Class Hierarchy:
The framework generates a three-level class hierarchy for each model:
Rsx_Js_Model // Framework base (fetch, refresh, toObject)
└── Base_Project_Model // Generated stub (enums, constants, relationships)
└── Project_Model // Concrete class (auto-generated or user-defined)
Base Stub Classes (Auto-Generated):
For each PHP model extending Rsx_Model_Abstract, the framework generates
a Base_* stub class with:
class Base_Project_Model extends Rsx_Js_Model {
static __MODEL = 'Project_Model'; // PHP class name for API calls
// Enum constants
static STATUS_PLANNING = 1;
static STATUS_ACTIVE = 2;
// Enum accessor methods
static status_enum_val() { ... } // Full enum config
static status_enum_select() { ... } // For dropdown population
// Relationship discovery
static get_relationships() { ... } // Returns array of names
// Relationship methods (async, lazy-loaded)
async client() { ... } // belongsTo → Model or null
async tasks() { ... } // hasMany → Model[]
}
Concrete Classes:
Concrete classes (without Base_ prefix) are what you use in application code.
They are either auto-generated or user-defined.
Auto-Generated (default):
If no custom JS file exists, the bundle compiler generates:
class Project_Model extends Base_Project_Model {}
User-Defined (optional):
Create a JS file with matching class name to add custom methods:
// rsx/models/Project_Model.js
class Project_Model extends Base_Project_Model {
get_display_title() {
return `${this.name} (${this.status_label})`;
}
}
Custom Base Class (Optional):
Configure a custom base class in rsx/resource/config/rsx.php to add
application-wide model functionality:
'js_model_base_class' => 'App_Model_Abstract',
Hierarchy becomes:
Rsx_Js_Model → App_Model_Abstract → Base_Project_Model → Project_Model
Bundle Integration:
- Base_* stubs auto-included when PHP model is in bundle
- Concrete classes auto-generated unless user-defined JS exists
- User-defined JS classes validated to extend Base_* directly
- Error thrown if custom JS exists in manifest but not in bundle
AUTHORIZATION PATTERNS
User-Specific Access:
class User_Profile_Model extends Rsx_Model_Abstract
{
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
{
$current_user = Session::get_user();
// Users can only fetch their own profile
if (!$current_user || $current_user->id != $id) {
@@ -216,12 +321,12 @@ AUTHORIZATION PATTERNS
}
Role-Based Access:
class Admin_Report_Model extends Rsx_Model
class Admin_Report_Model extends Rsx_Model_Abstract
{
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
{
$user = RsxAuth::user();
$user = Session::get_user();
// Only admin users can fetch reports
if (!$user || !$user->hasRole('admin')) {
@@ -233,7 +338,7 @@ AUTHORIZATION PATTERNS
}
Public Data Access:
class Public_Article_Model extends Rsx_Model
class Public_Article_Model extends Rsx_Model_Abstract
{
#[Ajax_Endpoint_Model_Fetch]
public static function fetch($id)
@@ -266,16 +371,51 @@ BASE MODEL PROTECTION
- Provides clear implementation guidance
- Ensures no models are fetchable by default
CODE QUALITY VALIDATION
The framework validates #[Ajax_Endpoint_Model_Fetch] attribute placement at
manifest build time. The attribute can ONLY be applied to:
1. Methods marked with #[Relationship] - exposes relationship to JavaScript
2. The static fetch($id) method - enables Model.fetch() in JavaScript
Invalid Placement:
// This will fail code quality check (MODEL-AJAX-FETCH-01)
#[Ajax_Endpoint_Model_Fetch]
public function get_display_name() // Not a relationship or fetch()
{
return $this->name;
}
For Custom Server-Side Methods:
If you need a custom method accessible from JavaScript:
1. Create a JS class extending Base_{ModelName}
2. Create an Ajax endpoint on an appropriate controller
3. Add the method to the JS class calling the Ajax endpoint
Example:
// PHP Controller
#[Ajax_Endpoint]
public static function get_project_stats(Request $request, array $params = []) {
return Project_Model::calculate_stats($params['id']);
}
// JavaScript (rsx/models/Project_Model.js)
class Project_Model extends Base_Project_Model {
async get_stats() {
return await Project_Controller.get_project_stats({id: this.id});
}
}
TESTING FETCH METHODS
PHP Testing:
// Test authorization
$user = User_Model::factory()->create();
RsxAuth::login($user);
Session::login($user);
$product = Product_Model::fetch(1);
$this->assertNotFalse($product);
RsxAuth::logout();
Session::logout();
$product = Product_Model::fetch(1);
$this->assertFalse($product);
@@ -304,7 +444,7 @@ COMMON PATTERNS
$model = static::find($id);
if (!$model) return false;
$user = RsxAuth::user();
$user = Session::get_user();
if (!$user || !$user->is_admin) {
// Remove admin-only fields for non-admin users
unset($model->internal_notes);
@@ -314,6 +454,49 @@ COMMON PATTERNS
return $model;
}
AUTOMATIC HYDRATION
All Ajax responses are automatically processed to convert objects with a
__MODEL property into proper JavaScript class instances. This happens
transparently in the Ajax layer.
How It Works:
1. PHP model's toArray() includes __MODEL property with class name
2. Ajax layer receives response with __MODEL: "Project_Model"
3. Ajax calls Rsx_Js_Model._instantiate_models_recursive() on response
4. Hydrator looks up class via Manifest.get_class_by_name()
5. If class extends Rsx_Js_Model, creates instance: new Project_Model(data)
6. Instance constructor strips __MODEL, assigns remaining properties
Result:
const project = await Project_Model.fetch(1);
console.log(project.constructor.name); // "Project_Model"
console.log(project instanceof Rsx_Js_Model); // true
Recursive Hydration:
The hydrator processes nested objects and arrays recursively. Any object
with __MODEL property at any depth will be instantiated as its class.
// If PHP returns related models in response:
{
id: 1,
name: "Project Alpha",
client: {
id: 5,
name: "Acme Corp",
__MODEL: "Client_Model"
},
__MODEL: "Project_Model"
}
// Both objects are hydrated:
project instanceof Project_Model; // true
project.client instanceof Client_Model; // true
Global Behavior:
This hydration applies to ALL Ajax responses, not just fetch() calls.
Any Ajax endpoint returning objects with __MODEL properties will have
them automatically instantiated as proper JavaScript class instances.
TROUBLESHOOTING
Model Not Fetchable:
- Verify #[Ajax_Endpoint_Model_Fetch] attribute present
@@ -322,7 +505,7 @@ TROUBLESHOOTING
- Verify model included in bundle manifest
Authorization Failures:
- Check RsxAuth::check() and RsxAuth::user() values
- Check Session::is_logged_in() and Session::get_user() values
- Verify authorization logic in fetch() method
- Test with different user roles and permissions
- Use rsx_dump_die() to debug authorization flow
@@ -333,13 +516,458 @@ TROUBLESHOOTING
- Ensure bundle compiles without errors
- Confirm JavaScript bundle loads in browser
Array Handling Issues:
- Never use is_array($id) in fetch() method
- Framework handles array splitting automatically
- Check for typos in model class names
- Verify all IDs in array are valid integers
JAVASCRIPT ORM ARCHITECTURE
The JavaScript ORM provides secure, read-only access to server-side models
through explicit opt-in. This section describes the full architecture vision.
Design Philosophy:
- Read-only ORM: No save/create/delete operations from JavaScript
- Explicit opt-in: Each model controls its own fetchability
- Individual authorization: Per-record security checks
- No query builder: Search/filter handled by dedicated Ajax endpoints
- Instance methods: Records come as objects with helper methods
What JavaScript ORM DOES:
- Fetch individual records by ID
- Provide enum helper methods on instances
- Load related records via lazy relationship methods
- Access attachments associated with records (see FUTURE DEVELOPMENT)
- Receive real-time updates via websocket (see FUTURE DEVELOPMENT)
What JavaScript ORM does NOT do:
- Save or update records (use Ajax endpoints)
- Create new records (use Ajax endpoints)
- Delete records (use Ajax endpoints)
- Search or query records (use DataGrid or Ajax)
- Eager load relationships (lazy load only)
LAZY RELATIONSHIPS
Related records load through async methods that mirror PHP relationships.
Each relationship method returns a Promise resolving to the related model(s).
Supported Relationship Types:
- belongsTo → Returns single model instance or null
- hasOne → Returns single model instance or null
- hasMany → Returns array of model instances (may be empty)
- morphTo → Returns single model instance or null
- morphOne → Returns single model instance or null
- morphMany → Returns array of model instances (may be empty)
JavaScript Usage:
// Load belongsTo relationship
const contact = await Contact_Model.fetch(123);
const client = await contact.client(); // Returns Client_Model or null
// Load hasMany relationship
const client = await Client_Model.fetch(456);
const contacts = await client.contacts(); // Returns Contact_Model[]
// Load morphMany relationship
const project = await Project_Model.fetch(1);
const tasks = await project.tasks(); // Returns Task_Model[]
// Chain relationships
const contact = await Contact_Model.fetch(123);
const client = await contact.client();
if (client) {
const contacts = await client.contacts();
}
Get Available Relationships:
// Static method returns array of relationship names
const rels = Project_Model.get_relationships();
// ["client", "contact", "tasks", "created_by", "owner"]
Security Model:
1. Source model's fetch() called first to verify access to parent record
2. Relationship method called to get related IDs (efficient pluck query)
3. Each related record passed through its model's fetch() for authorization
4. Only records passing fetch() security check are returned
Enabling Relationships for Ajax Fetch:
Relationships require BOTH attributes to be fetchable from JavaScript:
#[Relationship]
#[Ajax_Endpoint_Model_Fetch]
public function contacts()
{
return $this->hasMany(Contact_Model::class, 'client_id');
}
Without #[Ajax_Endpoint_Model_Fetch], the relationship will not appear in
get_relationships() and attempting to fetch it will return an error message
instructing the developer to add the attribute.
Implementation Notes:
- #[Relationship] defines the method as a Laravel relationship
- #[Ajax_Endpoint_Model_Fetch] exposes it to the JavaScript ORM
- Only relationships with BOTH attributes are included in JS stubs
- Related model must also have fetch() with #[Ajax_Endpoint_Model_Fetch]
- Singular relationships return null if not found or unauthorized
- Plural relationships return empty array if none found/authorized
ENUM PROPERTIES
Enum values are exposed as properties on fetched model instances, mirroring
the PHP magic property behavior. Each custom field defined in the enum
becomes a property named {column}_{field}.
PHP Enum Definition:
public static $enums = [
'status_id' => [
1 => ['constant' => 'STATUS_PLANNING', 'label' => 'Planning', 'badge' => 'bg-info'],
2 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success'],
3 => ['constant' => 'STATUS_ON_HOLD', 'label' => 'On Hold', 'badge' => 'bg-warning'],
],
];
Resulting JavaScript Instance Properties:
const project = await Project_Model.fetch(123);
// Raw enum value
project.status_id // 2
// Auto-generated properties from enum definition
project.status_id_label // "Active"
project.status_id_badge // "bg-success"
// All custom fields become properties
// If enum had 'button_class' => 'btn-success':
project.status_id_button_class // "btn-success"
Static Enum Constants:
// Constants available on the class (from 'constant' field)
if (project.status_id === Project_Model.STATUS_ACTIVE) {
console.log('Project is active');
}
Static Enum Methods:
// Get enum values for dropdown population (id => label)
const statusOptions = Project_Model.status_id_enum_select();
// {1: "Planning", 2: "Active", 3: "On Hold"}
// Get full enum config (id => all fields)
const statusConfig = Project_Model.status_id_enum_val();
// {1: {label: "Planning", badge: "bg-info"}, 2: {...}, ...}
===============================================================================
FUTURE DEVELOPMENT
===============================================================================
The features below are planned enhancements. They will be additive and will not
break the existing API. Implementation priority and timeline are not committed.
ATTACHMENTS INTEGRATION (planned)
File attachments accessible through model instances.
JavaScript Usage (API TBD):
const project = await Project_Model.fetch(123);
// Get all attachments
const attachments = await project.attachments();
// Get specific attachment type
const documents = await project.attachments('documents');
// Get attachment URLs
attachments.forEach(att => {
console.log(att.url); // View URL
console.log(att.download_url); // Download URL
console.log(att.thumbnail_url); // Thumbnail (images)
});
DATE HANDLING (planned)
Date fields converted to JavaScript Date objects or date library instances.
Implementation details TBD - may use native Date or dayjs.
JavaScript Usage (API TBD):
const project = await Project_Model.fetch(123);
// Access date fields
project.created_at // Date object or dayjs instance
project.due_date // Date object or dayjs instance
// Format dates
project.created_at.format('YYYY-MM-DD') // If dayjs
project.created_at.toLocaleDateString() // If native Date
REAL-TIME UPDATES (planned)
Model instances can receive real-time updates via websocket.
JavaScript Usage (API TBD):
const project = await Project_Model.fetch(123);
// Subscribe to updates
project.subscribe((updatedProject) => {
// Called when server broadcasts changes
updateUI(updatedProject);
});
// Or use event-based approach
project.on('update', (changes) => {
// Partial update with changed fields
});
TAGS SYSTEM (future)
Tags integration for taggable models (implementation TBD).
Potential API:
const project = await Project_Model.fetch(123);
const tags = await project.tags(); // ['urgent', 'q4-2025']
FETCH BATCHING (planned, Phase 2)
Automatic batching of multiple fetch requests made within same tick.
Reduces HTTP requests and database queries when loading multiple records.
Behavior:
- Multiple Model.fetch() calls in same tick batched into single request
- Server groups requests by model, uses IN clause for efficient queries
- Only enabled when Ajax request batching is enabled (config)
- Integrates with existing Ajax batching system
Example - Without Batching (3 HTTP requests, 3 queries):
const client = await Client_Model.fetch(1);
const contact = await Contact_Model.fetch(5);
const project = await Project_Model.fetch(10);
Example - With Batching (1 HTTP request, 3 queries):
// Same code, but requests batched automatically
// Server receives: {Client_Model: [1], Contact_Model: [5], Project_Model: [10]}
Example - Same Model Batching (1 HTTP request, 1 query with IN clause):
const clients = await Promise.all([
Client_Model.fetch(1),
Client_Model.fetch(2),
Client_Model.fetch(3)
]);
// Server receives: {Client_Model: [1, 2, 3]}
// Executes: SELECT * FROM clients WHERE id IN (1, 2, 3)
Server Implementation:
- Orm_Controller receives batched request with model->ids map
- Groups IDs by model class
- Executes single query per model using WHERE id IN (...)
- Returns results keyed by model and id
- Client resolves individual promises from batched response
Prerequisites:
- Ajax request batching enabled in config
- Works alongside existing Ajax.call() batching
JQHTML CACHE INTEGRATION (planned, Phase 2, low priority)
Integration with jqhtml's component caching for instant first renders.
Visual polish feature - avoids brief loading indicators, not a performance gain.
Problem:
- jqhtml caches on_load results for instant duplicate component renders
- First render still shows loading indicator while fetching
- If ORM data already cached from prior fetch, loading indicator unnecessary
Solution:
- Provide jqhtml with mock data fetch functions during first render
- Mock functions check ORM cache, return cached data if available
- If cache hit: on_load completes synchronously, no loading indicator
- If cache miss: falls back to normal async fetch with loading indicator
Example Scenario:
// User views Contact #5, data cached
const contact = await Contact_Model.fetch(5);
// User navigates away, then back to same contact
// Without integration: loading indicator shown briefly
// With integration: cached data used, renders instantly
Implementation Notes:
- ORM maintains client-side cache of fetched records
- jqhtml receives cache-aware fetch stubs during render
- Cache keyed by model name + id
- Cache invalidation TBD (TTL, manual, websocket push)
Priority: Low - purely visual improvement, no server/client perf benefit
BATCH SECURITY OPTIMIZATION (todo)
Current Limitation:
The static fetch($id) method signature accepts a single ID, which means
when fetching multiple related records (e.g., via relationship loading),
each record requires a separate fetch($id) call and database query.
This prevents optimization with IN (id1, id2, ...) queries when loading
relationship result sets.
Problem Scenario:
// Loading a client's 50 contacts via relationship
// Currently requires 50 individual fetch() calls:
foreach ($contact_ids as $id) {
$contact = Contact_Model::fetch($id); // Individual query each
}
// Cannot optimize to:
// SELECT * FROM contacts WHERE id IN (1, 2, 3, ...)
Proposed Solutions:
Option A - Query Prefetch Cache:
Before iterating through relationship results, prefetch all IDs
with a single query and cache the results. Individual fetch() calls
then hit the cache instead of the database.
// Pseudocode
$ids = $relationship->pluck('id');
static::prefetch_cache($ids); // Single IN query, cache results
foreach ($ids as $id) {
$record = static::fetch($id); // Hits cache, not database
}
Benefits: No API change, backwards compatible
Drawback: Cache management complexity, memory usage
Option B - Batch fetch() Variant:
Add a new method like fetch_batch($ids) that accepts an array
and returns filtered results using single query.
// fetch_batch must apply same security logic as fetch()
public static function fetch_batch(array $ids) {
$records = static::whereIn('id', $ids)->get();
return $records->filter(fn($r) => static::can_fetch($r));
}
Benefits: Clean API, explicit batch operation
Drawback: Requires refactoring existing security logic
Option C - Automatic Query Batching Layer:
Database query layer automatically batches identical queries
made within same request cycle using query deduplication.
Benefits: Transparent, no code changes
Drawback: Complex implementation, limited optimization scope
Recommended Approach:
Option A (Query Prefetch Cache) is likely the best balance of
simplicity and effectiveness. It can be implemented without changing
the fetch() API contract, and the cache can be request-scoped to
avoid memory/staleness issues.
Implementation Priority: Medium
Required before relationship fetching is considered production-ready
for models with large relationship sets.
OPT-IN FETCH CACHING (todo)
Current Limitation:
When a model's fetch() method returns an array (for augmented data),
relationship fetching must call the database twice: once via fetch()
to verify access, and once via find() to get the actual Eloquent model
for relationship method calls.
Proposed Solution - Request-Scoped Cache:
Implement opt-in caching of fetch() results scoped to the current
page/action lifecycle. Cache automatically invalidates on navigation
or form submission.
Cache Scope:
- Traditional pages: Single page instance (cache lives until navigation)
- SPA applications: Single SPA action (cache resets on action navigation)
Cache Invalidation Events:
1. Page navigation (traditional or SPA)
2. Rsx_Form submission (any form submit clears cache)
3. Developer-initiated (explicit cache clear call)
Opt-In API (TBD):
// Enable caching for a model
class Project_Model extends Rsx_Model_Abstract {
protected static $fetch_cache_enabled = true;
}
// Manual invalidation
Project_Model.clear_fetch_cache(); // Clear single model cache
Rsx_Js_Model.clear_all_fetch_caches(); // Clear all model caches
Implementation Notes:
- Cache keyed by model class + record ID
- Cache stores the raw fetch() result (model or array)
- For array results, also cache the underlying Eloquent model
- Relationship fetching checks cache before database query
- SPA integration via Spa.on('action:change') event
- Form integration via Rsx_Form.on('submit') event
Benefits:
- Eliminates duplicate database queries for relationship fetching
- Enables instant re-renders when returning to cached records
- Predictable invalidation tied to user actions
- Opt-in prevents unexpected caching behavior
Implementation Priority: Medium
Required before relationship fetching is considered production-ready
for models where fetch() returns augmented arrays.
PAGINATED RELATIONSHIP RESULTS (todo, low priority)
Current Limitation:
Relationship methods return all related records at once. For models
with large relationship sets (e.g., a client with 500 contacts),
this causes excessive data transfer and memory usage.
Proposed Solution - Paginated Relationship API:
Design a JavaScript API for paginated relationship fetching that
feels natural and is easy to use for common UI patterns like
infinite scroll, "load more" buttons, and traditional pagination.
API Design Goals:
- Simple default case: first page with sensible limit
- Easy iteration for "load more" patterns
- Support for offset/limit and cursor-based pagination
- Chainable or async iterator patterns
- Clear indication of "has more" and total count
Potential API Patterns (TBD):
Option A - Paginated Method Variant:
const page1 = await client.contacts_paginated({limit: 20});
// { data: Contact_Model[], has_more: true, total: 150, next_cursor: '...' }
const page2 = await client.contacts_paginated({cursor: page1.next_cursor});
Option B - Async Iterator:
for await (const contact of client.contacts_iterator({batch: 20})) {
// Yields one Contact_Model at a time, fetches in batches
}
Option C - Collection Object:
const contacts = await client.contacts({paginate: true, limit: 20});
// Returns Paginated_Collection object
contacts.data // Contact_Model[] (current page)
contacts.has_more // boolean
contacts.total // number (if available)
await contacts.next() // Load next page, returns same collection
await contacts.all() // Load all remaining (with warning for large sets)
Option D - Parameter on Existing Method:
const contacts = await client.contacts({limit: 20, offset: 0});
// Returns array but truncated to limit
const more = await client.contacts({limit: 20, offset: 20});
Server-Side Considerations:
- Orm_Controller::fetch_relationship() needs pagination params
- Efficient COUNT query for total (optional, can skip for performance)
- Cursor-based pagination for stable ordering during iteration
- Security: pagination params must not bypass fetch() security checks
UI Integration Patterns:
- DataGrid: Already handles pagination, may not need ORM integration
- Detail views: "Show more" buttons for related records
- Infinite scroll: Async iterator or cursor-based pagination
- Modal lists: Traditional page numbers
Implementation Priority: Low
Most large relationship sets are better served by DataGrid with
server-side filtering. This feature is for edge cases where
ORM-style access is preferred over DataGrid.
SEE ALSO
controller - Internal API attribute patterns
manifest_api - Model discovery and stub generation
coding_standards - Security patterns and authorization
crud(3) - Standard CRUD implementation using Model.fetch()
controller(3) - Internal API attribute patterns
manifest_api(3) - Model discovery and stub generation
coding_standards(3) - Security patterns and authorization
enums(3) - Model enum definitions and magic properties
RSX Framework 2025-11-23 MODEL_FETCH(3)

View File

@@ -187,12 +187,11 @@ JAVASCRIPT INTEGRATION
JavaScript classes auto-initialize:
- Extend appropriate base classes
- Use static on_app_ready() method for initialization
- Use static on_jqhtml_ready() to wait for JQHTML components to load
- No manual registration required
Lifecycle timing:
- on_app_ready(): Runs when page initializes, before JQHTML components finish
- on_jqhtml_ready(): Runs after all JQHTML components loaded and rendered
- on_app_ready(): Runs when page is ready for initialization
- For component readiness: await $(element).component().ready()
Important limitation:
JavaScript only executes when bundle is rendered in HTML output.

View File

@@ -39,13 +39,16 @@ CORE OPTIONS
CONSOLE OUTPUT
--console-log | --console-list
--console
Display all browser console output, not just errors. Shows console.log(),
console.warn(), console.info(), and console_debug() output.
--console-log
Alias for --console.
--console-debug-filter=<channel>
Filter console_debug() output to a specific channel (e.g., AUTH, DISPATCH,
BENCHMARK). Automatically enables console_debug and --console-log.
BENCHMARK). Automatically enables console_debug and --console.
--console-debug-all
Show all console_debug() channels without filtering.
@@ -177,7 +180,7 @@ OUTPUT FORMAT
The command outputs in a terse, parseable format:
- Status line with URL and HTTP status code
- Console errors (always shown if present)
- Console logs (if --console-log used)
- Console logs (if --console used)
- XHR/fetch requests (if --xhr-dump or --xhr-list used)
- Response headers (if --headers used)
- Response body (unless --no-body used)

View File

@@ -99,7 +99,7 @@ SPA ARCHITECTURE
5. Layout Template (.jqhtml)
- Persistent wrapper around actions
- Must have element with $id="content"
- Must have element with $sid="content"
- Persists across action navigation
6. Layout Class (.js)
@@ -114,7 +114,7 @@ SPA ARCHITECTURE
4. Client JavaScript discovers all actions via manifest
5. Router matches URL to action class
6. Creates layout on <body>
7. Creates action inside layout $id="content" area
7. Creates action inside layout $sid="content" area
Subsequent Navigation:
1. User clicks link or calls Spa.dispatch()
@@ -222,7 +222,7 @@ JAVASCRIPT ACTIONS
on_ready() {
// DOM is ready, setup event handlers
this.$id('search').on('input', () => this.reload());
this.$sid('search').on('input', () => this.reload());
}
}
@@ -311,7 +311,7 @@ LAYOUTS
</nav>
</header>
<main $id="content">
<main $sid="content">
<!-- Actions render here -->
</main>
@@ -322,7 +322,7 @@ LAYOUTS
</Define:Frontend_Layout>
Requirements:
- Must have element with $id="content"
- Must have element with $sid="content"
- Content area is where actions render
- Layout persists across navigation
@@ -351,6 +351,78 @@ LAYOUTS
- Can immediately access this.action properties
- Use await this.action.ready() to wait for action's full loading
SUBLAYOUTS
Sublayouts allow nesting multiple persistent layouts. Each layout in the
chain persists independently based on navigation context.
Use Case:
A settings section with its own sidebar that persists across all
settings pages, while the main application header/footer (outer layout)
also persists. Navigating between settings pages preserves both layouts;
navigating to dashboard destroys the settings sublayout but keeps the
outer layout.
Defining Sublayouts:
Add multiple @layout decorators - first is outermost, subsequent are nested:
@route('/frontend/settings/profile')
@layout('Frontend_Spa_Layout') // Outermost (header/footer)
@layout('Settings_Layout') // Nested inside Frontend_Spa_Layout
@spa('Frontend_Spa_Controller::index')
class Settings_Profile_Action extends Spa_Action { }
Creating a Sublayout:
Sublayouts are Spa_Layout classes with $sid="content":
// /rsx/app/frontend/settings/Settings_Layout.js
class Settings_Layout extends Spa_Layout {
async on_action(url, action_name, args) {
// Update sidebar highlighting based on current action
this._update_active_nav(action_name);
}
}
<!-- /rsx/app/frontend/settings/Settings_Layout.jqhtml -->
<Define:Settings_Layout>
<div class="settings-container">
<aside class="settings-sidebar">
<a href="..." data-page="profile">Profile</a>
<a href="..." data-page="security">Security</a>
</aside>
<main $sid="content"></main>
</div>
</Define:Settings_Layout>
Chain Resolution:
When navigating, Spa compares current layout chain to target:
- Matching layouts from the top are reused
- First mismatched layout and everything below is destroyed
- New layouts/action created from divergence point down
Example:
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
Target: Frontend_Spa_Layout > Settings_Layout > Security_Action
Result: Reuse both layouts, only replace action
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
Target: Frontend_Spa_Layout > Dashboard_Action
Result: Reuse Frontend_Spa_Layout, destroy Settings_Layout, create Dashboard_Action
on_action Propagation:
All layouts in the chain receive on_action() with the final action's info:
- url: The navigated URL
- action_name: Name of the action class (bottom of chain)
- args: URL parameters
This allows each layout to update its UI (sidebar highlighting, breadcrumbs)
based on which action is currently active.
References:
Spa.layout Always the top-level layout
Spa.action Always the bottom-level action (not a layout)
To access intermediate layouts, use DOM traversal or layout hooks.
URL GENERATION
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
"/contacts" will produce errors.
@@ -534,7 +606,7 @@ EXAMPLES
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
</nav>
</header>
<main $id="content"></main>
<main $sid="content"></main>
</div>
</Define:Frontend_Layout>
@@ -632,12 +704,12 @@ TROUBLESHOOTING
Navigation Not Working:
- Verify using Rsx.Route() not hardcoded URLs
- Check @spa() decorator references correct bootstrap controller
- Ensure layout has $id="content" element
- Ensure layout has $sid="content" element
- Test Spa.dispatch() directly
Layout Not Persisting:
- Verify all actions in module use same @layout()
- Check layout template has $id="content"
- Check layout template has $sid="content"
- Ensure not mixing SPA and traditional routes
this.args Empty:

47
app/RSpade/man/zindex.txt Executable file
View File

@@ -0,0 +1,47 @@
Z-INDEX STANDARDS
=================
RSpade z-index scale, extending Bootstrap 5 defaults.
SCALE
-----
z-index Layer Purpose
------- ----- -------
auto/0 Base Normal page content flow
1000 Dropdown Page-level dropdowns (Bootstrap)
1020 Sticky Sticky headers (Bootstrap)
1030 Fixed Fixed navbars (Bootstrap)
1040 Modal backdrop (Bootstrap)
1050 Modal (Bootstrap)
1070 Popover (Bootstrap)
1080 Tooltip (Bootstrap)
1090 Toast Notifications (Bootstrap)
1100 Modal children Dropdowns/selects inside modals
1200 Flash alerts Application flash messages
9000+ System/Debug Error overlays, debug panels
USAGE
-----
Page elements: Use auto/default stacking. Rarely need explicit z-index.
Modal children (1100):
Components that render dropdowns to <body> (TomSelect, datepickers)
need z-index > modal (1050) to appear above the modal.
Flash alerts (1200):
Application-level notifications that should appear above modals.
System (9000+):
Reserved for framework-level UI: uncaught error displays, debug tools.
Application code should not use this range.
ADDING NEW LAYERS
-----------------
Gaps between values are intentional. When adding new layers:
1. Use existing Bootstrap value if applicable
2. Otherwise, slot into appropriate range with buffer space
3. Document here
SEE ALSO
--------
Bootstrap z-index: https://getbootstrap.com/docs/5.3/layout/z-index/

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/config.js.map Normal file → Executable file
View File

View File

@@ -39,7 +39,6 @@ const CONVENTION_METHODS = [
'on_app_modules_init',
'on_app_init',
'on_app_ready',
'on_jqhtml_ready'
];
/**
* Check if a method is static by examining the line text

View File

View File

View File

@@ -12,7 +12,7 @@
* FILE TYPE HANDLERS & RESOLUTION RULES:
*
* 1. ROUTE PATTERNS (all files)
* Pattern: Rsx::Route('Controller') or Rsx.Route('Controller::method')
* Pattern: Rsx::Route('Controller') or Rsx.Route('Controller', 'method')
* Type: 'php_class'
* Reason: Routes always point to PHP controllers (server-side)
*
@@ -658,9 +658,9 @@ class RspadeDefinitionProvider {
if (wordRange) {
const method_name = document.getText(wordRange);
const wordStart = wordRange.start.character;
// Look backwards for "ClassName." pattern before the method
// Look backwards for "ClassName." or "ClassName::" pattern before the method
const beforeMethod = line.substring(0, wordStart);
const classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)\.$/);
const classMatch = beforeMethod.match(/([A-Z][A-Za-z0-9_]*)(?:\.|::)$/);
if (classMatch) {
const class_name = classMatch[1];
// Check if the class name looks like an RSX class (contains underscore)

File diff suppressed because one or more lines are too long

View File

View File

View File

Some files were not shown because too many files have changed in this diff Show More