From 84ca3dfe42c4fc96e908e93417281b3d179c8912 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Nov 2025 21:39:43 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20code=20quality=20violations=20and=20renam?= =?UTF-8?q?e=20select=20input=20components=20Move=20small=20tasks=20from?= =?UTF-8?q?=20wishlist=20to=20todo,=20update=20npm=20packages=20Replace=20?= =?UTF-8?q?#[Auth]=20attributes=20with=20manual=20auth=20checks=20and=20co?= =?UTF-8?q?de=20quality=20rule=20Remove=20on=5Fjqhtml=5Fready=20lifecycle?= =?UTF-8?q?=20method=20from=20framework=20Complete=20ACL=20system=20with?= =?UTF-8?q?=20100-based=20role=20indexing=20and=20/dev/acl=20tester=20WIP:?= =?UTF-8?q?=20ACL=20system=20implementation=20with=20debug=20instrumentati?= =?UTF-8?q?on=20Convert=20rsx:check=20JS=20linting=20to=20RPC=20socket=20s?= =?UTF-8?q?erver=20Clean=20up=20docs=20and=20fix=20$id=E2=86=92$sid=20in?= =?UTF-8?q?=20man=20pages,=20remove=20SSR/FPC=20feature=20Reorganize=20wis?= =?UTF-8?q?hlists:=20priority=20order,=20mark=20sublayouts=20complete,=20a?= =?UTF-8?q?dd=20email=20Update=20model=5Ffetch=20docs:=20mark=20MVP=20comp?= =?UTF-8?q?lete,=20fix=20enum=20docs,=20reorganize=20Comprehensive=20docum?= =?UTF-8?q?entation=20overhaul:=20clarity,=20compression,=20and=20critical?= =?UTF-8?q?=20rules=20Convert=20Contacts/Projects=20CRUD=20to=20Model.fetc?= =?UTF-8?q?h()=20and=20add=20fetch=5For=5Fnull()=20Add=20JS=20ORM=20relati?= =?UTF-8?q?onship=20lazy-loading=20and=20fetch=20array=20handling=20Add=20?= =?UTF-8?q?JS=20ORM=20relationship=20fetching=20and=20CRUD=20documentation?= =?UTF-8?q?=20Fix=20ORM=20hydration=20and=20add=20IDE=20resolution=20for?= =?UTF-8?q?=20Base=5F*=20model=20stubs=20Rename=20Json=5FTree=5FComponent?= =?UTF-8?q?=20to=20JS=5FTree=5FDebug=5FComponent=20and=20move=20to=20frame?= =?UTF-8?q?work=20Enhance=20JS=20ORM=20infrastructure=20and=20add=20Json?= =?UTF-8?q?=5FTree=20class=20name=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/RSpade/Bundles/Bootstrap5_Bundle.php | 4 +- app/RSpade/Bundles/Jquery_Bundle.php | 4 +- app/RSpade/Bundles/Lodash_Bundle.php | 4 +- app/RSpade/Bundles/Quill_Bundle.php | 4 +- app/RSpade/Bundles/Tom_Select_Bundle.php | 47 - app/RSpade/CodeQuality/CodeQualityChecker.php | 47 +- ...LifecycleMethodsStatic_CodeQualityRule.php | 1 - .../JavaScript/ThisUsage_CodeQualityRule.php | 33 +- .../JavaScript/resource/this-usage-parser.js | 378 - .../InstanceMethods_CodeQualityRule.php | 53 + ...odelAjaxFetchAttribute_CodeQualityRule.php | 150 + .../PHP/EndpointAuthCheck_CodeQualityRule.php | 316 + .../ModelFetchAuthCheck_CodeQualityRule.php | 276 + app/RSpade/CodeQuality/Support/CLAUDE.md | 53 + .../Support/InitializationSuggestions.php | 2 - .../Support/Js_CodeQuality_Rpc.php | 352 + .../resource/js-code-quality-server.js | 570 + .../Commands/Rsx/Bundle_Compile_Command.php | 6 +- .../Commands/Rsx/Route_Debug_Command.php | 18 +- .../Commands/Rsx/Ssr_Fpc_Create_Command.php | 279 - .../Commands/Rsx/Ssr_Fpc_Reset_Command.php | 88 - .../Rsx/resource/playwright/.placeholder | 0 .../playwright/generate-static-cache.js | 260 - .../Components/JS_Tree_Debug_Component.jqhtml | 29 + .../Components/JS_Tree_Debug_Component.js | 3 + .../Components/JS_Tree_Debug_Node.jqhtml | 68 + app/RSpade/Components/JS_Tree_Debug_Node.js | 257 + .../Components/js_tree_debug_component.scss | 150 + .../Core/Ajax/Ajax_Batch_Controller.php | 3 +- app/RSpade/Core/Api/Api_Key_Model.php | 180 + app/RSpade/Core/Bootstrap/RsxBootstrap.php | 10 - app/RSpade/Core/Bundle/BundleCompiler.php | 378 +- app/RSpade/Core/Bundle/CLAUDE.md | 23 + app/RSpade/Core/Bundle/Core_Bundle.php | 2 + .../Core/Bundle/Rsx_Asset_Bundle_Abstract.php | 139 + .../Bundle/Rsx_Module_Bundle_Abstract.php | 73 + app/RSpade/Core/CLAUDE.md | 2 +- .../Core/CodeTemplates/stubs/javascript.stub | 6 - .../Data/Rsx_Reference_Data_Controller.php | 2 + .../Database/Database_BundleIntegration.php | 88 +- .../Database/Models/Rsx_Model_Abstract.php | 37 + app/RSpade/Core/Database/Orm_Controller.php | 341 + app/RSpade/Core/Debug/Debugger_Controller.php | 2 + .../Dispatch/Ajax_Endpoint_Controller.php | 222 +- app/RSpade/Core/Dispatch/Dispatcher.php | 464 +- .../Core/Files/File_Attachment_Controller.php | 5 +- app/RSpade/Core/Js/Ajax.js | 54 +- app/RSpade/Core/Js/Rsx_Cache.js | 210 - app/RSpade/Core/Js/Rsx_Js_Model.js | 79 +- app/RSpade/Core/Manifest/Manifest.php | 22 + app/RSpade/Core/Models/User_Model.php | 391 +- .../Core/Models/User_Permission_Model.php | 151 + app/RSpade/Core/SPA/Default_Layout.jqhtml | 3 + app/RSpade/Core/SPA/Default_Layout.js | 9 + app/RSpade/Core/SPA/Spa.js | 243 +- app/RSpade/Core/SPA/Spa_Decorators.js | 20 +- app/RSpade/Core/SPA/Spa_Layout.js | 152 +- app/RSpade/Core/Session/Session.php | 15 +- .../Rsx_Formdata_Generator_Controller.php | 27 + app/RSpade/Ide/Services/handler.php | 64 + .../Integrations/Jqhtml/Jqhtml_Integration.js | 1 - app/RSpade/Lib/Flash/Flash_Alert.scss | 2 +- app/RSpade/helpers.php | 36 + app/RSpade/man/acls.txt | 598 + app/RSpade/man/ajax_error_handling.txt | 2 +- app/RSpade/man/auth.txt | 144 +- app/RSpade/man/bundle_api.txt | 173 +- app/RSpade/man/config_rsx.txt | 2 +- app/RSpade/man/controller.txt | 182 +- app/RSpade/man/crud.txt | 570 + .../man/database_schema_architecture.txt | 12 +- app/RSpade/man/enums.txt | 21 +- app/RSpade/man/external_api.txt | 148 + app/RSpade/man/forms_and_widgets.txt | 6 +- app/RSpade/man/jqhtmldoc.txt | 2 +- app/RSpade/man/modals.txt | 18 +- app/RSpade/man/model.txt | 14 +- app/RSpade/man/model_fetch.txt | 848 +- app/RSpade/man/rsx_architecture.txt | 5 +- app/RSpade/man/rsx_debug.txt | 9 +- app/RSpade/man/spa.txt | 88 +- app/RSpade/man/zindex.txt | 47 + .../out/auto_rename_provider.js.map | 0 .../vscode_extension/out/blade_client.js.map | 0 .../out/blade_component_provider.js.map | 0 .../vscode_extension/out/blade_spacer.js.map | 0 .../out/class_refactor_code_actions.js.map | 0 .../out/class_refactor_provider.js.map | 0 .../out/combined_semantic_provider.js.map | 0 .../comment_file_reference_provider.js.map | 0 .../vscode_extension/out/config.js.map | 0 .../out/convention_method_provider.js | 1 - .../out/convention_method_provider.js.map | 0 .../vscode_extension/out/debug_client.js.map | 0 .../out/decoration_provider.js.map | 0 .../out/definition_provider.js | 6 +- .../out/definition_provider.js.map | 2 +- .../vscode_extension/out/extension.js.map | 0 .../vscode_extension/out/file_watcher.js.map | 0 .../out/folder_color_provider.js.map | 0 .../out/folding_provider.js.map | 0 .../out/formatting_provider.js.map | 0 .../out/git_diff_provider.js.map | 0 .../out/git_status_provider.js.map | 0 .../out/jqhtml_lifecycle_provider.js | 1 - .../out/jqhtml_lifecycle_provider.js.map | 0 .../out/laravel_completion_provider.js.map | 0 .../out/php_attribute_provider.js.map | 0 .../out/refactor_code_actions.js.map | 0 .../out/refactor_provider.js.map | 0 .../out/sort_class_methods_provider.js.map | 0 .../out/symlink_redirect_provider.js.map | 0 .../out/that_variable_provider.js.map | 0 .../resource/vscode_extension/package.json | 2 +- .../vscode_extension/rspade-framework.vsix | Bin 104747 -> 104757 bytes .../src/convention_method_provider.ts | 1 - .../src/definition_provider.ts | 4 +- .../src/jqhtml_lifecycle_provider.ts | 1 - bin/js-linter.js | 65 - config/rsx.php | 29 +- database/migrations/.migration_whitelist | 15 + ...025_11_21_193529_create_api_keys_table.php | 52 + ...3_160855_create_user_permissions_table.php | 48 + ...164439_reindex_user_roles_to_100_based.php | 38 + docs/CLAUDE.dist.md | 535 +- node_modules/.package-lock.json | 38 +- node_modules/@jqhtml/core/LLM_REFERENCE.md | 2 +- node_modules/@jqhtml/core/README.md | 4 +- node_modules/@jqhtml/core/dist/component.d.ts | 20 +- .../@jqhtml/core/dist/component.d.ts.map | 2 +- node_modules/@jqhtml/core/dist/index.cjs | 40 +- node_modules/@jqhtml/core/dist/index.cjs.map | 2 +- node_modules/@jqhtml/core/dist/index.js | 40 +- node_modules/@jqhtml/core/dist/index.js.map | 2 +- .../@jqhtml/core/dist/jqhtml-core.esm.js | 42 +- .../@jqhtml/core/dist/jqhtml-core.esm.js.map | 2 +- node_modules/@jqhtml/core/package.json | 2 +- node_modules/@jqhtml/parser/LLM_REFERENCE.md | 2 +- node_modules/@jqhtml/parser/dist/codegen.js | 2 +- node_modules/@jqhtml/parser/dist/lexer.js | 2 +- node_modules/@jqhtml/parser/dist/lexer.js.map | 2 +- node_modules/@jqhtml/parser/dist/parser.js | 2 +- .../@jqhtml/parser/dist/parser.js.map | 2 +- node_modules/@jqhtml/parser/package.json | 2 +- node_modules/@jqhtml/router/dist/index.cjs | 6 +- .../@jqhtml/router/dist/index.cjs.map | 2 +- node_modules/@jqhtml/router/dist/index.js | 4 +- node_modules/@jqhtml/router/dist/index.js.map | 2 +- .../@jqhtml/router/dist/jqhtml-router.esm.js | 6 +- .../router/dist/jqhtml-router.esm.js.map | 2 +- node_modules/@jqhtml/router/dist/layout.d.ts | 2 +- node_modules/@jqhtml/router/package.json | 2 +- .../@jqhtml/vscode-extension/.version | 2 +- ...x => jqhtml-vscode-extension-2.2.220.vsix} | Bin 42519 -> 42521 bytes .../out/definitionProvider.js.map | 2 +- .../@jqhtml/vscode-extension/package.json | 2 +- .../@jqhtml/webpack-loader/package.json | 4 +- .../baseline-browser-mapping/dist/index.cjs | 2 +- .../baseline-browser-mapping/dist/index.js | 2 +- .../baseline-browser-mapping/package.json | 2 +- package-lock.json | 38 +- .../rsx-build/bundles/.placeholder | 0 .../bundles/Frontend_Bundle__app.8e0e8df3.css | 1406 - .../bundles/Frontend_Bundle__app.8e0e8df3.js | 22028 ---------------- .../Frontend_Bundle__vendor.9c882dc4.css | 11877 --------- .../Frontend_Bundle__vendor.9c882dc4.js | 8301 ------ ...Bundle_6459e8ed0f60bda4f121420766012d53.js | 2203 -- 167 files changed, 7538 insertions(+), 49164 deletions(-) delete mode 100755 app/RSpade/Bundles/Tom_Select_Bundle.php delete mode 100755 app/RSpade/CodeQuality/Rules/JavaScript/resource/this-usage-parser.js create mode 100755 app/RSpade/CodeQuality/Rules/Models/ModelAjaxFetchAttribute_CodeQualityRule.php create mode 100755 app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php create mode 100755 app/RSpade/CodeQuality/Rules/PHP/ModelFetchAuthCheck_CodeQualityRule.php create mode 100755 app/RSpade/CodeQuality/Support/Js_CodeQuality_Rpc.php create mode 100755 app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js delete mode 100755 app/RSpade/Commands/Rsx/Ssr_Fpc_Create_Command.php delete mode 100755 app/RSpade/Commands/Rsx/Ssr_Fpc_Reset_Command.php create mode 100755 app/RSpade/Commands/Rsx/resource/playwright/.placeholder delete mode 100755 app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js create mode 100755 app/RSpade/Components/JS_Tree_Debug_Component.jqhtml create mode 100755 app/RSpade/Components/JS_Tree_Debug_Component.js create mode 100755 app/RSpade/Components/JS_Tree_Debug_Node.jqhtml create mode 100755 app/RSpade/Components/JS_Tree_Debug_Node.js create mode 100755 app/RSpade/Components/js_tree_debug_component.scss create mode 100755 app/RSpade/Core/Api/Api_Key_Model.php create mode 100755 app/RSpade/Core/Bundle/Rsx_Asset_Bundle_Abstract.php create mode 100755 app/RSpade/Core/Bundle/Rsx_Module_Bundle_Abstract.php create mode 100755 app/RSpade/Core/Database/Orm_Controller.php delete mode 100755 app/RSpade/Core/Js/Rsx_Cache.js create mode 100755 app/RSpade/Core/Models/User_Permission_Model.php create mode 100755 app/RSpade/Core/SPA/Default_Layout.jqhtml create mode 100755 app/RSpade/Core/SPA/Default_Layout.js create mode 100755 app/RSpade/man/acls.txt create mode 100755 app/RSpade/man/crud.txt create mode 100755 app/RSpade/man/external_api.txt create mode 100755 app/RSpade/man/zindex.txt mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/blade_client.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/blade_component_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/blade_spacer.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/config.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/debug_client.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/decoration_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/definition_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/extension.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/file_watcher.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/folding_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/formatting_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/git_status_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/refactor_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map mode change 100644 => 100755 app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map delete mode 100755 bin/js-linter.js create mode 100755 database/migrations/2025_11_21_193529_create_api_keys_table.php create mode 100755 database/migrations/2025_11_23_160855_create_user_permissions_table.php create mode 100755 database/migrations/2025_11_23_164439_reindex_user_roles_to_100_based.php rename node_modules/@jqhtml/vscode-extension/{jqhtml-vscode-extension-2.2.218.vsix => jqhtml-vscode-extension-2.2.220.vsix} (87%) create mode 100644 storage-working/rsx-build/bundles/.placeholder delete mode 100644 storage-working/rsx-build/bundles/Frontend_Bundle__app.8e0e8df3.css delete mode 100644 storage-working/rsx-build/bundles/Frontend_Bundle__app.8e0e8df3.js delete mode 100755 storage-working/rsx-build/bundles/Frontend_Bundle__vendor.9c882dc4.css delete mode 100755 storage-working/rsx-build/bundles/Frontend_Bundle__vendor.9c882dc4.js delete mode 100755 storage-working/rsx-build/bundles/npm_Frontend_Bundle_6459e8ed0f60bda4f121420766012d53.js diff --git a/app/RSpade/Bundles/Bootstrap5_Bundle.php b/app/RSpade/Bundles/Bootstrap5_Bundle.php index 57faf32bd..2a07bda5a 100755 --- a/app/RSpade/Bundles/Bootstrap5_Bundle.php +++ b/app/RSpade/Bundles/Bootstrap5_Bundle.php @@ -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 diff --git a/app/RSpade/Bundles/Jquery_Bundle.php b/app/RSpade/Bundles/Jquery_Bundle.php index 940d65bef..8e8e025f0 100755 --- a/app/RSpade/Bundles/Jquery_Bundle.php +++ b/app/RSpade/Bundles/Jquery_Bundle.php @@ -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 diff --git a/app/RSpade/Bundles/Lodash_Bundle.php b/app/RSpade/Bundles/Lodash_Bundle.php index 6f3a9242f..ba7f92fd1 100755 --- a/app/RSpade/Bundles/Lodash_Bundle.php +++ b/app/RSpade/Bundles/Lodash_Bundle.php @@ -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 diff --git a/app/RSpade/Bundles/Quill_Bundle.php b/app/RSpade/Bundles/Quill_Bundle.php index 0c397cc3a..cbfa41502 100755 --- a/app/RSpade/Bundles/Quill_Bundle.php +++ b/app/RSpade/Bundles/Quill_Bundle.php @@ -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 diff --git a/app/RSpade/Bundles/Tom_Select_Bundle.php b/app/RSpade/Bundles/Tom_Select_Bundle.php deleted file mode 100755 index 817386910..000000000 --- a/app/RSpade/Bundles/Tom_Select_Bundle.php +++ /dev/null @@ -1,47 +0,0 @@ - [], // 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', - ], - ], - ], - ]; - } -} diff --git a/app/RSpade/CodeQuality/CodeQualityChecker.php b/app/RSpade/CodeQuality/CodeQualityChecker.php index ec294ab7d..31f321b45 100755 --- a/app/RSpade/CodeQuality/CodeQualityChecker.php +++ b/app/RSpade/CodeQuality/CodeQualityChecker.php @@ -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 } diff --git a/app/RSpade/CodeQuality/Rules/JavaScript/LifecycleMethodsStatic_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/JavaScript/LifecycleMethodsStatic_CodeQualityRule.php index dc0109f91..b87cfb8f8 100755 --- a/app/RSpade/CodeQuality/Rules/JavaScript/LifecycleMethodsStatic_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/JavaScript/LifecycleMethodsStatic_CodeQualityRule.php @@ -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 diff --git a/app/RSpade/CodeQuality/Rules/JavaScript/ThisUsage_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/JavaScript/ThisUsage_CodeQualityRule.php index 22683de2a..f7a91e5a4 100755 --- a/app/RSpade/CodeQuality/Rules/JavaScript/ThisUsage_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/JavaScript/ThisUsage_CodeQualityRule.php @@ -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)); diff --git a/app/RSpade/CodeQuality/Rules/JavaScript/resource/this-usage-parser.js b/app/RSpade/CodeQuality/Rules/JavaScript/resource/this-usage-parser.js deleted file mode 100755 index ae28b61a6..000000000 --- a/app/RSpade/CodeQuality/Rules/JavaScript/resource/this-usage-parser.js +++ /dev/null @@ -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 - * 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)); \ No newline at end of file diff --git a/app/RSpade/CodeQuality/Rules/Manifest/InstanceMethods_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/Manifest/InstanceMethods_CodeQualityRule.php index ec793df6a..415a6d614 100755 --- a/app/RSpade/CodeQuality/Rules/Manifest/InstanceMethods_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/Manifest/InstanceMethods_CodeQualityRule.php @@ -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) { diff --git a/app/RSpade/CodeQuality/Rules/Models/ModelAjaxFetchAttribute_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/Models/ModelAjaxFetchAttribute_CodeQualityRule.php new file mode 100755 index 000000000..129601d1b --- /dev/null +++ b/app/RSpade/CodeQuality/Rules/Models/ModelAjaxFetchAttribute_CodeQualityRule.php @@ -0,0 +1,150 @@ + $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 + } +} diff --git a/app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php new file mode 100755 index 000000000..c8c4812d9 --- /dev/null +++ b/app/RSpade/CodeQuality/Rules/PHP/EndpointAuthCheck_CodeQualityRule.php @@ -0,0 +1,316 @@ +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); + } +} diff --git a/app/RSpade/CodeQuality/Rules/PHP/ModelFetchAuthCheck_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/PHP/ModelFetchAuthCheck_CodeQualityRule.php new file mode 100755 index 000000000..d68617270 --- /dev/null +++ b/app/RSpade/CodeQuality/Rules/PHP/ModelFetchAuthCheck_CodeQualityRule.php @@ -0,0 +1,276 @@ +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); + } +} diff --git a/app/RSpade/CodeQuality/Support/CLAUDE.md b/app/RSpade/CodeQuality/Support/CLAUDE.md index a7a006180..21ab25855 100755 --- a/app/RSpade/CodeQuality/Support/CLAUDE.md +++ b/app/RSpade/CodeQuality/Support/CLAUDE.md @@ -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) diff --git a/app/RSpade/CodeQuality/Support/InitializationSuggestions.php b/app/RSpade/CodeQuality/Support/InitializationSuggestions.php index f1e3f8a9a..9fd5f8600 100755 --- a/app/RSpade/CodeQuality/Support/InitializationSuggestions.php +++ b/app/RSpade/CodeQuality/Support/InitializationSuggestions.php @@ -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"; diff --git a/app/RSpade/CodeQuality/Support/Js_CodeQuality_Rpc.php b/app/RSpade/CodeQuality/Support/Js_CodeQuality_Rpc.php new file mode 100755 index 000000000..e8f789845 --- /dev/null +++ b/app/RSpade/CodeQuality/Support/Js_CodeQuality_Rpc.php @@ -0,0 +1,352 @@ + ++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); + } + } +} diff --git a/app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js b/app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js new file mode 100755 index 000000000..664ffa2ff --- /dev/null +++ b/app/RSpade/CodeQuality/Support/resource/js-code-quality-server.js @@ -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); + }); +}); diff --git a/app/RSpade/Commands/Rsx/Bundle_Compile_Command.php b/app/RSpade/Commands/Rsx/Bundle_Compile_Command.php index cb29de797..a39ae10e4 100755 --- a/app/RSpade/Commands/Rsx/Bundle_Compile_Command.php +++ b/app/RSpade/Commands/Rsx/Bundle_Compile_Command.php @@ -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; diff --git a/app/RSpade/Commands/Rsx/Route_Debug_Command.php b/app/RSpade/Commands/Rsx/Route_Debug_Command.php index f66863e63..21174e0dd 100755 --- a/app/RSpade/Commands/Rsx/Route_Debug_Command.php +++ b/app/RSpade/Commands/Rsx/Route_Debug_Command.php @@ -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'); diff --git a/app/RSpade/Commands/Rsx/Ssr_Fpc_Create_Command.php b/app/RSpade/Commands/Rsx/Ssr_Fpc_Create_Command.php deleted file mode 100755 index 99c7cf52f..000000000 --- a/app/RSpade/Commands/Rsx/Ssr_Fpc_Create_Command.php +++ /dev/null @@ -1,279 +0,0 @@ -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; - } -} diff --git a/app/RSpade/Commands/Rsx/Ssr_Fpc_Reset_Command.php b/app/RSpade/Commands/Rsx/Ssr_Fpc_Reset_Command.php deleted file mode 100755 index 4e4403394..000000000 --- a/app/RSpade/Commands/Rsx/Ssr_Fpc_Reset_Command.php +++ /dev/null @@ -1,88 +0,0 @@ -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; - } - } -} diff --git a/app/RSpade/Commands/Rsx/resource/playwright/.placeholder b/app/RSpade/Commands/Rsx/resource/playwright/.placeholder new file mode 100755 index 000000000..e69de29bb diff --git a/app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js b/app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js deleted file mode 100755 index 841e02a80..000000000 --- a/app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js +++ /dev/null @@ -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 [options] - * - * Arguments: - * route The route to generate cache for (e.g., /about) - * - * Options: - * --timeout= 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 [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= 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); - } -})(); diff --git a/app/RSpade/Components/JS_Tree_Debug_Component.jqhtml b/app/RSpade/Components/JS_Tree_Debug_Component.jqhtml new file mode 100755 index 000000000..44bb024b8 --- /dev/null +++ b/app/RSpade/Components/JS_Tree_Debug_Component.jqhtml @@ -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. +--%> + + <% if (JS_Tree_Debug_Node.get_type(this.args.data) !== 'object' && JS_Tree_Debug_Node.get_type(this.args.data) !== 'array') { %> + <%= JS_Tree_Debug_Node.format_value(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %> + <% } else { %> + + <% } %> + diff --git a/app/RSpade/Components/JS_Tree_Debug_Component.js b/app/RSpade/Components/JS_Tree_Debug_Component.js new file mode 100755 index 000000000..537dc7c9d --- /dev/null +++ b/app/RSpade/Components/JS_Tree_Debug_Component.js @@ -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 +} diff --git a/app/RSpade/Components/JS_Tree_Debug_Node.jqhtml b/app/RSpade/Components/JS_Tree_Debug_Node.jqhtml new file mode 100755 index 000000000..ff51fbcf2 --- /dev/null +++ b/app/RSpade/Components/JS_Tree_Debug_Node.jqhtml @@ -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 +--%> + + <% + 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); + %> + + + + <% if (this.args.label !== null && this.args.label !== undefined) { %> + <%= this.args.label %>: + <% } %> + <%= Array.isArray(this.args.data) ? '[' : '{' %> + <% if (class_name) { %> + <%= class_name %><% if (this.args.data && this.args.data.id !== undefined) { %>(<%= this.args.data.id %>)<% } %> + <% } %> + <%= JS_Tree_Debug_Node.get_preview(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %> + +
+ <%-- 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) { %> + + <% } else { %> +
+ <%= key %>: + <%= JS_Tree_Debug_Node.format_value(value, val_type) %> +
+ <% } %> + <% } %> + + <%-- Relationship nodes (lazy-loaded) --%> + <% for (const rel_name of relationships) { %> +
+ <% + this.handler_toggle_rel = () => this.toggle_relationship(rel_name); + %> + + + + <%= rel_name %>(): + ... + +
+ <% } %> +
+ <%= Array.isArray(this.args.data) ? ']' : '}' %> +
diff --git a/app/RSpade/Components/JS_Tree_Debug_Node.js b/app/RSpade/Components/JS_Tree_Debug_Node.js new file mode 100755 index 000000000..30eed86e2 --- /dev/null +++ b/app/RSpade/Components/JS_Tree_Debug_Node.js @@ -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('loading...'); + } else if (state === 'error') { + $preview.html('error'); + } 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('
Loading...
'); + } else if (state === 'error') { + $children.html('
"' + this._escape_html(error) + '"
'); + } 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('
(no data)
'); + return; + } + + // Handle empty array + if (type === 'array' && data.length === 0) { + $container.html('
(empty array)
'); + return; + } + + // Handle empty object + if (type === 'object' && Object.keys(data).length === 0) { + $container.html('
(empty object)
'); + return; + } + + // Handle primitive values (shouldn't happen but be safe) + if (type !== 'array' && type !== 'object') { + $container.html('
' + + this._escape_html(JS_Tree_Debug_Node.format_value(data, type)) + '
'); + 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 = $('
'); + $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( + '
' + + '' + this._escape_html(String(key)) + '' + + ': ' + + '' + + this._escape_html(JS_Tree_Debug_Node.format_value(value, val_type)) + + '
' + ); + } + } + } + + _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 []; + } + } +} diff --git a/app/RSpade/Components/js_tree_debug_component.scss b/app/RSpade/Components/js_tree_debug_component.scss new file mode 100755 index 000000000..b7895965a --- /dev/null +++ b/app/RSpade/Components/js_tree_debug_component.scss @@ -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; + } +} diff --git a/app/RSpade/Core/Ajax/Ajax_Batch_Controller.php b/app/RSpade/Core/Ajax/Ajax_Batch_Controller.php index 860196400..f96936f02 100755 --- a/app/RSpade/Core/Ajax/Ajax_Batch_Controller.php +++ b/app/RSpade/Core/Ajax/Ajax_Batch_Controller.php @@ -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 = []) { diff --git a/app/RSpade/Core/Api/Api_Key_Model.php b/app/RSpade/Core/Api/Api_Key_Model.php new file mode 100755 index 000000000..a9464e4db --- /dev/null +++ b/app/RSpade/Core/Api/Api_Key_Model.php @@ -0,0 +1,180 @@ + '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; + } +} diff --git a/app/RSpade/Core/Bootstrap/RsxBootstrap.php b/app/RSpade/Core/Bootstrap/RsxBootstrap.php index f4bf2b153..67d01c0b1 100755 --- a/app/RSpade/Core/Bootstrap/RsxBootstrap.php +++ b/app/RSpade/Core/Bootstrap/RsxBootstrap.php @@ -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' diff --git a/app/RSpade/Core/Bundle/BundleCompiler.php b/app/RSpade/Core/Bundle/BundleCompiler.php index 4804b2d92..d8476d3ad 100755 --- a/app/RSpade/Core/Bundle/BundleCompiler.php +++ b/app/RSpade/Core/Bundle/BundleCompiler.php @@ -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; } diff --git a/app/RSpade/Core/Bundle/CLAUDE.md b/app/RSpade/Core/Bundle/CLAUDE.md index dd8eb9387..33e934594 100755 --- a/app/RSpade/Core/Bundle/CLAUDE.md +++ b/app/RSpade/Core/Bundle/CLAUDE.md @@ -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 diff --git a/app/RSpade/Core/Bundle/Core_Bundle.php b/app/RSpade/Core/Bundle/Core_Bundle.php index 64404c775..67a0e9b47 100755 --- a/app/RSpade/Core/Bundle/Core_Bundle.php +++ b/app/RSpade/Core/Bundle/Core_Bundle.php @@ -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', ], ]; } diff --git a/app/RSpade/Core/Bundle/Rsx_Asset_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Asset_Bundle_Abstract.php new file mode 100755 index 000000000..fc0fee546 --- /dev/null +++ b/app/RSpade/Core/Bundle/Rsx_Asset_Bundle_Abstract.php @@ -0,0 +1,139 @@ + ['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" . + " }" + ); + } + } + } +} diff --git a/app/RSpade/Core/Bundle/Rsx_Module_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Module_Bundle_Abstract.php new file mode 100755 index 000000000..410eb9a8c --- /dev/null +++ b/app/RSpade/Core/Bundle/Rsx_Module_Bundle_Abstract.php @@ -0,0 +1,73 @@ + [ + * __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" . + " }" + ); + } + } +} diff --git a/app/RSpade/Core/CLAUDE.md b/app/RSpade/Core/CLAUDE.md index 44f9c5b7e..407fedae2 100755 --- a/app/RSpade/Core/CLAUDE.md +++ b/app/RSpade/Core/CLAUDE.md @@ -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/` diff --git a/app/RSpade/Core/CodeTemplates/stubs/javascript.stub b/app/RSpade/Core/CodeTemplates/stubs/javascript.stub index 83d24066a..f173340e2 100755 --- a/app/RSpade/Core/CodeTemplates/stubs/javascript.stub +++ b/app/RSpade/Core/CodeTemplates/stubs/javascript.stub @@ -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 - // } } \ No newline at end of file diff --git a/app/RSpade/Core/Data/Rsx_Reference_Data_Controller.php b/app/RSpade/Core/Data/Rsx_Reference_Data_Controller.php index 32cfeb433..4ab4035c9 100755 --- a/app/RSpade/Core/Data/Rsx_Reference_Data_Controller.php +++ b/app/RSpade/Core/Data/Rsx_Reference_Data_Controller.php @@ -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 { diff --git a/app/RSpade/Core/Database/Database_BundleIntegration.php b/app/RSpade/Core/Database/Database_BundleIntegration.php index 4a17ad058..c4061d177 100755 --- a/app/RSpade/Core/Database/Database_BundleIntegration.php +++ b/app/RSpade/Core/Database/Database_BundleIntegration.php @@ -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"; } diff --git a/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php b/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php index 0df0a9933..5a1a2bf17 100755 --- a/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php +++ b/app/RSpade/Core/Database/Models/Rsx_Model_Abstract.php @@ -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 * diff --git a/app/RSpade/Core/Database/Orm_Controller.php b/app/RSpade/Core/Database/Orm_Controller.php new file mode 100755 index 000000000..f13880a4d --- /dev/null +++ b/app/RSpade/Core/Database/Orm_Controller.php @@ -0,0 +1,341 @@ +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; + } + } +} diff --git a/app/RSpade/Core/Debug/Debugger_Controller.php b/app/RSpade/Core/Debug/Debugger_Controller.php index 8f3a1b9ea..d4e49cc93 100755 --- a/app/RSpade/Core/Debug/Debugger_Controller.php +++ b/app/RSpade/Core/Debug/Debugger_Controller.php @@ -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 { diff --git a/app/RSpade/Core/Dispatch/Ajax_Endpoint_Controller.php b/app/RSpade/Core/Dispatch/Ajax_Endpoint_Controller.php index 76186fe41..eb8a7ec17 100755 --- a/app/RSpade/Core/Dispatch/Ajax_Endpoint_Controller.php +++ b/app/RSpade/Core/Dispatch/Ajax_Endpoint_Controller.php @@ -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); - } - } } diff --git a/app/RSpade/Core/Dispatch/Dispatcher.php b/app/RSpade/Core/Dispatch/Dispatcher.php index 79f3b43ae..bcc5cf09d 100755 --- a/app/RSpade/Core/Dispatch/Dispatcher.php +++ b/app/RSpade/Core/Dispatch/Dispatcher.php @@ -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); - } - } } diff --git a/app/RSpade/Core/Files/File_Attachment_Controller.php b/app/RSpade/Core/Files/File_Attachment_Controller.php index 2958caf98..a4adde4ec 100755 --- a/app/RSpade/Core/Files/File_Attachment_Controller.php +++ b/app/RSpade/Core/Files/File_Attachment_Controller.php @@ -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'] ?? ''; diff --git a/app/RSpade/Core/Js/Ajax.js b/app/RSpade/Core/Js/Ajax.js index fc01e2f45..7e5092516 100755 --- a/app/RSpade/Core/Js/Ajax.js +++ b/app/RSpade/Core/Js/Ajax.js @@ -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; diff --git a/app/RSpade/Core/Js/Rsx_Cache.js b/app/RSpade/Core/Js/Rsx_Cache.js deleted file mode 100755 index 3a3c00484..000000000 --- a/app/RSpade/Core/Js/Rsx_Cache.js +++ /dev/null @@ -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'); - } -} diff --git a/app/RSpade/Core/Js/Rsx_Js_Model.js b/app/RSpade/Core/Js/Rsx_Js_Model.js index 9f7cab533..d4aef06af 100755 --- a/app/RSpade/Core/Js/Rsx_Js_Model.js +++ b/app/RSpade/Core/Js/Rsx_Js_Model.js @@ -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); } } diff --git a/app/RSpade/Core/Manifest/Manifest.php b/app/RSpade/Core/Manifest/Manifest.php index bb4a5de95..a6a70627c 100755 --- a/app/RSpade/Core/Manifest/Manifest.php +++ b/app/RSpade/Core/Manifest/Manifest.php @@ -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; diff --git a/app/RSpade/Core/Models/User_Model.php b/app/RSpade/Core/Models/User_Model.php index 8854d261a..d0546a607 100755 --- a/app/RSpade/Core/Models/User_Model.php +++ b/app/RSpade/Core/Models/User_Model.php @@ -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); } /** diff --git a/app/RSpade/Core/Models/User_Permission_Model.php b/app/RSpade/Core/Models/User_Permission_Model.php new file mode 100755 index 000000000..442e8726f --- /dev/null +++ b/app/RSpade/Core/Models/User_Permission_Model.php @@ -0,0 +1,151 @@ +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'); + } +} diff --git a/app/RSpade/Core/SPA/Default_Layout.jqhtml b/app/RSpade/Core/SPA/Default_Layout.jqhtml new file mode 100755 index 000000000..b4a3277f3 --- /dev/null +++ b/app/RSpade/Core/SPA/Default_Layout.jqhtml @@ -0,0 +1,3 @@ + +
+
diff --git a/app/RSpade/Core/SPA/Default_Layout.js b/app/RSpade/Core/SPA/Default_Layout.js new file mode 100755 index 000000000..2df668b37 --- /dev/null +++ b/app/RSpade/Core/SPA/Default_Layout.js @@ -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 +} diff --git a/app/RSpade/Core/SPA/Spa.js b/app/RSpade/Core/SPA/Spa.js index 271d0d920..c7f8651b9 100755 --- a/app/RSpade/Core/SPA/Spa.js +++ b/app/RSpade/Core/SPA/Spa.js @@ -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 diff --git a/app/RSpade/Core/SPA/Spa_Decorators.js b/app/RSpade/Core/SPA/Spa_Decorators.js index 70b490b20..8c04a88c0 100755 --- a/app/RSpade/Core/SPA/Spa_Decorators.js +++ b/app/RSpade/Core/SPA/Spa_Decorators.js @@ -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; }; } diff --git a/app/RSpade/Core/SPA/Spa_Layout.js b/app/RSpade/Core/SPA/Spa_Layout.js index 22b0e4410..d3102fa63 100755 --- a/app/RSpade/Core/SPA/Spa_Layout.js +++ b/app/RSpade/Core/SPA/Spa_Layout.js @@ -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`); } } diff --git a/app/RSpade/Core/Session/Session.php b/app/RSpade/Core/Session/Session.php index d605c1d4c..0d702a821 100755 --- a/app/RSpade/Core/Session/Session.php +++ b/app/RSpade/Core/Session/Session.php @@ -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; } diff --git a/app/RSpade/Core/Testing/Rsx_Formdata_Generator_Controller.php b/app/RSpade/Core/Testing/Rsx_Formdata_Generator_Controller.php index c212e57f1..9dda02619 100755 --- a/app/RSpade/Core/Testing/Rsx_Formdata_Generator_Controller.php +++ b/app/RSpade/Core/Testing/Rsx_Formdata_Generator_Controller.php @@ -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; diff --git a/app/RSpade/Ide/Services/handler.php b/app/RSpade/Ide/Services/handler.php index b70f15585..9721685ec 100755 --- a/app/RSpade/Ide/Services/handler.php +++ b/app/RSpade/Ide/Services/handler.php @@ -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, diff --git a/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js b/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js index aa6024a47..d8f038bf8 100755 --- a/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js +++ b/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js @@ -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; diff --git a/app/RSpade/Lib/Flash/Flash_Alert.scss b/app/RSpade/Lib/Flash/Flash_Alert.scss index 1670d1a0f..a73da442c 100755 --- a/app/RSpade/Lib/Flash/Flash_Alert.scss +++ b/app/RSpade/Lib/Flash/Flash_Alert.scss @@ -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 diff --git a/app/RSpade/helpers.php b/app/RSpade/helpers.php index cdea2acbf..eb35ddfc5 100755 --- a/app/RSpade/helpers.php +++ b/app/RSpade/helpers.php @@ -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 * diff --git a/app/RSpade/man/acls.txt b/app/RSpade/man/acls.txt new file mode 100755 index 000000000..42cda5b29 --- /dev/null +++ b/app/RSpade/man/acls.txt @@ -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) { %> + + <% } %> + + 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) diff --git a/app/RSpade/man/ajax_error_handling.txt b/app/RSpade/man/ajax_error_handling.txt index 979899191..a866521d0 100755 --- a/app/RSpade/man/ajax_error_handling.txt +++ b/app/RSpade/man/ajax_error_handling.txt @@ -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 diff --git a/app/RSpade/man/auth.txt b/app/RSpade/man/auth.txt index 6f0e900c6..e88fd7baa 100755 --- a/app/RSpade/man/auth.txt +++ b/app/RSpade/man/auth.txt @@ -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: diff --git a/app/RSpade/man/bundle_api.txt b/app/RSpade/man/bundle_api.txt index 16e135bc8..0dcc79900 100755 --- a/app/RSpade/man/bundle_api.txt +++ b/app/RSpade/man/bundle_api.txt @@ -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 diff --git a/app/RSpade/man/config_rsx.txt b/app/RSpade/man/config_rsx.txt index 217f46b82..ab79b7a12 100755 --- a/app/RSpade/man/config_rsx.txt +++ b/app/RSpade/man/config_rsx.txt @@ -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 diff --git a/app/RSpade/man/controller.txt b/app/RSpade/man/controller.txt index d54fd6deb..b32153274 100755 --- a/app/RSpade/man/controller.txt +++ b/app/RSpade/man/controller.txt @@ -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 diff --git a/app/RSpade/man/crud.txt b/app/RSpade/man/crud.txt new file mode 100755 index 000000000..43a81d07c --- /dev/null +++ b/app/RSpade/man/crud.txt @@ -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) + + + + + Clients + + + + New + + + + + + + + +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: + + + + <#DG_Card_Header> + Client List + + + + + + <#DG_Table_Header> + + ID + Name + Created + Actions + + + + <#row> + + <%= row.id %> + <%= row.name %> + <%= new Date(row.created_at).toLocaleDateString() %> + + + + + + + + + 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: + + + + <% if (this.data.loading) { %> + + <% } else if (this.data.error_data) { %> + + <% } else { %> + + + <%= this.data.client.name %> + + + + Edit + + + + + +
+ +
<%= this.data.client.name %>
+
+ <% } %> +
+
+ + 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) + + + <% if (this.data.loading) { %> + + <% } else if (this.data.error_data) { %> + + <% } else { %> + + <%= this.data.is_edit ? 'Edit Client' : 'Add Client' %> + + + + + <% if (this.data.is_edit) { %> + + <% } %> + + + + + + + + + + + + <% } %> + + + +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 + + + + + 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) { %> + + <% } %> + + 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) diff --git a/app/RSpade/man/database_schema_architecture.txt b/app/RSpade/man/database_schema_architecture.txt index 622503601..87edfe7ec 100755 --- a/app/RSpade/man/database_schema_architecture.txt +++ b/app/RSpade/man/database_schema_architecture.txt @@ -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 diff --git a/app/RSpade/man/enums.txt b/app/RSpade/man/enums.txt index 964db7757..09e8e60f1 100755 --- a/app/RSpade/man/enums.txt +++ b/app/RSpade/man/enums.txt @@ -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 diff --git a/app/RSpade/man/external_api.txt b/app/RSpade/man/external_api.txt new file mode 100755 index 000000000..ea1bfa9f0 --- /dev/null +++ b/app/RSpade/man/external_api.txt @@ -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 diff --git a/app/RSpade/man/forms_and_widgets.txt b/app/RSpade/man/forms_and_widgets.txt index aa11f18ce..ee00eede6 100755 --- a/app/RSpade/man/forms_and_widgets.txt +++ b/app/RSpade/man/forms_and_widgets.txt @@ -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
<% for (let i = 1; i <= 5; i++) { %> - <% } %> diff --git a/app/RSpade/man/jqhtmldoc.txt b/app/RSpade/man/jqhtmldoc.txt index a06332ea5..d48f950ae 100755 --- a/app/RSpade/man/jqhtmldoc.txt +++ b/app/RSpade/man/jqhtmldoc.txt @@ -209,7 +209,7 @@ EXAMPLES <% if (this.args.show_price === "true") { %>

$<%= this.data.product.price %>

<% } %> - + <% } %> diff --git a/app/RSpade/man/modals.txt b/app/RSpade/man/modals.txt index c123b40d1..497c27372 100755 --- a/app/RSpade/man/modals.txt +++ b/app/RSpade/man/modals.txt @@ -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:
+ - Include error container:
Example form component (my_form.jqhtml): -
+
- +
- +
@@ -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
+ - Ensure form has
- Verify field name attributes match error keys - Check that fields are wrapped in .form-group containers - Use Form_Utils.apply_form_errors(form.$, errors) diff --git a/app/RSpade/man/model.txt b/app/RSpade/man/model.txt index 2cbc87275..032cbbef2 100755 --- a/app/RSpade/man/model.txt +++ b/app/RSpade/man/model.txt @@ -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 diff --git a/app/RSpade/man/model_fetch.txt b/app/RSpade/man/model_fetch.txt index 202d325e2..2eee3ae12 100755 --- a/app/RSpade/man/model_fetch.txt +++ b/app/RSpade/man/model_fetch.txt @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/RSpade/man/rsx_architecture.txt b/app/RSpade/man/rsx_architecture.txt index ca1de6f93..df12b7d22 100755 --- a/app/RSpade/man/rsx_architecture.txt +++ b/app/RSpade/man/rsx_architecture.txt @@ -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. diff --git a/app/RSpade/man/rsx_debug.txt b/app/RSpade/man/rsx_debug.txt index 316086f14..95dfbb4c2 100755 --- a/app/RSpade/man/rsx_debug.txt +++ b/app/RSpade/man/rsx_debug.txt @@ -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= 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) diff --git a/app/RSpade/man/spa.txt b/app/RSpade/man/spa.txt index be675c559..cb2e94591 100755 --- a/app/RSpade/man/spa.txt +++ b/app/RSpade/man/spa.txt @@ -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 - 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 -
+
@@ -322,7 +322,7 @@ LAYOUTS 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); + } + } + + + +
+ +
+
+
+ + 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 Contacts -
+
@@ -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: diff --git a/app/RSpade/man/zindex.txt b/app/RSpade/man/zindex.txt new file mode 100755 index 000000000..6af143c95 --- /dev/null +++ b/app/RSpade/man/zindex.txt @@ -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 (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/ diff --git a/app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map b/app/RSpade/resource/vscode_extension/out/auto_rename_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/blade_client.js.map b/app/RSpade/resource/vscode_extension/out/blade_client.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/blade_component_provider.js.map b/app/RSpade/resource/vscode_extension/out/blade_component_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/blade_spacer.js.map b/app/RSpade/resource/vscode_extension/out/blade_spacer.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map b/app/RSpade/resource/vscode_extension/out/class_refactor_code_actions.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map b/app/RSpade/resource/vscode_extension/out/class_refactor_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map b/app/RSpade/resource/vscode_extension/out/combined_semantic_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map b/app/RSpade/resource/vscode_extension/out/comment_file_reference_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/config.js.map b/app/RSpade/resource/vscode_extension/out/config.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/convention_method_provider.js b/app/RSpade/resource/vscode_extension/out/convention_method_provider.js index 76e7874bb..ed06ac61b 100755 --- a/app/RSpade/resource/vscode_extension/out/convention_method_provider.js +++ b/app/RSpade/resource/vscode_extension/out/convention_method_provider.js @@ -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 diff --git a/app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map b/app/RSpade/resource/vscode_extension/out/convention_method_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/debug_client.js.map b/app/RSpade/resource/vscode_extension/out/debug_client.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/decoration_provider.js.map b/app/RSpade/resource/vscode_extension/out/decoration_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/definition_provider.js b/app/RSpade/resource/vscode_extension/out/definition_provider.js index b8ec0834c..5d439de44 100755 --- a/app/RSpade/resource/vscode_extension/out/definition_provider.js +++ b/app/RSpade/resource/vscode_extension/out/definition_provider.js @@ -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) diff --git a/app/RSpade/resource/vscode_extension/out/definition_provider.js.map b/app/RSpade/resource/vscode_extension/out/definition_provider.js.map old mode 100644 new mode 100755 index 2528cd744..c125e2ee4 --- a/app/RSpade/resource/vscode_extension/out/definition_provider.js.map +++ b/app/RSpade/resource/vscode_extension/out/definition_provider.js.map @@ -1 +1 @@ -{"version":3,"file":"definition_provider.js","sourceRoot":"","sources":["../src/definition_provider.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"definition_provider.js","sourceRoot":"","sources":["../src/definition_provider.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/app/RSpade/resource/vscode_extension/out/extension.js.map b/app/RSpade/resource/vscode_extension/out/extension.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/file_watcher.js.map b/app/RSpade/resource/vscode_extension/out/file_watcher.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map b/app/RSpade/resource/vscode_extension/out/folder_color_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/folding_provider.js.map b/app/RSpade/resource/vscode_extension/out/folding_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/formatting_provider.js.map b/app/RSpade/resource/vscode_extension/out/formatting_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map b/app/RSpade/resource/vscode_extension/out/git_diff_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/git_status_provider.js.map b/app/RSpade/resource/vscode_extension/out/git_status_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js b/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js index b48148358..4ac4f1eb9 100755 --- a/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js +++ b/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js @@ -43,7 +43,6 @@ const CONVENTION_METHODS = [ 'on_app_modules_init', 'on_app_init', 'on_app_ready', - 'on_jqhtml_ready' ]; /** * Lifecycle method documentation diff --git a/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map b/app/RSpade/resource/vscode_extension/out/jqhtml_lifecycle_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map b/app/RSpade/resource/vscode_extension/out/laravel_completion_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map b/app/RSpade/resource/vscode_extension/out/php_attribute_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map b/app/RSpade/resource/vscode_extension/out/refactor_code_actions.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/refactor_provider.js.map b/app/RSpade/resource/vscode_extension/out/refactor_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map b/app/RSpade/resource/vscode_extension/out/sort_class_methods_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map b/app/RSpade/resource/vscode_extension/out/symlink_redirect_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map b/app/RSpade/resource/vscode_extension/out/that_variable_provider.js.map old mode 100644 new mode 100755 diff --git a/app/RSpade/resource/vscode_extension/package.json b/app/RSpade/resource/vscode_extension/package.json index 47e4f066a..6d41e3d6d 100755 --- a/app/RSpade/resource/vscode_extension/package.json +++ b/app/RSpade/resource/vscode_extension/package.json @@ -2,7 +2,7 @@ "name": "rspade-framework", "displayName": "RSpade Framework Support", "description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management", - "version": "0.1.217", + "version": "0.1.218", "publisher": "rspade", "engines": { "vscode": "^1.74.0" diff --git a/app/RSpade/resource/vscode_extension/rspade-framework.vsix b/app/RSpade/resource/vscode_extension/rspade-framework.vsix index 60b9aa48274eded5de076228289581cff2b4fdb7..dc6c28e7195426a915db81a65bd3c7dd3c85e1ce 100755 GIT binary patch delta 10992 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 UzJv$^a-0Et3%3{xd|AtX0aaf$YXATM delta 10977 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 - * Exit codes: - * 0 - No syntax errors - * 1 - Syntax error found - */ - -const fs = require('fs'); -const path = require('path'); - -// 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')); - -// Get input file from command line arguments -const inputFile = process.argv[2]; - -if (!inputFile) { - console.error('Usage: node js-linter.js '); - process.exit(1); -} - -try { - const code = fs.readFileSync(inputFile, 'utf8'); - - // Parse the code to check for syntax errors - // Babel parser supports decorators natively - babelParser.parse(code, { - sourceType: 'module', - plugins: [ - 'decorators-legacy', // Support for @decorator syntax - 'classProperties', - 'classPrivateProperties', - 'classPrivateMethods', - 'optionalChaining', - 'nullishCoalescingOperator', - 'asyncGenerators', - 'bigInt', - 'dynamicImport', - 'exportDefaultFrom', - 'exportNamespaceFrom', - 'objectRestSpread', - 'topLevelAwait' - ] - }); - - // Silent success - no output when no errors - process.exit(0); - -} catch (error) { - // Output error information - console.error('Syntax error: ' + error.message); - if (error.loc) { - console.error('Line: ' + error.loc.line); - console.error('Column: ' + error.loc.column); - } - process.exit(1); -} \ No newline at end of file diff --git a/config/rsx.php b/config/rsx.php index c1e0111f2..ea7b0a0b6 100755 --- a/config/rsx.php +++ b/config/rsx.php @@ -286,6 +286,7 @@ return [ 'app/RSpade/CodeQuality', // Code quality rules and checks 'app/RSpade/Testing', // Testing framework classes 'app/RSpade/Lib', // UI features and other extras + 'app/RSpade/Components', // Framework-provided reusable components 'app/RSpade/temp', // Framework developer testing directory ], @@ -456,34 +457,6 @@ return [ \App\RSpade\Core\Providers\Rsx_Dispatch_Bootstrapper_Handler::class, // Priority 1000 ], - /* - |-------------------------------------------------------------------------- - | SSR Full Page Cache (FPC) Configuration - |-------------------------------------------------------------------------- - | - | Settings for server-side rendered static page caching. Routes marked with - | #[Static_Page] attribute are pre-rendered as static HTML via headless - | Chrome (Playwright) and cached in Redis for optimal SEO and performance. - | - | Cache Behavior: - | - Only served to unauthenticated users (no active session) - | - Auto-invalidates on deployment (build_key changes) - | - Supports ETags for 304 Not Modified responses - | - Cache headers: 0s in dev, 5min in prod - | - | Commands: - | - php artisan rsx:ssr_fpc:create /route # Generate cache for route - | - php artisan rsx:ssr_fpc:reset # Clear all FPC caches - | - */ - 'ssr_fpc' => [ - // Enable SSR Full Page Cache system - 'enabled' => env('SSR_FPC_ENABLED', false), - - // Playwright generation timeout in milliseconds - 'generation_timeout' => env('SSR_FPC_TIMEOUT', 30000), - ], - /* |-------------------------------------------------------------------------- | Thumbnail Configuration diff --git a/database/migrations/.migration_whitelist b/database/migrations/.migration_whitelist index fd903881a..a75567cf0 100755 --- a/database/migrations/.migration_whitelist +++ b/database/migrations/.migration_whitelist @@ -311,6 +311,21 @@ "created_at": "2025-11-19T23:17:08+00:00", "created_by": "root", "command": "php artisan make:migration:safe create_test_user_record" + }, + "2025_11_21_193529_create_api_keys_table.php": { + "created_at": "2025-11-21T19:35:29+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_api_keys_table" + }, + "2025_11_23_160855_create_user_permissions_table.php": { + "created_at": "2025-11-23T16:08:55+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_user_permissions_table" + }, + "2025_11_23_164439_reindex_user_roles_to_100_based.php": { + "created_at": "2025-11-23T16:44:39+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe reindex_user_roles_to_100_based" } } } \ No newline at end of file diff --git a/database/migrations/2025_11_21_193529_create_api_keys_table.php b/database/migrations/2025_11_21_193529_create_api_keys_table.php new file mode 100755 index 000000000..6dfd17782 --- /dev/null +++ b/database/migrations/2025_11_21_193529_create_api_keys_table.php @@ -0,0 +1,52 @@ + # RSpade Framework - AI/LLM Development Guide @@ -35,7 +34,7 @@ This separation ensures: **Visual Basic-like development for PHP/Laravel.** Think: VB6 apps → VB6 runtime → Windows = RSX apps → RSpade runtime → Laravel. -**Philosophy**: Modern anti-modernization. While JavaScript fragments into complexity and React demands quarterly paradigm shifts, RSpade asks: "What if coding was easy again?" Business apps need quick builds and easy maintenance, not bleeding-edge architecture. +**Philosophy**: RSpade is rebellion against JavaScript fatigue. Like VB6 made Windows programming accessible, RSpade makes web development simple again. No build steps, no config hell, just code and reload. **Important**: RSpade is built on Laravel but diverges significantly. Do not assume Laravel patterns work in RSX without verification. @@ -45,38 +44,32 @@ This separation ensures: ## CRITICAL RULES -### 🔴 RSpade Builds Automatically - NEVER RUN BUILD COMMANDS +### CRITICAL: RSpade Builds Automatically - NEVER RUN BUILD COMMANDS -**RSpade is an INTERPRETED framework** - like Python or PHP, changes are automatically detected and compiled on-the-fly. There is NO manual build step. +**RSpade is INTERPRETED** - changes compile on-the-fly. NO manual build steps. -**ABSOLUTELY FORBIDDEN** (unless explicitly instructed): -- `npm run compile` / `npm run build` - **DO NOT EXIST** -- `bin/publish` - Creates releases for OTHER developers (not for testing YOUR changes) -- `rsx:bundle:compile` - Bundles compile automatically in dev mode -- `rsx:manifest:build` - Manifest rebuilds automatically in dev mode -- ANY command with "build", "compile", or "publish" +**FORBIDDEN** (unless explicitly instructed): +- `npm run compile/build` - Don't exist +- `bin/publish` - For releases, not testing +- `rsx:bundle:compile` / `rsx:manifest:build` - Automatic +- ANY "build", "compile", or "publish" command -**How it works**: -1. Edit JS/SCSS/PHP files -2. Refresh browser -3. Changes are live (< 1 second) +Edit → Save → Refresh browser → Changes live (< 1 second) -**If you find yourself wanting to run build commands**: STOP. You're doing something wrong. Changes are already live. - -### 🔴 Framework Updates +### Framework Updates ```bash -php artisan rsx:framework:pull # 5-minute timeout required +php artisan rsx:framework:pull # User-initiated only ``` -Updates take 2-5 minutes. Includes code pull, manifest rebuild, bundle recompilation. **For AI: Always use 5-minute timeout.** +Updates take 2-5 minutes. Includes code pull, manifest rebuild, bundle recompilation. Only run when requested by user. -### 🔴 Fail Loud - No Silent Fallbacks +### Fail Loud - No Silent Fallbacks **ALWAYS fail visibly.** No redundant fallbacks, silent failures, or alternative code paths. ```php -// ❌ CATASTROPHIC +// ❌ WRONG try { $clean = Sanitizer::sanitize($input); } catch (Exception $e) { $clean = $input; } // DISASTER @@ -84,29 +77,9 @@ catch (Exception $e) { $clean = $input; } // DISASTER $clean = Sanitizer::sanitize($input); // Let it throw ``` -**SECURITY-CRITICAL**: If sanitization/validation/auth fails, NEVER continue. Always throw immediately. +**SECURITY-CRITICAL**: Never continue after security failures. Only catch expected failures (file uploads, APIs, user input). Let exceptions bubble to global handler. -**NO BLANKET TRY/CATCH**: Use try/catch only for expected failures (file uploads, external APIs, user input parsing). NEVER wrap database operations or entire functions "just in case". - -```php -// ❌ WRONG - Defensive "on error resume" -try { - $user->save(); - $result = process_data($user); - return $result; -} catch (Exception $e) { - throw new Exception("Failed: " . $e->getMessage(), 0, $e); -} - -// ✅ CORRECT - Let exceptions bubble -$user->save(); -$result = process_data($user); -return $result; -``` - -Exception handlers format errors for different contexts (Ajax JSON, CLI, HTML). Don't wrap exceptions with generic messages - let them bubble to the global handler. - -### 🔴 No Defensive Coding +### No Defensive Coding Core classes ALWAYS exist. Never check. @@ -115,11 +88,11 @@ Core classes ALWAYS exist. Never check. // ✅ GOOD: Rsx.Route('Controller::method') ``` -### 🔴 Static-First Philosophy +### Static-First Philosophy Classes are namespacing tools. Use static unless instances needed (models, resources). Avoid dependency injection. -### 🔴 Git Workflow - Framework is READ-ONLY +### Git Workflow - Framework is READ-ONLY **NEVER modify `/var/www/html/system/`** - It's like node_modules or the Linux kernel. @@ -129,7 +102,7 @@ Classes are namespacing tools. Use static unless instances needed (models, resou **Commit discipline**: ONLY commit when explicitly asked. Commits are milestones, not individual changes. -### 🔴 DO NOT RUN `rsx:clean` +### DO NOT RUN `rsx:clean` **RSpade's cache auto-invalidates on file changes.** Running `rsx:clean` causes 30-60 second rebuilds with zero benefit. @@ -137,7 +110,7 @@ Classes are namespacing tools. Use static unless instances needed (models, resou **Correct workflow**: Edit → Save → Reload browser → See changes (< 1 second) -### 🔴 Trust Code Quality Rules +### Trust Code Quality Rules Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do: - Some rules say "fix immediately" @@ -168,12 +141,16 @@ Files sharing a common prefix are a related set. When renaming, maintain the gro ``` frontend_calendar_event.scss frontend_calendar_event_controller.php -frontend_calendar_event.blade.php +frontend_calendar_event.jqhtml frontend_calendar_event.js ``` **Critical**: Never create same-name different-case files (e.g., `user.php` and `User.php`). +### Component Naming Pattern + +Input components follow: `{Supertype}_{Variant}_{Supertype}` → e.g., `Select_Country_Input`, `Select_State_Input`, `Select_Ajax_Input` + --- ## DIRECTORY STRUCTURE @@ -183,12 +160,15 @@ frontend_calendar_event.js ├── rsx/ # YOUR CODE │ ├── app/ # Modules │ ├── models/ # Database models +│ ├── services/ # Background tasks, external integrations │ ├── public/ # Static files (web-accessible) │ ├── resource/ # Framework-ignored │ └── theme/ # Global assets └── system/ # FRAMEWORK (read-only) ``` +**Services**: Extend `Rsx_Service_Abstract` for non-HTTP functionality like scheduled tasks or external system integrations. + ### Special Directories (Path-Agnostic) **`resource/`** - ANY directory named this is framework-ignored. Store helpers, docs, third-party code. Exception: `/rsx/resource/config/` IS processed. @@ -221,7 +201,12 @@ Merged via `array_merge_deep()`. Common overrides: `development.auto_rename_file ```php class Frontend_Controller extends Rsx_Controller_Abstract { - #[Auth('Permission::anybody()')] + public static function pre_dispatch(Request $request, array $params = []) + { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; + } + #[Route('/', methods: ['GET'])] public static function index(Request $request, array $params = []) { @@ -232,17 +217,22 @@ class Frontend_Controller extends Rsx_Controller_Abstract } ``` -**Rules**: Only GET/POST allowed. Use `:param` syntax. All routes MUST have `#[Auth]`. +**Rules**: Only GET/POST. Use `:param` syntax. Manual auth checks in pre_dispatch or method body. -### #[Auth] Attribute +### Authentication Pattern ```php -#[Auth('Permission::anybody()')] // Public -#[Auth('Permission::authenticated()')] // Require login -#[Auth('Permission::has_role("admin")')] // Custom +// Controller-wide auth (recommended) +public static function pre_dispatch(Request $request, array $params = []) { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; +} + +// Public endpoints: add @auth-exempt to class docblock +/** @auth-exempt Public route */ ``` -**Controller-wide**: Add to `pre_dispatch()`. Multiple attributes = all must pass. +**Code quality**: PHP-AUTH-01 rule verifies auth checks exist. Use `@auth-exempt` for public routes. ### Type-Safe URLs @@ -283,17 +273,19 @@ Client-side routing for authenticated application areas. One PHP bootstrap contr ### SPA Components -**1. PHP Bootstrap Controller (ONE per feature/bundle)** +**1. PHP Bootstrap Controller** - ONE per module with auth in pre_dispatch ```php -class Frontend_Spa_Controller extends Rsx_Controller_Abstract { - #[SPA] - #[Auth('Permission::authenticated()')] - public static function index(Request $request, array $params = []) { - return rsx_view(SPA); - } +public static function pre_dispatch(Request $request, array $params = []) { + if (!Session::is_logged_in()) return response_unauthorized(); + return null; +} + +#[SPA] +public static function index(Request $request, array $params = []) { + return rsx_view(SPA); } ``` -**CRITICAL**: One #[SPA] per feature/bundle (e.g., `/app/frontend`, `/app/root`, `/app/login`). Bundles separate features to save bandwidth, reduce processing time, and segregate confidential code (e.g., root admin from unauthorized users). The #[SPA] bootstrap performs server-side auth checks with failure/redirect before loading client-side actions. Typically one #[SPA] per feature at `rsx/app/(feature)/(feature)_spa_controller::index`. +One #[SPA] per module at `rsx/app/(module)/(module)_spa_controller::index`. Segregates code by permission level. **2. JavaScript Actions (MANY)** ```javascript @@ -350,12 +342,17 @@ class Contacts_View_Action extends Spa_Action { ### File Organization +Pattern: `/rsx/app/(module)/(feature)/` +- **Module**: Major functionality (login, frontend, root) +- **Feature**: Screen within module (contacts, reports, invoices) +- **Submodule**: Feature grouping (settings), often with sublayouts + ``` -/rsx/app/frontend/ +/rsx/app/frontend/ # Module ├── Frontend_Spa_Controller.php # Single SPA bootstrap ├── Frontend_Layout.js ├── Frontend_Layout.jqhtml -└── contacts/ +└── contacts/ # Feature ├── frontend_contacts_controller.php # Ajax endpoints only ├── Contacts_Index_Action.js # /contacts ├── Contacts_Index_Action.jqhtml @@ -366,6 +363,10 @@ class Contacts_View_Action extends Spa_Action { **Use SPA for:** Authenticated areas, dashboards, admin panels **Avoid for:** Public pages (SEO needed), simple static pages +### Sublayouts + +**Sublayouts** are `Spa_Layout` classes for nested persistent UI (e.g., settings sidebar). Use multiple `@layout` decorators - first is outermost: `@layout('Frontend_Spa_Layout')` then `@layout('Settings_Layout')`. Each must have `$sid="content"`. Layouts persist when unchanged; only differing parts recreated. All receive `on_action(url, action_name, args)` with final action info. + Details: `php artisan rsx:man spa` ### View Action Pattern (Loading Data) @@ -396,149 +397,47 @@ Template uses three states: `` → `` -- `{!! $html !!}` → `<%!= this.data.html %>` -- `@if($cond)` → `<% if (this.data.cond) { %>` -- `@foreach($items as $item)` → `<% for (let item of this.data.items) { %>` -- `@endforeach` → `<% } %>` -- `{{-- comment --}}` → `<%-- comment --%>` -- `{{ Rsx::Route('Class') }}` → `<%= Rsx.Route('Class') %>` - -```jqhtml - - - Title - <% for (let item of this.data.items) { %> -
<%= item.name %>
- <% } %> -
-
-``` - -**4. Update Controller - Remove server-side route entirely:** -```php -// Remove #[Route] method completely. Add Ajax endpoints: -#[Ajax_Endpoint] -public static function fetch_items(Request $request, array $params = []) { - return ['items' => Feature_Model::all()]; -} -``` - -**CRITICAL**: Do NOT add `#[SPA]` to feature controllers. The `#[SPA]` attribute only exists in the bootstrap controller (e.g., `Frontend_Spa_Controller::index`). Feature controllers should only contain `#[Ajax_Endpoint]` methods for data fetching. - -**5. Update Route References** - -**Search entire codebase for old route references:** -```bash -grep -r "Feature_Controller::method" rsx/app/ -``` - -Find/replace in all files: -- `Rsx::Route('Feature_Controller::method')` → `Rsx::Route('Feature_Action')` -- `Rsx.Route('Feature_Controller::method')` → `Rsx.Route('Feature_Action')` -- Hardcoded `/feature/method/123` → `Rsx.Route('Feature_Action', 123)` - -**Check:** DataGrids, dashboards, save endpoints, navigation, breadcrumbs - -**6. Archive Old Files** -```bash -mkdir -p rsx/resource/archive/frontend/feature/ -mv feature_index.blade.php rsx/resource/archive/frontend/feature/ -``` - -**7. Test** -```bash -php artisan rsx:debug /path -``` - -Verify: No JS errors, page renders, data loads. - -**Common Patterns:** - -**DataGrid (no data loading):** -```javascript -class Items_Index_Action extends Spa_Action { - full_width = true; - async on_load() {} // DataGrid loads own data -} -``` - -**Detail page (load data):** -```javascript -@route('/items/:id') -class Items_View_Action extends Spa_Action { - async on_load() { - this.data.item = await Items_Controller.get({id: this.args.id}); - } -} -``` +For converting server-side Blade pages to client-side SPA actions, see `php artisan rsx:man blade_to_spa`. +The process involves creating Action classes with @route decorators and converting templates from Blade to jqhtml syntax. --- ## BLADE & VIEWS +**Note**: SPA pages are the preferred standard. Use Blade only for SEO-critical public pages or authentication flows. + ```blade @rsx_id('Frontend_Index') {{-- Every view starts with this --}} - {{-- Adds view class --}} - -@rsx_include('Component_Name') {{-- Include by name, not path --}} ``` -**NO inline styles, scripts, or event handlers** in Blade views: -- No `