Framework updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-01 05:16:45 +00:00
parent f48cda006a
commit 0efdcd4cde
27 changed files with 2970 additions and 153 deletions

View File

@@ -260,7 +260,9 @@ class Maint_Migrate extends Command
}
// Run normalize_schema BEFORE migrations to fix existing tables
$requiredColumnsArgs = $is_development ? [] : ['--production' => true];
// Use --production flag if not using snapshots (framework-only or non-development mode)
$use_snapshot = $is_development && !$is_framework_only;
$requiredColumnsArgs = $use_snapshot ? [] : ['--production' => true];
$this->info("\n Pre-migration normalization (fixing existing tables)...\n");
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);

View File

@@ -11,6 +11,7 @@ use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Models\Login_User_Model;
use App\RSpade\Core\Portal\Portal_User_Model;
/**
* RSX Route Debug Command
@@ -156,7 +157,9 @@ class Route_Debug_Command extends Command
{--console-list : Alias for --console-log to display all console output}
{--screenshot-width= : Screenshot width (px or preset: mobile, iphone-mobile, tablet, desktop-small, desktop-medium, desktop-large). Defaults to 1920}
{--screenshot-path= : Path to save screenshot file (triggers screenshot capture, max height 5000px)}
{--dump-dimensions= : Add data-dimensions attribute to elements matching selector (for layout debugging)}';
{--dump-dimensions= : Add data-dimensions attribute to elements matching selector (for layout debugging)}
{--portal : Test portal routes (uses /_portal/ prefix and portal authentication)}
{--portal-user= : Test as specific portal user ID or email (requires --portal)}';
/**
* The console command description.
@@ -213,15 +216,46 @@ class Route_Debug_Command extends Command
$url = '/' . $url;
}
// Get portal mode options
$portal_mode = $this->option('portal');
$portal_user_input = $this->option('portal-user');
// Validate portal options
if ($portal_user_input && !$portal_mode) {
$this->error('--portal-user requires --portal flag');
return 1;
}
// Normalize URL for portal mode (strip /_portal/ prefix if present)
if ($portal_mode && str_starts_with($url, '/_portal')) {
$url = substr($url, 8); // Remove '/_portal'
if ($url === '' || $url === false) {
$url = '/';
}
}
// Get user ID from options (accepts ID or email)
$user_id = $this->option('user');
if ($user_id !== null) {
if ($portal_mode) {
$this->error('Use --portal-user instead of --user when --portal flag is set');
return 1;
}
$user_id = $this->resolve_user($user_id);
if ($user_id === null) {
return 1; // Error already displayed
}
}
// Get portal user ID from options (accepts ID or email)
$portal_user_id = null;
if ($portal_user_input !== null) {
$portal_user_id = $this->resolve_portal_user($portal_user_input);
if ($portal_user_id === null) {
return 1; // Error already displayed
}
}
// Get log flag
$show_log = $this->option('log');
@@ -384,16 +418,26 @@ class Route_Debug_Command extends Command
// This prevents unauthorized requests from hijacking sessions via headers
$dev_auth_token = null;
if ($user_id) {
$dev_auth_token = $this->generate_dev_auth_token($url, $user_id);
$dev_auth_token = $this->generate_dev_auth_token($url, $user_id, false);
} elseif ($portal_user_id) {
$dev_auth_token = $this->generate_dev_auth_token($url, $portal_user_id, true);
}
// Build command arguments
$command_args = ['node', $playwright_script, $url];
if ($portal_mode) {
$command_args[] = '--portal';
}
if ($user_id) {
$command_args[] = "--user={$user_id}";
}
if ($portal_user_id) {
$command_args[] = "--portal-user={$portal_user_id}";
}
if ($dev_auth_token) {
$command_args[] = "--dev-auth-token={$dev_auth_token}";
}
@@ -560,6 +604,15 @@ class Route_Debug_Command extends Command
$this->line(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email');
$this->line('');
$this->comment('PORTAL ROUTES:');
$this->line(' php artisan rsx:debug /dashboard --portal --portal-user=1');
$this->line(' # Test portal as user ID 1');
$this->line(' php artisan rsx:debug /_portal/dashboard --portal --portal-user=1');
$this->line(' # Same (/_portal/ prefix stripped)');
$this->line(' php artisan rsx:debug /mail --portal --portal-user=client@example.com');
$this->line(' # Test portal as user by email');
$this->line('');
$this->comment('TESTING RSX JAVASCRIPT (use return or console.log for output):');
$this->line(' php artisan rsx:debug / --eval="return typeof Rsx_Time" # Check if class exists');
$this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time');
@@ -677,6 +730,43 @@ class Route_Debug_Command extends Command
return $user_id;
}
/**
* Resolve portal user identifier to user ID
*
* Accepts either a numeric user ID or an email address.
* Validates that the portal user exists in the database.
*
* @param string $user_input Portal user ID or email address
* @return int|null User ID or null if not found (error already displayed)
*/
protected function resolve_portal_user(string $user_input): ?int
{
// Check if input is an email address
if (str_contains($user_input, '@')) {
$portal_user = Portal_User_Model::where('email', $user_input)->first();
if (!$portal_user) {
$this->error("Portal user not found: {$user_input}");
return null;
}
return $portal_user->id;
}
// Input is a user ID - validate it exists
if (!ctype_digit($user_input)) {
$this->error("Invalid portal user identifier: {$user_input} (must be numeric ID or email address)");
return null;
}
$user_id = (int) $user_input;
$portal_user = Portal_User_Model::find($user_id);
if (!$portal_user) {
$this->error("Portal user ID not found: {$user_id}");
return null;
}
return $user_id;
}
/**
* Generate a signed dev auth token for Playwright requests
*
@@ -686,9 +776,10 @@ class Route_Debug_Command extends Command
*
* @param string $url The URL being tested
* @param int $user_id The user ID to authenticate as
* @param bool $is_portal Whether this is a portal user (vs main site user)
* @return string The signed token
*/
protected function generate_dev_auth_token(string $url, int $user_id): string
protected function generate_dev_auth_token(string $url, int $user_id, bool $is_portal = false): string
{
$app_key = config('app.key');
if (!$app_key) {
@@ -700,6 +791,7 @@ class Route_Debug_Command extends Command
$payload = json_encode([
'url' => $url,
'user_id' => $user_id,
'portal' => $is_portal,
]);
// Sign with HMAC-SHA256

View File

@@ -6,6 +6,8 @@ use RuntimeException;
use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors;
use App\RSpade\Core\Bundle\BundleCompiler;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Portal\Portal_Session;
use App\RSpade\Core\Portal\Rsx_Portal;
use App\RSpade\Core\Rsx;
use App\RSpade\Core\Session\Session;
@@ -261,10 +263,12 @@ abstract class Rsx_Bundle_Abstract
// Add runtime data
$rsxapp_data['debug'] = Rsx::is_development();
$rsxapp_data['current_controller'] = Rsx::get_current_controller();
$rsxapp_data['current_action'] = Rsx::get_current_action();
$rsxapp_data['is_auth'] = Session::is_logged_in();
$rsxapp_data['is_spa'] = Rsx::is_spa();
// Use portal-specific methods when in portal context
$is_portal = Rsx_Portal::is_portal_request();
$rsxapp_data['is_portal'] = $is_portal;
$rsxapp_data['current_controller'] = $is_portal ? Rsx_Portal::get_current_controller() : Rsx::get_current_controller();
$rsxapp_data['current_action'] = $is_portal ? Rsx_Portal::get_current_action() : Rsx::get_current_action();
$rsxapp_data['is_spa'] = $is_portal ? Rsx_Portal::is_spa() : Rsx::is_spa();
// Enable ajax batching in debug/production modes, disable in development for easier debugging
$rsxapp_data['ajax_batching'] = !Rsx::is_development();
@@ -274,9 +278,18 @@ abstract class Rsx_Bundle_Abstract
$rsxapp_data['params'] = $current_params ?? [];
// Add user, site, and csrf data from session
$rsxapp_data['user'] = Session::get_user();
$rsxapp_data['site'] = Session::get_site();
$rsxapp_data['csrf'] = Session::get_csrf_token();
// Use Portal_Session for portal requests, Session for regular requests
if ($is_portal) {
$rsxapp_data['is_auth'] = Portal_Session::is_logged_in();
$rsxapp_data['user'] = Portal_Session::get_portal_user();
$rsxapp_data['site'] = Portal_Session::get_site();
$rsxapp_data['csrf'] = Portal_Session::get_csrf_token();
} else {
$rsxapp_data['is_auth'] = Session::is_logged_in();
$rsxapp_data['user'] = Session::get_user();
$rsxapp_data['site'] = Session::get_site();
$rsxapp_data['csrf'] = Session::get_csrf_token();
}
// Add browser error logging flag (enabled in both dev and production)
if (config('rsx.log_browser_errors', false)) {

View File

@@ -19,6 +19,8 @@ use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Dispatch\AssetHandler;
use App\RSpade\Core\Dispatch\RouteResolver;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Portal\Portal_Dispatcher;
use App\RSpade\Core\Portal\Rsx_Portal;
use App\RSpade\Core\Rsx;
/**
@@ -83,6 +85,12 @@ class Dispatcher
$request = $request ?? request();
// Check if this is a portal request - delegate to Portal_Dispatcher
if (Rsx_Portal::is_portal_request()) {
console_debug('DISPATCH', 'Portal request detected, delegating to Portal_Dispatcher');
return Portal_Dispatcher::dispatch($url, $method, $extra_params, $request);
}
// Custom session is handled by Session::init() in RsxAuth
// Check if this is an asset request

View File

@@ -30,50 +30,42 @@ use App\RSpade\Core\Files\File_Storage_Model;
* provides the basic structure for categorizing uploaded files.
*/
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2026-01-29 08:25:50
* Table: _file_attachments
*
* @property int $id
* @property mixed $key
* @property int $file_storage_id
* @property mixed $file_name
* @property mixed $file_extension
* @property int $file_type_id
* @property int $width
* @property int $height
* @property int $duration
* @property bool $is_animated
* @property int $frame_count
* @property int $fileable_type
* @property int $fileable_id
* @property mixed $fileable_category
* @property mixed $fileable_type_meta
* @property int $fileable_order
* _AUTO_GENERATED_
* @property integer $id
* @property string $key
* @property integer $file_storage_id
* @property string $file_name
* @property string $file_extension
* @property integer $file_type_id
* @property integer $width
* @property integer $height
* @property integer $duration
* @property boolean $is_animated
* @property integer $frame_count
* @property integer $fileable_type
* @property integer $fileable_id
* @property string $fileable_category
* @property string $fileable_type_meta
* @property integer $fileable_order
* @property string $fileable_meta
* @property int $site_id
* @property mixed $session_id
* @property string $created_at
* @property string $updated_at
* @property int $created_by
* @property int $updated_by
*
* @property-read string $file_type_id__label
* @property-read string $file_type_id__constant
*
* @method static array file_type_id__enum() Get all enum definitions with full metadata
* @method static array file_type_id__enum_select() Get selectable items for dropdowns
* @method static array file_type_id__enum_labels() Get simple id => label map
* @method static array file_type_id__enum_ids() Get array of all valid enum IDs
*
* @property integer $site_id
* @property string $session_id
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property integer $created_by
* @property integer $updated_by
* @method static mixed file_type_id_enum()
* @method static mixed file_type_id_enum_select()
* @method static mixed file_type_id_enum_ids()
* @property-read mixed $file_type_id_constant
* @property-read mixed $file_type_id_label
* @mixin \Eloquent
*/
class File_Attachment_Model extends Rsx_Site_Model_Abstract
{
/**
* _AUTO_GENERATED_ Enum constants
*/
{
/** __AUTO_GENERATED: */
const FILE_TYPE_IMAGE = 1;
const FILE_TYPE_ANIMATED_IMAGE = 2;
const FILE_TYPE_VIDEO = 3;
@@ -81,9 +73,6 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
const FILE_TYPE_TEXT = 5;
const FILE_TYPE_DOCUMENT = 6;
const FILE_TYPE_OTHER = 7;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */
/**

405
app/RSpade/Core/Js/Rsx_Portal.js Executable file
View File

@@ -0,0 +1,405 @@
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx_Portal - Client Portal JavaScript Runtime Utilities
*
* Provides static utility methods for client portal JavaScript code including
* route generation and portal context detection.
*
* Key differences from Rsx:
* - Route() prepends portal domain/prefix
* - Portal-specific context detection
* - Simpler (no event system, fewer utilities)
*
* Usage Examples:
* ```javascript
* // Check if in portal context
* if (Rsx_Portal.is_portal()) { ... }
*
* // Route generation (applies portal prefix/domain)
* const url = Rsx_Portal.Route('Portal_Dashboard_Action');
* // Development: /_portal/dashboard
* // Production: /dashboard (on portal domain)
*
* // Route with parameters
* const url = Rsx_Portal.Route('Portal_Project_View_Action', 123);
* // Development: /_portal/projects/123
* ```
*
* @static
* @global
*/
class Rsx_Portal {
/**
* URL prefix for portal when no dedicated domain configured
* Must match PHP Rsx_Portal::URL_PREFIX
*/
static URL_PREFIX = '/_portal';
/**
* Storage for portal route definitions loaded from bundles
*/
static _routes = {};
/**
* Cached portal context detection result
*/
static _is_portal = null;
// =========================================================================
// Configuration Methods
// =========================================================================
/**
* Get the configured portal domain
*
* @returns {string|null} Domain if configured, null otherwise
*/
static get_domain() {
return window.rsxapp?.portal?.domain || null;
}
/**
* Get the portal URL prefix (used when no domain configured)
*
* @returns {string} The prefix, defaults to '/_portal'
*/
static get_prefix() {
return window.rsxapp?.portal?.prefix || Rsx_Portal.URL_PREFIX;
}
/**
* Check if portal is using a dedicated domain (vs URL prefix)
*
* @returns {boolean} True if dedicated domain is configured
*/
static has_dedicated_domain() {
return !!Rsx_Portal.get_domain();
}
// =========================================================================
// Portal Context Detection
// =========================================================================
/**
* Check if currently in portal context
*
* @returns {boolean} True if this is a portal page
*/
static is_portal() {
if (Rsx_Portal._is_portal !== null) {
return Rsx_Portal._is_portal;
}
// Check rsxapp flag (set by portal bootstrap)
Rsx_Portal._is_portal = !!window.rsxapp?.is_portal;
return Rsx_Portal._is_portal;
}
/**
* Get the current portal user
*
* @returns {Object|null} Portal user data or null if not logged in
*/
static user() {
if (!Rsx_Portal.is_portal()) {
return null;
}
return window.rsxapp?.user || null;
}
// =========================================================================
// Route Generation
// =========================================================================
/**
* Generate URL for a portal route
*
* Similar to Rsx.Route() but:
* - Returns URLs with portal domain or prefix
* - Only works with portal routes
*
* Usage examples:
* ```javascript
* // Portal action route
* const url = Rsx_Portal.Route('Portal_Dashboard_Action');
* // Development: /_portal/dashboard
*
* // Route with integer parameter (sets 'id')
* const url = Rsx_Portal.Route('Portal_Project_View_Action', 123);
* // Development: /_portal/projects/123
*
* // Route with named parameters
* const url = Rsx_Portal.Route('Portal_Project_View_Action', {id: 123, tab: 'files'});
* // Development: /_portal/projects/123?tab=files
*
* // Placeholder route
* const url = Rsx_Portal.Route('Future_Portal_Feature::#index');
* // Returns: #
* ```
*
* @param {string} action Controller class, SPA action, or "Class::method"
* @param {number|Object} [params=null] Route parameters
* @returns {string} The generated URL (includes portal prefix in dev mode)
*/
static Route(action, params = null) {
// Parse action into class_name and action_name
let class_name, action_name;
if (action.includes('::')) {
[class_name, action_name] = action.split('::', 2);
} else {
class_name = action;
action_name = 'index';
}
// Normalize params to object
let params_obj = {};
if (typeof params === 'number') {
params_obj = { id: params };
} else if (typeof params === 'string' && /^\d+$/.test(params)) {
params_obj = { id: parseInt(params, 10) };
} else if (params && typeof params === 'object') {
params_obj = params;
} else if (params !== null && params !== undefined) {
throw new Error('Params must be number, object, or null');
}
// Placeholder route: action starts with # means unimplemented/scaffolding
if (action_name.startsWith('#')) {
return '#';
}
// Check if route exists in portal route definitions
let pattern = null;
if (Rsx_Portal._routes[class_name] && Rsx_Portal._routes[class_name][action_name]) {
const route_patterns = Rsx_Portal._routes[class_name][action_name];
pattern = Rsx_Portal._select_best_route_pattern(route_patterns, params_obj);
if (!pattern) {
const route_list = route_patterns.join(', ');
throw new Error(
`No suitable portal route found for ${class_name}::${action_name} with provided parameters. ` +
`Available routes: ${route_list}`
);
}
} else {
// Not found in portal routes - try SPA action route
pattern = Rsx_Portal._try_spa_action_route(class_name, params_obj);
if (!pattern) {
throw new Error(
`Portal route not found for ${action}. ` +
`Ensure the class has a @portal_route decorator.`
);
}
}
// Generate base URL from pattern
const path = Rsx_Portal._generate_url_from_pattern(pattern, params_obj);
// Apply portal prefix (in dev mode) or return as-is (domain mode)
return Rsx_Portal._apply_portal_base(path);
}
/**
* Apply portal domain or prefix to a path
*
* @param {string} path The route path (e.g., '/dashboard')
* @returns {string} Path with portal prefix (dev) or plain path (domain mode)
* @private
*/
static _apply_portal_base(path) {
// If using dedicated domain, path is relative to that domain
if (Rsx_Portal.has_dedicated_domain()) {
return path;
}
// Development mode: prepend prefix
const prefix = Rsx_Portal.get_prefix();
return prefix + path;
}
/**
* Select the best matching route pattern from available patterns
*
* @param {Array<string>} patterns Array of route patterns
* @param {Object} params_obj Provided parameters
* @returns {string|null} Selected pattern or null if none match
* @private
*/
static _select_best_route_pattern(patterns, params_obj) {
const satisfiable = [];
for (const pattern of patterns) {
// Extract required parameters from pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check if all required parameters are provided
let can_satisfy = true;
for (const required of required_params) {
if (!(required in params_obj)) {
can_satisfy = false;
break;
}
}
if (can_satisfy) {
satisfiable.push({
pattern: pattern,
param_count: required_params.length
});
}
}
if (satisfiable.length === 0) {
return null;
}
// Sort by parameter count descending (most parameters first)
satisfiable.sort((a, b) => b.param_count - a.param_count);
return satisfiable[0].pattern;
}
/**
* Generate URL from route pattern by replacing parameters
*
* @param {string} pattern The route pattern (e.g., '/projects/:id')
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
* @private
*/
static _generate_url_from_pattern(pattern, params) {
// Extract required parameters from the pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check for required parameters
const missing = [];
for (const required of required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(`Required parameters [${missing.join(', ')}] are missing for portal route ${pattern}`);
}
// Build the URL by replacing parameters
let url = pattern;
const used_params = {};
for (const param_name of required_params) {
const value = params[param_name];
const encoded_value = encodeURIComponent(value);
url = url.replace(':' + param_name, encoded_value);
used_params[param_name] = true;
}
// Collect extra parameters for query string
const internal_params = ['_loader_title_hint'];
const query_params = {};
for (const key in params) {
if (!used_params[key] && !internal_params.includes(key)) {
query_params[key] = params[key];
}
}
// Append query string if there are extra parameters
if (Object.keys(query_params).length > 0) {
const query_string = Object.entries(query_params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += '?' + query_string;
}
return url;
}
/**
* Try to find a route pattern for a portal SPA action class
*
* @param {string} class_name The action class name
* @param {Object} params_obj The parameters for route selection
* @returns {string|null} The route pattern or null
* @private
*/
static _try_spa_action_route(class_name, params_obj) {
// Get all classes from manifest
const all_classes = Manifest.get_all_classes();
// Find the class by name
for (const class_info of all_classes) {
if (class_info.class_name === class_name) {
const class_object = class_info.class_object;
// Check if it's a SPA action with portal routes
if (typeof Spa_Action !== 'undefined' &&
class_object.prototype instanceof Spa_Action) {
// Get route patterns from decorator metadata
// Portal SPA actions use @route() decorator (stores in _spa_routes)
// and @portal_spa() decorator (sets _is_portal_spa = true)
const routes = class_object._spa_routes || [];
// Only match if this is a portal SPA action
if (routes.length > 0 && class_object._is_portal_spa) {
const selected = Rsx_Portal._select_best_route_pattern(routes, params_obj);
if (!selected) {
throw new Error(
`No suitable portal route found for SPA action ${class_name} with provided parameters. ` +
`Available routes: ${routes.join(', ')}`
);
}
return selected;
}
}
return null;
}
}
return null;
}
/**
* Define portal routes from bundled data
* Called by generated JavaScript in portal bundles
*
* @param {Object} routes Route definitions object
*/
static _define_routes(routes) {
for (const class_name in routes) {
if (!Rsx_Portal._routes[class_name]) {
Rsx_Portal._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx_Portal._routes[class_name][method_name] = routes[class_name][method_name];
}
}
}
/**
* Clear cached state (for testing)
*/
static _clear_cache() {
Rsx_Portal._is_portal = null;
Rsx_Portal._routes = {};
}
}

View File

@@ -23,46 +23,41 @@ use App\RSpade\Core\Models\User_Profile_Model;
* See: php artisan rsx:man acls
*/
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2026-01-29 08:25:50
* Table: users
*
* @property int $id
* @property int $login_user_id
* @property int $site_id
* @property mixed $first_name
* @property mixed $last_name
* @property mixed $phone
* @property int $role_id
* @property bool $is_enabled
* @property int $user_role_id
* @property mixed $email
* @property string $deleted_at
* @property string $created_at
* @property string $updated_at
* @property int $created_by
* @property int $updated_by
* @property int $deleted_by
* @property mixed $invite_code
* @property string $invite_accepted_at
* @property string $invite_expires_at
*
* @property-read string $role_id__label
* @property-read string $role_id__constant
*
* @method static array role_id__enum() Get all enum definitions with full metadata
* @method static array role_id__enum_select() Get selectable items for dropdowns
* @method static array role_id__enum_labels() Get simple id => label map
* @method static array role_id__enum_ids() Get array of all valid enum IDs
*
* _AUTO_GENERATED_
* @property integer $id
* @property integer $login_user_id
* @property integer $site_id
* @property string $first_name
* @property string $last_name
* @property string $phone
* @property integer $role_id
* @property boolean $is_enabled
* @property integer $user_role_id
* @property string $email
* @property \Carbon\Carbon $deleted_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property integer $created_by
* @property integer $updated_by
* @property integer $deleted_by
* @property string $invite_code
* @property \Carbon\Carbon $invite_accepted_at
* @property \Carbon\Carbon $invite_expires_at
* @method static mixed role_id_enum()
* @method static mixed role_id_enum_select()
* @method static mixed role_id_enum_ids()
* @property-read mixed $role_id_constant
* @property-read mixed $role_id_label
* @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_ Enum constants
*/
{
/** __AUTO_GENERATED: */
const ROLE_DEVELOPER = 100;
const ROLE_ROOT_ADMIN = 200;
const ROLE_SITE_OWNER = 300;
@@ -71,9 +66,6 @@ class User_Model extends Rsx_Site_Model_Abstract
const ROLE_USER = 600;
const ROLE_VIEWER = 700;
const ROLE_DISABLED = 800;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */
// =========================================================================

View File

@@ -11,44 +11,33 @@ use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
* and two-factor authentication via email or SMS.
*/
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2026-01-29 08:25:50
* Table: user_verifications
*
* @property int $id
* @property mixed $email
* @property mixed $verification_code
* @property int $verification_type_id
* @property string $verified_at
* @property string $expires_at
* @property string $created_at
* @property string $updated_at
* @property int $created_by
* @property int $updated_by
*
* @property-read string $verification_type_id__label
* @property-read string $verification_type_id__constant
*
* @method static array verification_type_id__enum() Get all enum definitions with full metadata
* @method static array verification_type_id__enum_select() Get selectable items for dropdowns
* @method static array verification_type_id__enum_labels() Get simple id => label map
* @method static array verification_type_id__enum_ids() Get array of all valid enum IDs
*
* _AUTO_GENERATED_
* @property integer $id
* @property string $email
* @property string $verification_code
* @property integer $verification_type_id
* @property \Carbon\Carbon $verified_at
* @property \Carbon\Carbon $expires_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property integer $created_by
* @property integer $updated_by
* @method static mixed verification_type_id_enum()
* @method static mixed verification_type_id_enum_select()
* @method static mixed verification_type_id_enum_ids()
* @property-read mixed $verification_type_id_constant
* @property-read mixed $verification_type_id_label
* @mixin \Eloquent
*/
class User_Verification_Model extends Rsx_Model_Abstract
{
/**
* _AUTO_GENERATED_ Enum constants
*/
{
/** __AUTO_GENERATED: */
const VERIFICATION_TYPE_EMAIL = 1;
const VERIFICATION_TYPE_SMS = 2;
const VERIFICATION_TYPE_EMAIL_RECOVERY = 3;
const VERIFICATION_TYPE_SMS_RECOVERY = 4;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */
/**

View File

@@ -0,0 +1,412 @@
<?php
namespace App\RSpade\Core\Portal;
use Illuminate\Http\Request;
use App\RSpade\Core\Dispatch\AssetHandler;
use App\RSpade\Core\Dispatch\RouteResolver;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Portal\Portal_Session;
use App\RSpade\Core\Portal\Rsx_Portal;
/**
* Portal_Dispatcher - Handles dispatch for client portal requests
*
* Similar to Dispatcher but for portal routes:
* - Uses portal_routes from manifest instead of routes
* - Uses Portal_Session instead of Session
* - Calls portal.php hooks instead of main.php
* - Strips portal prefix in development mode
*/
class Portal_Dispatcher
{
/**
* Check if URL is a portal request and should be handled by portal dispatcher
*
* Detection logic:
* 1. If portal domain configured: check if request host matches
* 2. If no domain: check if URL starts with portal prefix
*
* @param string $url Request URL
* @param Request|null $request Optional request object
* @return bool True if this is a portal request
*/
public static function is_portal_request(string $url, ?Request $request = null): bool
{
return Rsx_Portal::is_portal_request();
}
/**
* Get the normalized portal URL (with prefix stripped if applicable)
*
* @param string $url Original request URL
* @return string URL normalized for portal route matching
*/
public static function normalize_portal_url(string $url): string
{
// If not using dedicated domain, strip the prefix
if (!Rsx_Portal::has_dedicated_domain()) {
$prefix = Rsx_Portal::get_prefix();
if (str_starts_with($url, $prefix)) {
$url = substr($url, strlen($prefix)) ?: '/';
}
}
return $url;
}
/**
* Dispatch a portal request to the appropriate handler
*
* @param string $url The URL to dispatch (will be normalized)
* @param string $method HTTP method (GET, POST, etc.)
* @param array $extra_params Additional parameters to merge
* @param Request|null $request Optional request object
* @return mixed Response from handler, or null if no route found
*/
public static function dispatch(string $url, string $method = 'GET', array $extra_params = [], ?Request $request = null)
{
console_debug('PORTAL', "Portal dispatch started for: {$method} {$url}");
// Handle dev auth for rsx:debug testing (development only)
static::__handle_dev_auth($request ?? request(), $url);
// Normalize the URL (strip prefix if needed)
$normalized_url = static::normalize_portal_url($url);
console_debug('PORTAL', "Normalized URL: {$normalized_url}");
// Initialize manifest
Manifest::init();
$request = $request ?? request();
// Check if this is an asset request
if (AssetHandler::is_asset_request($normalized_url)) {
console_debug('PORTAL', "Serving static asset: {$normalized_url}");
return AssetHandler::serve($normalized_url, $request);
}
// HEAD requests should be treated as GET
$original_method = $method;
$route_method = ($method === 'HEAD') ? 'GET' : $method;
if ($method === 'HEAD' && $request) {
$request->setMethod('GET');
}
// Find matching portal route
console_debug('PORTAL', "Looking for portal route: {$normalized_url}, method: {$route_method}");
$route_match = static::__find_portal_route($normalized_url, $route_method);
if (!$route_match) {
console_debug('PORTAL', "No portal route found for: {$normalized_url}");
// Call unhandled_route hook from Portal_Main
$unhandled_response = static::__call_portal_unhandled_route($request, $extra_params);
if ($unhandled_response !== null) {
return $unhandled_response;
}
return null;
}
console_debug('PORTAL', "Found portal route: {$route_match['class']}::{$route_match['method']}");
// Call pre_dispatch hooks with handler info
$pre_dispatch_params = array_merge($route_match['params'], [
'_handler' => $route_match['class'] . '::' . $route_match['method'],
'_class' => $route_match['class'],
'_method' => $route_match['method'],
]);
$pre_dispatch_response = static::__call_portal_pre_dispatch($request, $pre_dispatch_params);
if ($pre_dispatch_response !== null) {
return $pre_dispatch_response;
}
// Track current controller/action for portal context
Rsx_Portal::_set_current_controller_action(
$route_match['class'],
$route_match['method'],
$route_match['type'] ?? 'standard'
);
// Load controller class (autoloaded by framework)
$controller_class = $route_match['class'];
// Call controller pre_dispatch if it exists
if (method_exists($controller_class, 'pre_dispatch')) {
$controller_response = $controller_class::pre_dispatch($request, $route_match['params']);
if ($controller_response !== null) {
return $controller_response;
}
}
// Execute the action
$action_method = $route_match['method'];
if (!method_exists($controller_class, $action_method)) {
throw new \RuntimeException("Portal action method not found: {$controller_class}::{$action_method}");
}
console_debug('PORTAL', "Executing: {$controller_class}::{$action_method}");
$result = $controller_class::$action_method($request, $route_match['params']);
// Build response
return static::__build_response($result, $original_method, $request);
}
/**
* Find matching portal route
*
* @param string $url URL to match
* @param string $method HTTP method
* @return array|null Route match data or null
*/
protected static function __find_portal_route(string $url, string $method): ?array
{
$manifest = Manifest::get_full_manifest();
$portal_routes = $manifest['data']['portal_routes'] ?? [];
if (empty($portal_routes)) {
return null;
}
// Sort routes for deterministic matching
$sorted_routes = [];
foreach ($portal_routes as $pattern => $route_data) {
$sorted_routes[] = array_merge($route_data, ['pattern' => $pattern]);
}
// Sort by specificity (longer patterns first, static before dynamic)
// Catch-all routes (/*) should always be matched last
usort($sorted_routes, function ($a, $b) {
// Catch-all routes should be last
$a_catchall = str_contains($a['pattern'], '*');
$b_catchall = str_contains($b['pattern'], '*');
if ($a_catchall !== $b_catchall) {
return $a_catchall ? 1 : -1; // Catch-all routes last
}
// Count path segments
$a_segments = count(explode('/', trim($a['pattern'], '/')));
$b_segments = count(explode('/', trim($b['pattern'], '/')));
if ($a_segments !== $b_segments) {
return $b_segments <=> $a_segments; // More segments first
}
// Count dynamic segments (: params and {} params)
$a_dynamic = substr_count($a['pattern'], ':') + substr_count($a['pattern'], '{');
$b_dynamic = substr_count($b['pattern'], ':') + substr_count($b['pattern'], '{');
return $a_dynamic <=> $b_dynamic; // Fewer dynamic first
});
// Try to match each route
foreach ($sorted_routes as $route_data) {
$pattern = $route_data['pattern'];
$route_methods = $route_data['methods'] ?? ['GET'];
// Check HTTP method
if (!in_array($method, $route_methods)) {
continue;
}
// Try to match the pattern
$params = RouteResolver::match($url, $pattern);
if ($params !== false) {
return [
'class' => $route_data['class'],
'method' => $route_data['method'],
'params' => $params,
'pattern' => $pattern,
'type' => $route_data['type'] ?? 'portal',
'require' => $route_data['require'] ?? [],
];
}
}
return null;
}
/**
* Call Portal_Main::pre_dispatch if it exists
*
* @param Request $request
* @param array $params
* @return mixed|null Response or null to continue
*/
protected static function __call_portal_pre_dispatch(Request $request, array $params)
{
// Find classes extending Portal_Main_Abstract via manifest
$portal_main_classes = Manifest::php_get_extending('Portal_Main_Abstract');
foreach ($portal_main_classes as $portal_main_class) {
if (isset($portal_main_class['fqcn']) && $portal_main_class['fqcn']) {
$class_name = $portal_main_class['fqcn'];
if (method_exists($class_name, 'pre_dispatch')) {
console_debug('PORTAL', "Calling {$class_name}::pre_dispatch");
$result = $class_name::pre_dispatch($request, $params);
if ($result !== null) {
return $result;
}
}
}
}
return null;
}
/**
* Call Portal_Main::unhandled_route if it exists
*
* @param Request $request
* @param array $params
* @return mixed|null Response or null for default 404
*/
protected static function __call_portal_unhandled_route(Request $request, array $params)
{
// Find classes extending Portal_Main_Abstract via manifest
$portal_main_classes = Manifest::php_get_extending('Portal_Main_Abstract');
foreach ($portal_main_classes as $portal_main_class) {
if (isset($portal_main_class['fqcn']) && $portal_main_class['fqcn']) {
$class_name = $portal_main_class['fqcn'];
if (method_exists($class_name, 'unhandled_route')) {
console_debug('PORTAL', "Calling {$class_name}::unhandled_route");
$result = $class_name::unhandled_route($request, $params);
if ($result !== null) {
return $result;
}
}
}
}
return null;
}
/**
* Build appropriate response from handler result
*
* @param mixed $result Handler result
* @param string $method Original HTTP method
* @param Request $request
* @return mixed
*/
protected static function __build_response($result, string $method, Request $request)
{
// If already a Response, return as-is
if ($result instanceof \Illuminate\Http\Response ||
$result instanceof \Symfony\Component\HttpFoundation\Response) {
// For HEAD requests, strip the body
if ($method === 'HEAD') {
$result->setContent('');
}
return $result;
}
// If a View object, render it to a response
if ($result instanceof \Illuminate\Contracts\View\View) {
$response = response($result->render());
if ($method === 'HEAD') {
$response->setContent('');
}
return $response;
}
// If array, return as JSON
if (is_array($result)) {
$response = response()->json($result);
if ($method === 'HEAD') {
$response->setContent('');
}
return $response;
}
// If string, return as HTML
if (is_string($result)) {
$response = response($result);
if ($method === 'HEAD') {
$response->setContent('');
}
return $response;
}
// If null, let caller handle (likely 404)
return $result;
}
/**
* Handle dev auth headers for rsx:debug testing
*
* This allows rsx:debug to authenticate as any portal user in development.
* The token is validated using APP_KEY to ensure only authorized requests.
*
* @param Request $request
* @param string $url
* @return void
*/
protected static function __handle_dev_auth(Request $request, string $url): void
{
// Only in non-production environments
if (app()->environment('production')) {
return;
}
// Check for portal dev auth header
$portal_user_id = $request->header('X-Dev-Auth-Portal-User-Id');
if (!$portal_user_id) {
return;
}
$token = $request->header('X-Dev-Auth-Token');
if (!$token) {
console_debug('PORTAL', 'Dev auth: Missing token header');
return;
}
// Validate the token
$app_key = config('app.key');
if (!$app_key) {
console_debug('PORTAL', 'Dev auth: APP_KEY not configured');
return;
}
// Normalize URL for token validation (strip /_portal prefix)
$normalized_url = static::normalize_portal_url($url);
// Recreate the expected token payload
$expected_payload = json_encode([
'url' => $normalized_url,
'user_id' => (int) $portal_user_id,
'portal' => true,
]);
$expected_token = hash_hmac('sha256', $expected_payload, $app_key);
if (!hash_equals($expected_token, $token)) {
console_debug('PORTAL', 'Dev auth: Token validation failed');
return;
}
// Token is valid - authenticate as the portal user
$portal_user = Portal_User_Model::find((int) $portal_user_id);
if (!$portal_user) {
console_debug('PORTAL', "Dev auth: Portal user not found: {$portal_user_id}");
return;
}
// Log the user in using the portal user's site_id
Portal_Session::set_portal_user_id((int) $portal_user_id, $portal_user->site_id);
console_debug('PORTAL', "Dev auth: Logged in as portal user {$portal_user_id}");
}
}

View File

@@ -0,0 +1,61 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Portal;
use Illuminate\Http\Request;
/**
* Portal_Main_Abstract - Base class for portal-wide middleware-style hooks
*
* Similar to Main_Abstract but specifically for portal requests.
* Application extends this via /rsx/portal.php as Portal_Main.
*
* Portal_Main provides hooks that run BEFORE individual controller hooks:
* 1. Portal_Main::init() - Called once during portal bootstrap
* 2. Portal_Main::pre_dispatch() - Called before any portal route dispatch
* 3. Portal_Main::unhandled_route() - Called when no portal route matches
*/
abstract class Portal_Main_Abstract
{
/**
* Initialize the Portal_Main class
*
* Called once during portal bootstrap (when a portal request is detected)
*
* @return void
*/
abstract public static function init();
/**
* Pre-dispatch hook for portal requests
*
* Called before any portal route dispatch. If a non-null value is returned,
* dispatch is halted and that value is returned as the response.
*
* Typical uses:
* - Require portal authentication
* - Load portal user data
* - Set portal-specific headers
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null to continue, or a response to halt dispatch
*/
abstract public static function pre_dispatch(Request $request, array $params);
/**
* Unhandled route hook for portal requests
*
* Called when no portal route matches the request
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null for default 404, or a response to handle
*/
abstract public static function unhandled_route(Request $request, array $params);
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\RSpade\Core\Portal;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for building portal routes index from #[Portal_Route] attributes
*
* Similar to Route_ManifestSupport but for portal-specific routes.
* Portal routes are stored separately in the manifest and handled by
* the portal dispatcher.
*
* Usage in controllers:
* ```php
* #[Portal_Route('/dashboard')]
* public static function index(Request $request, array $params = []) { ... }
*
* #[Portal_Route('/projects/:id', methods: ['GET'])]
* public static function view(Request $request, array $params = []) { ... }
* ```
*/
class Portal_Route_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Portal Routes';
}
/**
* Process the manifest and build portal routes index
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize portal routes structures
if (!isset($manifest_data['data']['portal_routes'])) {
$manifest_data['data']['portal_routes'] = [];
}
if (!isset($manifest_data['data']['portal_routes_by_target'])) {
$manifest_data['data']['portal_routes_by_target'] = [];
}
// Look for Portal_Route attributes
$files = $manifest_data['data']['files'];
$portal_route_classes = [];
foreach ($files as $file => $metadata) {
// Check public static method attributes for Portal_Route
if (isset($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
if (isset($method_data['attributes'])) {
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
// Check if this is a Portal_Route attribute
if (str_ends_with($attr_name, '\\Portal_Route') || $attr_name === 'Portal_Route') {
$portal_route_classes[] = [
'file' => $file,
'class' => $metadata['class'] ?? null,
'fqcn' => $metadata['fqcn'] ?? null,
'method' => $method_name,
'type' => 'method',
'instances' => $attr_instances,
];
}
}
}
}
}
}
foreach ($portal_route_classes as $item) {
if ($item['type'] === 'method') {
foreach ($item['instances'] as $route_args) {
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
$name = $route_args[2] ?? ($route_args['name'] ?? null);
if ($pattern) {
// Ensure pattern starts with /
if ($pattern[0] !== '/') {
$pattern = '/' . $pattern;
}
// Type is always 'portal' for routes with #[Portal_Route] attribute
$type = 'portal';
// Extract Auth attributes for this method (portal-specific auth would use Portal_Auth or similar)
$require_attrs = [];
$file_metadata = $files[$item['file']] ?? null;
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Portal_Auth'])) {
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Portal_Auth'];
}
// Check for duplicate portal route definition
if (isset($manifest_data['data']['portal_routes'][$pattern])) {
$existing = $manifest_data['data']['portal_routes'][$pattern];
$existing_location = "{$existing['class']}::{$existing['method']} in {$existing['file']}";
throw new \RuntimeException(
"Duplicate portal route definition: {$pattern}\n" .
" Already defined: {$existing_location}\n" .
" Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}"
);
}
// Store route with flat structure (for portal dispatcher)
$route_data = [
'methods' => array_map('strtoupper', (array) $methods),
'type' => $type,
'class' => $item['fqcn'] ?? $item['class'],
'method' => $item['method'],
'name' => $name,
'file' => $item['file'],
'require' => $require_attrs,
'pattern' => $pattern,
];
$manifest_data['data']['portal_routes'][$pattern] = $route_data;
// Also store by target for URL generation
$target = $item['class'] . '::' . $item['method'];
if (!isset($manifest_data['data']['portal_routes_by_target'][$target])) {
$manifest_data['data']['portal_routes_by_target'][$target] = [];
}
$manifest_data['data']['portal_routes_by_target'][$target][] = $route_data;
}
}
}
}
// Sort routes alphabetically by path
ksort($manifest_data['data']['portal_routes']);
ksort($manifest_data['data']['portal_routes_by_target']);
}
}

View File

@@ -0,0 +1,729 @@
<?php
namespace App\RSpade\Core\Portal;
use App\RSpade\Core\Database\Models\Rsx_System_Model_Abstract;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Models\Site_Model;
use App\RSpade\Core\Portal\Rsx_Portal;
use App\RSpade\Core\Session\User_Agent;
/**
* Portal_Session - handles authentication for external portal users
*
* This class serves dual purposes:
* 1. As a Laravel Eloquent model for the portal_sessions table
* 2. As a static interface for portal session management
*
* Key differences from Session:
* - Site-scoped (no multi-site switching)
* - Simpler model (no experience_id)
* - Uses different cookie name ('rsx_portal')
* - References Portal_User_Model instead of Login_User_Model
*
* @FILE-SUBCLASS-01-EXCEPTION Class intentionally named Portal_Session instead of Portal_Session_Model
*
* @property int $id
* @property int $site_id
* @property int $portal_user_id
* @property string $session_token
* @property string $csrf_token
* @property string $ip_address
* @property string $user_agent
* @property \Carbon\Carbon $last_active
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class Portal_Session extends Rsx_System_Model_Abstract
{
// Enum definitions (required by abstract parent)
public static $enums = [];
// Cookie name for portal sessions (different from internal 'rsx')
const COOKIE_NAME = 'rsx_portal';
// Static session management properties
private static $_session = null;
private static $_site = null;
private static $_portal_user = null;
private static $_session_token = null;
private static $_has_init = false;
private static $_has_activate = false;
private static $_has_set_cookie = false;
// CLI mode properties (static-only, no database)
private static $_cli_site_id = null;
private static $_cli_portal_user_id = null;
/**
* The table associated with the model
* @var string
*/
protected $table = 'portal_sessions';
/**
* The attributes that should be cast
* @var array
*/
protected $casts = [
'site_id' => 'integer',
'portal_user_id' => 'integer',
'last_active' => 'datetime',
];
/**
* Columns that should never be exported to JavaScript
* @var array
*/
protected $neverExport = [
'session_token',
'csrf_token',
'ip_address',
];
/**
* Check if running in CLI mode
* @return bool
*/
private static function __is_cli(): bool
{
return php_sapi_name() === 'cli';
}
/**
* Initialize session from cookie
* Loads existing session but does not create new one
* In CLI mode: does nothing
* @return void
*/
public static function init(): void
{
if (self::$_has_init) {
return;
}
self::$_has_init = true;
// CLI mode: do nothing
if (self::__is_cli()) {
return;
}
Manifest::init();
// Try to get session token from cookie
$session_token = $_COOKIE[self::COOKIE_NAME] ?? null;
if (empty($session_token)) {
self::$_session = null;
return;
}
// Load session
$session = static::where('session_token', $session_token)->first();
if (!$session) {
self::$_session = null;
return;
}
// Update last activity
static::where('id', $session->id)->update(['last_active' => now()]);
// Reload the session to ensure we have the latest version
$session = static::find($session->id);
self::$_session_token = $session_token;
self::$_session = $session;
self::_set_cookie();
}
/**
* Activate session - creates new one if needed
* In CLI mode: does nothing
* @param int $site_id Required site ID for new sessions
* @return void
*/
private static function __activate(int $site_id = 0): void
{
if (self::$_has_activate) {
return;
}
self::$_has_activate = true;
// CLI mode: do nothing
if (self::__is_cli()) {
return;
}
self::init();
// If no session exists, create one
if (empty(self::$_session)) {
if ($site_id === 0) {
throw new \RuntimeException('Portal session requires site_id for activation');
}
// Generate cryptographically secure token
self::$_session_token = bin2hex(random_bytes(32));
// Generate CSRF token
$csrf_token = bin2hex(random_bytes(32));
$session = new static();
$session->session_token = self::$_session_token;
$session->csrf_token = $csrf_token;
$session->ip_address = self::__get_client_ip();
$session->user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
$session->last_active = now();
$session->site_id = $site_id;
$session->portal_user_id = null;
$session->save();
self::$_session = $session;
self::_set_cookie();
}
}
/**
* Set the session cookie with security flags
* In CLI mode: does nothing
* @return void
*/
private static function _set_cookie(): void
{
if (self::$_has_set_cookie) {
return;
}
self::$_has_set_cookie = true;
// CLI mode: do nothing
if (self::__is_cli()) {
return;
}
$lifetime_days = config('rsx.portal.session_lifetime_days', 30);
// Set cookie with security flags
setcookie(self::COOKIE_NAME, self::$_session_token, [
'expires' => time() + ($lifetime_days * 86400),
'path' => '/',
'domain' => '', // Current domain only
'secure' => true, // HTTPS only
'httponly' => true, // No JavaScript access
'samesite' => 'Lax', // CSRF protection
]);
}
/**
* Get client IP address, handling proxies
* In CLI mode: returns "CLI"
* @return string
*/
private static function __get_client_ip(): string
{
// CLI mode: return "CLI"
if (self::__is_cli()) {
return 'CLI';
}
// Check for forwarded IP (when behind proxy/CDN)
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return $_SERVER['HTTP_X_REAL_IP'];
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
/**
* Reset/logout the current session
* @return void
*/
public static function reset(): void
{
self::init();
if (!empty(self::$_session)) {
self::$_session->delete();
}
self::$_session = null;
self::$_site = null;
self::$_portal_user = null;
self::$_has_init = false;
self::$_has_activate = false;
self::$_has_set_cookie = false;
// Clear cookie
setcookie(self::COOKIE_NAME, '', [
'expires' => time() - 3600,
'path' => '/',
'domain' => '',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
/**
* Get site ID for current session
* In CLI mode: returns static CLI property
* If no session exists, detects site from domain/config
* @return int
*/
public static function get_site_id(): int
{
// CLI mode: return static property
if (self::__is_cli()) {
return self::$_cli_site_id ?? 0;
}
self::init();
// If session exists, use its site_id
if (!empty(self::$_session)) {
return self::$_session->site_id ?? 0;
}
// No session - detect site from context
return self::detect_site_id();
}
/**
* Detect site ID from request context (domain or config default)
* Used when no session exists yet (e.g., login page)
* @return int
*/
public static function detect_site_id(): int
{
// Check for portal domain in config that matches current host
$portal_domain = Rsx_Portal::get_domain();
if (!empty($portal_domain)) {
$request_host = $_SERVER['HTTP_HOST'] ?? '';
if (strtolower($request_host) === strtolower($portal_domain)) {
// In production, could look up site by portal_domain
// For now, use default site
return config('rsx.portal.default_site_id', 1);
}
}
// Development mode or no specific domain - use default site
return config('rsx.portal.default_site_id', 1);
}
/**
* Get site model for current session
* @return Site_Model|null
*/
public static function get_site()
{
$site_id = self::get_site_id();
if ($site_id === 0) {
return null;
}
if (empty(self::$_site)) {
self::$_site = Site_Model::find($site_id);
}
return self::$_site;
}
/**
* Get portal user ID for current session
* In CLI mode: returns static CLI property
* @return int|null
*/
public static function get_portal_user_id()
{
// CLI mode: return static property
if (self::__is_cli()) {
return self::$_cli_portal_user_id;
}
self::init();
if (empty(self::$_session)) {
return null;
}
return self::$_session->portal_user_id;
}
/**
* Check if portal user is logged in
* @return bool
*/
public static function is_logged_in(): bool
{
return !empty(self::get_portal_user_id());
}
/**
* Get portal user model for current session
* @return \Portal_User_Model|null
*/
public static function get_user()
{
$portal_user_id = self::get_portal_user_id();
if (empty($portal_user_id)) {
return null;
}
if (empty(self::$_portal_user)) {
self::$_portal_user = \Portal_User_Model::find($portal_user_id);
}
return self::$_portal_user;
}
/**
* Alias for get_user() to match naming convention
* @return \Portal_User_Model|null
*/
public static function get_portal_user()
{
return self::get_user();
}
/**
* Get current session model (creates if needed)
* @param int $site_id Required for new sessions
* @return Portal_Session
*/
public static function get_session(int $site_id = 0): Portal_Session
{
self::__activate($site_id);
return self::$_session;
}
/**
* Get current session ID (creates session if needed)
* @param int $site_id Required for new sessions
* @return int
*/
public static function get_session_id(int $site_id = 0): int
{
self::__activate($site_id);
return self::$_session->id;
}
/**
* Get CSRF token for current session
* @return string|null
*/
public static function get_csrf_token(): ?string
{
self::init();
if (empty(self::$_session)) {
return null;
}
return self::$_session->csrf_token;
}
/**
* Verify CSRF token
* @param string $token
* @return bool
*/
public static function verify_csrf_token(string $token): bool
{
self::init();
if (empty(self::$_session)) {
return false;
}
// Use constant-time comparison
return hash_equals(self::$_session->csrf_token, $token);
}
/**
* Logout current portal user
* @return void
*/
public static function logout(): void
{
self::set_portal_user_id(null);
}
/**
* Set portal user ID for current session (login/logout)
* In CLI mode: sets static CLI property only, no database
* @param int|null $portal_user_id Portal user ID, or null to logout
* @param int $site_id Required for new sessions when logging in
* @return void
*/
public static function set_portal_user_id(?int $portal_user_id, int $site_id = 0): void
{
// Logout if null/0
if (empty($portal_user_id)) {
// CLI mode: clear static property only
if (self::__is_cli()) {
self::$_cli_portal_user_id = null;
self::$_portal_user = null;
self::$_site = null;
return;
}
self::init();
if (!empty(self::$_session)) {
self::$_session->portal_user_id = null;
self::$_session->save();
}
self::$_portal_user = null;
self::$_site = null;
return;
}
// CLI mode: set static property only
if (self::__is_cli()) {
self::$_cli_portal_user_id = $portal_user_id;
self::$_portal_user = null;
self::$_site = null;
return;
}
self::__activate($site_id);
// Regenerate session token and CSRF token on login (prevent session fixation)
$new_token = bin2hex(random_bytes(32));
$new_csrf = bin2hex(random_bytes(32));
self::$_session->session_token = $new_token;
self::$_session->csrf_token = $new_csrf;
self::$_session->portal_user_id = $portal_user_id;
self::$_session->save();
self::$_session_token = $new_token;
self::$_has_set_cookie = false; // Force new cookie
self::_set_cookie();
// Clear cached portal_user/site
self::$_portal_user = null;
self::$_site = null;
// Update portal user's last login timestamp
$portal_user = self::get_user();
if ($portal_user) {
$portal_user->last_login = now();
$portal_user->save();
}
}
/**
* Check if a session exists
* In CLI mode: returns true if site_id or user_id is set
* In web mode: returns true if session record exists
* @return bool
*/
public static function has_session(): bool
{
// CLI mode: check if site_id or user_id is set
if (self::__is_cli()) {
return self::$_cli_site_id !== null || self::$_cli_portal_user_id !== null;
}
// Web mode: init and check if session exists
self::init();
return !empty(self::$_session);
}
/**
* Get session by token (for API/external access)
* @param string $token
* @return Portal_Session|null
*/
public static function find_by_token(string $token)
{
return static::where('session_token', $token)->first();
}
/**
* Clean up expired sessions (garbage collection)
* @param int $days_until_expiry
* @return int Number of sessions deleted
*/
public static function cleanup_expired(int $days_until_expiry = 30): int
{
return static::where('last_active', '<', now()->subDays($days_until_expiry))
->delete();
}
/**
* Relationship: Portal User
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function portal_user()
{
return $this->belongsTo(\Portal_User_Model::class, 'portal_user_id');
}
/**
* Relationship: Site
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function site()
{
return $this->belongsTo(Site_Model::class, 'site_id');
}
// =========================================================================
// CLI MODE SETTERS
// =========================================================================
/**
* Set site ID in CLI mode
* @param int $site_id
* @return void
*/
public static function cli_set_site_id(int $site_id): void
{
self::$_cli_site_id = $site_id;
self::$_site = null;
}
/**
* Set portal user ID in CLI mode
* @param int $portal_user_id
* @return void
*/
public static function cli_set_portal_user_id(int $portal_user_id): void
{
self::$_cli_portal_user_id = $portal_user_id;
self::$_portal_user = null;
}
// =========================================================================
// SESSION MANAGEMENT METHODS
// =========================================================================
/**
* Get all active sessions for a portal user
* Returns formatted session info including device parsing
*
* @param int|null $portal_user_id If null, uses current logged-in user
* @return array Array of session info
*/
public static function get_sessions_for_user(?int $portal_user_id = null): array
{
if ($portal_user_id === null) {
$portal_user_id = self::get_portal_user_id();
}
if (empty($portal_user_id)) {
return [];
}
$current_session_id = self::has_session() ? self::$_session?->id : null;
return static::where('portal_user_id', $portal_user_id)
->orderBy('last_active', 'desc')
->get()
->map(function ($session) use ($current_session_id) {
$parsed_ua = User_Agent::parse($session->user_agent);
return [
'id' => $session->id,
'ip_address' => $session->ip_address,
'user_agent' => $session->user_agent,
'user_agent_parsed' => $parsed_ua,
'device_summary' => $parsed_ua['summary'],
'last_active' => $session->last_active,
'created_at' => $session->created_at,
'is_current' => $session->id === $current_session_id,
];
})
->toArray();
}
/**
* Terminate a specific session by ID
* Cannot terminate the current session (use logout() instead)
*
* @param int $session_id
* @return bool True if session was terminated, false if not found or is current
*/
public static function terminate_session(int $session_id): bool
{
self::init();
// Don't allow terminating current session
if (self::$_session && self::$_session->id === $session_id) {
return false;
}
$affected = static::where('id', $session_id)->delete();
return $affected > 0;
}
/**
* Terminate all sessions for the current user except the current one
*
* @return int Number of sessions terminated
*/
public static function terminate_all_other_sessions(): int
{
self::init();
$portal_user_id = self::get_portal_user_id();
if (empty($portal_user_id)) {
return 0;
}
$query = static::where('portal_user_id', $portal_user_id);
// Exclude current session if we have one
if (self::$_session) {
$query->where('id', '!=', self::$_session->id);
}
return $query->delete();
}
/**
* Terminate all sessions for a specific portal user
* Useful for admin actions or password changes
*
* @param int $portal_user_id
* @param int|null $except_session_id Optional session ID to exclude
* @return int Number of sessions terminated
*/
public static function terminate_all_sessions_for_user(int $portal_user_id, ?int $except_session_id = null): int
{
$query = static::where('portal_user_id', $portal_user_id);
if ($except_session_id !== null) {
$query->where('id', '!=', $except_session_id);
}
return $query->delete();
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\RSpade\Core\Portal;
use RuntimeException;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for extracting Portal Spa route metadata from Spa_Action classes
*
* Similar to Spa_ManifestSupport but for portal-specific SPA routes.
* Portal SPA actions use the @portal_spa() decorator instead of @spa()
* and their routes are registered in portal_routes instead of routes.
*
* Usage in JS action:
* ```javascript
* @route('/dashboard')
* @layout('Portal_Layout')
* @portal_spa('Portal_Spa_Controller::index')
* class Portal_Dashboard_Action extends Spa_Action {
* // ...
* }
* ```
*/
class Portal_Spa_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Portal Spa Routes';
}
/**
* Process the manifest and build Portal Spa routes index
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize portal routes structures if not already set
if (!isset($manifest_data['data']['portal_routes'])) {
$manifest_data['data']['portal_routes'] = [];
}
if (!isset($manifest_data['data']['portal_routes_by_target'])) {
$manifest_data['data']['portal_routes_by_target'] = [];
}
// Get all files to look up PHP controller metadata
$files = $manifest_data['data']['files'];
// Get all JavaScript classes extending Spa_Action
$action_classes = Manifest::js_get_extending('Spa_Action');
foreach ($action_classes as $class_name => $action_metadata) {
// Extract decorator metadata
$decorators = $action_metadata['decorators'] ?? [];
// Parse decorators into route configuration
$route_info = static::_parse_decorators($decorators);
// Skip if no @portal_spa decorator (this is a regular SPA action)
if (empty($route_info['portal_spa_controller'])) {
continue;
}
// Skip if no route decorator found
if (empty($route_info['routes'])) {
continue;
}
// Find the PHP controller file and metadata
$php_controller_class = $route_info['portal_spa_controller'];
$php_controller_method = $route_info['portal_spa_method'];
$php_controller_file = null;
$php_controller_fqcn = null;
// Search for the controller in the manifest
foreach ($files as $file => $metadata) {
if (($metadata['class'] ?? null) === $php_controller_class || ($metadata['fqcn'] ?? null) === $php_controller_class) {
$php_controller_file = $file;
$php_controller_fqcn = $metadata['fqcn'] ?? $metadata['class'];
break;
}
}
if (!$php_controller_file) {
throw new RuntimeException(
"Portal Spa action '{$class_name}' references unknown controller '{$php_controller_class}'.\n" .
"The @portal_spa decorator must reference a valid PHP controller class.\n" .
"File: {$action_metadata['file']}"
);
}
// Build complete route metadata for each route pattern
foreach ($route_info['routes'] as $route_pattern) {
// Ensure pattern starts with /
if ($route_pattern[0] !== '/') {
$route_pattern = '/' . $route_pattern;
}
// Check for duplicate portal route definition
if (isset($manifest_data['data']['portal_routes'][$route_pattern])) {
$existing = $manifest_data['data']['portal_routes'][$route_pattern];
$existing_type = $existing['type'] ?? 'portal';
$existing_location = $existing_type === 'portal_spa'
? "Portal Spa action {$existing['js_action_class']} in {$existing['file']}"
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
throw new RuntimeException(
"Duplicate portal route definition: {$route_pattern}\n" .
" Already defined: {$existing_location}\n" .
" Conflicting: Portal Spa action {$class_name} in {$action_metadata['file']}"
);
}
// Store route with unified structure (for portal dispatcher)
$route_data = [
'methods' => ['GET'], // Spa routes are always GET
'type' => 'portal_spa',
'class' => $php_controller_fqcn,
'method' => $php_controller_method,
'name' => null,
'file' => $php_controller_file,
'require' => [],
'js_action_class' => $class_name,
'pattern' => $route_pattern,
];
$manifest_data['data']['portal_routes'][$route_pattern] = $route_data;
// Also store by target for URL generation (group multiple routes per action class)
$target = $class_name; // For SPA, target is the JS action class name
if (!isset($manifest_data['data']['portal_routes_by_target'][$target])) {
$manifest_data['data']['portal_routes_by_target'][$target] = [];
}
$manifest_data['data']['portal_routes_by_target'][$target][] = $route_data;
}
}
}
/**
* Parse decorator metadata into route configuration
*
* @param array $decorators Array of decorator data from manifest
* @return array Parsed route configuration
*/
private static function _parse_decorators(array $decorators): array
{
$config = [
'routes' => [],
'layout' => null,
'portal_spa_controller' => null,
'portal_spa_method' => null,
];
foreach ($decorators as $decorator) {
[$name, $args] = $decorator;
switch ($name) {
case 'route':
// @route('/path') - args is array with single string
if (!empty($args[0])) {
$config['routes'][] = $args[0];
}
break;
case 'layout':
// @layout('Layout_Name') - args is array with single string
if (!empty($args[0])) {
$config['layout'] = $args[0];
}
break;
case 'portal_spa':
// @portal_spa('Controller::method') - args is array with single string
if (!empty($args[0])) {
$parts = explode('::', $args[0]);
if (count($parts) === 2) {
$config['portal_spa_controller'] = $parts[0];
$config['portal_spa_method'] = $parts[1];
}
}
break;
}
}
return $config;
}
}

View File

@@ -0,0 +1,481 @@
<?php
/**
* @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
*/
namespace App\RSpade\Core\Portal;
use RuntimeException;
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
use App\RSpade\Core\Manifest\Manifest;
/**
* Portal utility class
*
* Provides static utility methods for the client portal including
* route generation and portal context detection.
*
* Key differences from Rsx:
* - Route() prepends portal domain/prefix
* - Portal-specific context detection
* - Simpler (no event system, fewer utilities)
*/
class Rsx_Portal
{
/**
* URL prefix for portal when no dedicated domain configured
*/
public const URL_PREFIX = '/_portal';
/**
* Whether we're currently in a portal request context
* @var bool|null
*/
protected static ?bool $_is_portal_request = null;
/**
* Current portal controller being executed
* @var string|null
*/
protected static ?string $current_controller = null;
/**
* Current portal action being executed
* @var string|null
*/
protected static ?string $current_action = null;
/**
* Current portal route type ('spa' or 'standard')
* @var string|null
*/
protected static ?string $current_route_type = null;
// =========================================================================
// Configuration Methods
// =========================================================================
/**
* Get the configured portal domain
*
* @return string|null Domain if configured, null otherwise
*/
public static function get_domain(): ?string
{
return config('rsx.portal.domain');
}
/**
* Get the portal URL prefix (used when no domain configured)
*
* @return string The prefix, defaults to '/_portal'
*/
public static function get_prefix(): string
{
return config('rsx.portal.prefix', self::URL_PREFIX);
}
/**
* Check if portal is using a dedicated domain (vs URL prefix)
*
* @return bool True if dedicated domain is configured
*/
public static function has_dedicated_domain(): bool
{
return !empty(self::get_domain());
}
// =========================================================================
// Portal Request Context Detection
// =========================================================================
/**
* Check if current request is a portal request
*
* Detection logic:
* 1. If portal domain configured: check if request host matches
* 2. If no domain: check if URL starts with portal prefix
*
* @return bool True if this is a portal request
*/
public static function is_portal_request(): bool
{
if (self::$_is_portal_request !== null) {
return self::$_is_portal_request;
}
// CLI mode: default to false (can be overridden)
if (php_sapi_name() === 'cli') {
self::$_is_portal_request = false;
return false;
}
$portal_domain = self::get_domain();
if (!empty($portal_domain)) {
// Domain-based detection
$request_host = $_SERVER['HTTP_HOST'] ?? '';
self::$_is_portal_request = (strtolower($request_host) === strtolower($portal_domain));
} else {
// Prefix-based detection
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$prefix = self::get_prefix();
self::$_is_portal_request = str_starts_with($request_uri, $prefix . '/') ||
$request_uri === $prefix;
}
return self::$_is_portal_request;
}
/**
* Manually set whether this is a portal request (for testing/CLI)
*
* @param bool $is_portal
* @return void
*/
public static function set_portal_request(bool $is_portal): void
{
self::$_is_portal_request = $is_portal;
}
/**
* Get the current request URL with portal prefix stripped (if applicable)
*
* @return string The normalized path
*/
public static function get_normalized_path(): string
{
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
// Remove query string
$path = parse_url($request_uri, PHP_URL_PATH) ?? '/';
// If using prefix mode and path starts with prefix, strip it
if (!self::has_dedicated_domain()) {
$prefix = self::get_prefix();
if (str_starts_with($path, $prefix)) {
$path = substr($path, strlen($prefix)) ?: '/';
}
}
return $path;
}
// =========================================================================
// Route Generation
// =========================================================================
/**
* Generate URL for a portal route
*
* Similar to Rsx::Route() but:
* - Returns URLs with portal domain or prefix
* - Only works with routes that have #[Portal_Route] attribute
*
* Usage examples:
* ```php
* // Portal action route
* $url = Rsx_Portal::Route('Portal_Dashboard_Action');
* // Development: /_portal/dashboard
* // Production: https://portal.example.com/dashboard
*
* // Route with integer parameter
* $url = Rsx_Portal::Route('Portal_Project_View_Action', 123);
* // Development: /_portal/projects/123
*
* // Placeholder route
* $url = Rsx_Portal::Route('Future_Portal_Feature::#index');
* // Returns: #
* ```
*
* @param string $action Controller class, SPA action, or "Class::method"
* @param int|array|\stdClass|null $params Route parameters
* @return string The generated URL (may include portal domain/prefix)
*/
public static function Route($action, $params = null): string
{
// Parse action into class_name and action_name
if (str_contains($action, '::')) {
[$class_name, $action_name] = explode('::', $action, 2);
} else {
$class_name = $action;
$action_name = 'index';
}
// Normalize params to array
$params_array = [];
if (is_int($params)) {
$params_array = ['id' => $params];
} elseif (is_array($params)) {
$params_array = $params;
} elseif ($params instanceof \stdClass) {
$params_array = (array) $params;
} elseif ($params !== null) {
throw new RuntimeException("Params must be integer, array, stdClass, or null");
}
// Placeholder route
if (str_starts_with($action_name, '#')) {
return '#';
}
// Look up routes in manifest using portal_routes_by_target
$manifest = Manifest::get_full_manifest();
// Build target - always include ::method for consistency with manifest keys
$target = $class_name . '::' . $action_name;
// First try direct target lookup (Class::method)
if (!isset($manifest['data']['portal_routes_by_target'][$target])) {
// Allow shorthand: Route('MyController') implies Route('MyController::index')
if ($action_name === 'index' && isset($manifest['data']['portal_routes_by_target'][$class_name])) {
$target = $class_name;
} else {
throw new Rsx_Caller_Exception(
"Portal route not found for {$action}. " .
"Ensure the class has a #[Portal_Route] attribute."
);
}
}
$routes = $manifest['data']['portal_routes_by_target'][$target];
// Select best matching route
$selected_route = self::_select_best_route($routes, $params_array);
if (!$selected_route) {
throw new Rsx_Caller_Exception(
"No suitable portal route found for {$action} with provided parameters. " .
"Available routes: " . implode(', ', array_column($routes, 'pattern'))
);
}
// Generate base URL from pattern
$path = self::_generate_url_from_pattern($selected_route['pattern'], $params_array);
// Apply portal prefix/domain
return self::_apply_portal_base($path);
}
/**
* Generate absolute URL for a portal route
*
* @param string $action Controller class, SPA action, or "Class::method"
* @param int|array|\stdClass|null $params Route parameters
* @return string Full URL including protocol and domain
*/
public static function url($action, $params = null): string
{
$path = self::Route($action, $params);
// If already has domain, return as-is
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
// Build absolute URL
$portal_domain = self::get_domain();
if (!empty($portal_domain)) {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
return $protocol . '://' . $portal_domain . $path;
}
// No portal domain, use current host with path
return url($path);
}
/**
* Apply portal domain or prefix to a path
*
* @param string $path The route path (e.g., '/dashboard')
* @return string Path with portal prefix, or full URL if domain configured
*/
protected static function _apply_portal_base(string $path): string
{
// If using dedicated domain in production, keep path as-is
// The domain will be handled at routing level
if (self::has_dedicated_domain()) {
// In production, the path is relative to portal domain
// The caller should use url() if they need full URL
return $path;
}
// Development mode: prepend prefix
$prefix = self::get_prefix();
return $prefix . $path;
}
/**
* Select the best matching route from available routes
*
* @param array $routes Array of route data from manifest
* @param array $params_array Provided parameters
* @return array|null Selected route data or null if none match
*/
protected static function _select_best_route(array $routes, array $params_array): ?array
{
$satisfiable = [];
foreach ($routes as $route) {
$pattern = $route['pattern'];
// Extract required parameters from pattern
$required_params = [];
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
$required_params = $matches[1];
}
// Check if all required parameters are provided
$can_satisfy = true;
foreach ($required_params as $required) {
if (!array_key_exists($required, $params_array)) {
$can_satisfy = false;
break;
}
}
if ($can_satisfy) {
$satisfiable[] = [
'route' => $route,
'param_count' => count($required_params),
];
}
}
if (empty($satisfiable)) {
return null;
}
// Sort by parameter count descending (most parameters first)
usort($satisfiable, function ($a, $b) {
return $b['param_count'] <=> $a['param_count'];
});
return $satisfiable[0]['route'];
}
/**
* Generate URL from route pattern by replacing parameters
*
* @param string $pattern The route pattern
* @param array $params Parameters to fill in
* @return string The generated URL
*/
protected static function _generate_url_from_pattern(string $pattern, array $params): string
{
// Extract required parameters from the pattern
$required_params = [];
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
$required_params = $matches[1];
}
// Build the URL by replacing parameters
$url = $pattern;
$used_params = [];
foreach ($required_params as $param_name) {
if (!array_key_exists($param_name, $params)) {
throw new RuntimeException("Required parameter '{$param_name}' missing for route {$pattern}");
}
$value = $params[$param_name];
$encoded_value = urlencode((string) $value);
$url = str_replace(':' . $param_name, $encoded_value, $url);
$used_params[$param_name] = true;
}
// Collect extra parameters for query string
$query_params = [];
foreach ($params as $key => $value) {
if (!isset($used_params[$key])) {
$query_params[$key] = $value;
}
}
// Append query string if there are extra parameters
if (!empty($query_params)) {
$url .= '?' . http_build_query($query_params);
}
return $url;
}
// =========================================================================
// Controller/Action Tracking
// =========================================================================
/**
* Set the current portal controller and action being executed
*
* @param string $controller_class The controller class name
* @param string $action_method The action method name
* @param string|null $route_type Route type ('spa' or 'standard')
*/
public static function _set_current_controller_action(
string $controller_class,
string $action_method,
?string $route_type = null
): void {
// Extract just the class name without namespace
$parts = explode('\\', $controller_class);
$class_name = end($parts);
static::$current_controller = $class_name;
static::$current_action = $action_method;
static::$current_route_type = $route_type;
}
/**
* Get the current portal controller class name
*
* @return string|null
*/
public static function get_current_controller(): ?string
{
return static::$current_controller;
}
/**
* Get the current portal action method name
*
* @return string|null
*/
public static function get_current_action(): ?string
{
return static::$current_action;
}
/**
* Check if current portal route is a SPA route
*
* @return bool
*/
public static function is_spa(): bool
{
return static::$current_route_type === 'spa' || static::$current_route_type === 'portal_spa';
}
/**
* Clear the current controller and action tracking
*/
public static function _clear_current_controller_action(): void
{
static::$current_controller = null;
static::$current_action = null;
static::$current_route_type = null;
}
/**
* Clear all cached state (for testing)
*/
public static function _clear_cache(): void
{
static::$_is_portal_request = null;
static::$current_controller = null;
static::$current_action = null;
static::$current_route_type = null;
}
}

View File

@@ -250,6 +250,14 @@ class Spa {
const parsed = Spa.parse_url(url);
let path = parsed.path;
// Strip portal prefix if in portal context
if (Rsx_Portal.is_portal() && !Rsx_Portal.has_dedicated_domain()) {
const prefix = Rsx_Portal.get_prefix();
if (path.startsWith(prefix)) {
path = path.substring(prefix.length) || '/';
}
}
// Normalize path - remove leading/trailing slashes for matching
path = path.substring(1); // Remove leading /

View File

@@ -86,3 +86,22 @@ function title(page_title) {
return target;
};
}
/**
* @decorator
* Link a Portal Spa action to its PHP controller method
* Used for portal routes which are handled by Portal_Spa_ManifestSupport
*
* Usage:
* @portal_spa('Portal_Spa_Controller::index')
* class Portal_Dashboard_Action extends Spa_Action { }
*/
function portal_spa(controller_method) {
return function (target) {
// Store controller::method reference on the class
target._spa_controller_method = controller_method;
// Mark as portal SPA action
target._is_portal_spa = true;
return target;
};
}

View File

@@ -51,6 +51,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
// Parse decorators into route configuration
$route_info = static::_parse_decorators($decorators);
// Skip if this is a portal SPA action (handled by Portal_Spa_ManifestSupport)
if (!empty($route_info['is_portal_spa'])) {
continue;
}
// Skip if no route decorator found
if (empty($route_info['routes'])) {
continue;
@@ -155,6 +160,7 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
'layout' => null,
'spa_controller' => null,
'spa_method' => null,
'is_portal_spa' => false,
];
foreach ($decorators as $decorator) {
@@ -175,6 +181,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
}
break;
case 'portal_spa':
// @portal_spa decorator - handled by Portal_Spa_ManifestSupport, skip here
$config['is_portal_spa'] = true;
break;
case 'spa':
// @spa('Controller::method') - args is array with single string
if (!empty($args[0])) {

View File

@@ -5,41 +5,29 @@ namespace App\RSpade\Lib\Flash;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2026-01-29 08:25:50
* Table: _flash_alerts
*
* @property int $id
* @property int $session_id
* @property int $type_id
* _AUTO_GENERATED_
* @property integer $id
* @property integer $session_id
* @property integer $type_id
* @property string $message
* @property string $created_at
* @property int $created_by
* @property int $updated_by
* @property string $updated_at
*
* @property-read string $type_id__label
* @property-read string $type_id__constant
*
* @method static array type_id__enum() Get all enum definitions with full metadata
* @method static array type_id__enum_select() Get selectable items for dropdowns
* @method static array type_id__enum_labels() Get simple id => label map
* @method static array type_id__enum_ids() Get array of all valid enum IDs
*
* @property \Carbon\Carbon $created_at
* @property integer $created_by
* @property integer $updated_by
* @property \Carbon\Carbon $updated_at
* @method static mixed type_id_enum()
* @method static mixed type_id_enum_select()
* @method static mixed type_id_enum_ids()
* @property-read mixed $type_id_constant
* @property-read mixed $type_id_label
* @mixin \Eloquent
*/
class Flash_Alert_Model extends Rsx_Model_Abstract
{
/**
* _AUTO_GENERATED_ Enum constants
*/
{
/** __AUTO_GENERATED: */
const TYPE_SUCCESS = 1;
const TYPE_ERROR = 2;
const TYPE_INFO = 3;
const TYPE_WARNING = 4;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */
// Enum constants (auto-generated by rsx:migrate:document_models)

View File

@@ -27,6 +27,16 @@ CORE OPTIONS
running test. Uses backdoor authentication that only works in
development environments.
--portal
Enable portal mode for testing client portal routes. When set, the URL
is automatically prefixed with /_portal/ (if not already present).
Use with --portal-user to authenticate as a portal user.
--portal-user=<id|email>
Test as a specific portal user, bypassing portal authentication.
Accepts either a numeric portal user ID or email address. Requires
--portal flag. Validates portal user exists before running test.
--no-body
Suppress HTTP response body output. Useful when you only want to see
headers, status code, console errors, or other diagnostic information.
@@ -281,6 +291,17 @@ Test a protected route as user ID 1:
Test a protected route by email:
php artisan rsx:debug /admin/users --user=admin@example.com
Test a portal route as portal user ID 1:
php artisan rsx:debug /dashboard --portal --portal-user=1
Test a portal route by email:
php artisan rsx:debug /mail --portal --portal-user=client@example.com
Test a portal route (URL with or without /_portal/ prefix):
php artisan rsx:debug /_portal/dashboard --portal --portal-user=1
php artisan rsx:debug /dashboard --portal --portal-user=1
# Both are equivalent - prefix is normalized
Check if JavaScript errors occur:
php artisan rsx:debug /page
# Console errors are always shown

View File

@@ -57,6 +57,8 @@ function parse_args() {
console.log(' --console-debug-benchmark Include benchmark timing in console_debug');
console.log(' --console-debug-all Show all console_debug channels');
console.log(' --dump-dimensions=<sel> Add layout dimensions to matching elements');
console.log(' --portal Test portal routes (uses /_portal/ prefix)');
console.log(' --portal-user=<id> Test as specific portal user ID');
console.log(' --help Show this help message');
process.exit(0);
}
@@ -99,7 +101,9 @@ function parse_args() {
screenshot_width: null,
screenshot_path: null,
dump_dimensions: null,
dev_auth_token: null
dev_auth_token: null,
portal: false,
portal_user_id: null
};
for (const arg of args) {
@@ -176,6 +180,10 @@ function parse_args() {
options.screenshot_path = arg.substring(18);
} else if (arg.startsWith('--dump-dimensions=')) {
options.dump_dimensions = arg.substring(18);
} else if (arg === '--portal') {
options.portal = true;
} else if (arg.startsWith('--portal-user=')) {
options.portal_user_id = arg.substring(14);
} else if (!arg.startsWith('--')) {
options.route = arg;
}
@@ -208,9 +216,14 @@ function parse_args() {
// Main execution
(async () => {
const options = parse_args();
const baseUrl = 'http://localhost';
const fullUrl = baseUrl + options.route;
// In portal mode, ensure route has /_portal prefix
let route = options.route;
if (options.portal && !route.startsWith('/_portal')) {
route = '/_portal' + route;
}
const fullUrl = baseUrl + route;
const laravel_log_path = process.env.LARAVEL_LOG_PATH || '/var/www/html/storage/logs/laravel.log';
// Launch browser (always headless)
@@ -380,6 +393,9 @@ function parse_args() {
if (options.user_id) {
extraHeaders['X-Dev-Auth-User-Id'] = options.user_id;
}
if (options.portal_user_id) {
extraHeaders['X-Dev-Auth-Portal-User-Id'] = options.portal_user_id;
}
if (options.dev_auth_token) {
extraHeaders['X-Dev-Auth-Token'] = options.dev_auth_token;
}
@@ -699,6 +715,14 @@ function parse_args() {
if (options.user_id) {
output += ` user:${options.user_id}`;
}
if (options.portal) {
output += ` portal:true`;
}
if (options.portal_user_id) {
output += ` portal-user:${options.portal_user_id}`;
}
// Add key headers
if (headers_response['content-type']) {

View File

@@ -142,6 +142,8 @@ return [
'manifest_support' => [
\App\RSpade\Core\Dispatch\Route_ManifestSupport::class,
\App\RSpade\Core\Portal\Portal_Route_ManifestSupport::class,
\App\RSpade\Core\Portal\Portal_Spa_ManifestSupport::class,
\App\RSpade\Core\Manifest\Modules\Model_ManifestSupport::class,
\App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestSupport::class,
\App\RSpade\Core\SPA\Spa_ManifestSupport::class,

View File

@@ -396,6 +396,26 @@
"created_at": "2026-01-29T08:19:02+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_notifications_table"
},
"2026_01_29_180540_create_portal_users_table.php": {
"created_at": "2026-01-29T18:05:40+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_portal_users_table"
},
"2026_01_29_180545_create_portal_invitations_table.php": {
"created_at": "2026-01-29T18:05:45+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_portal_invitations_table"
},
"2026_01_29_180545_create_portal_sessions_table.php": {
"created_at": "2026-01-29T18:05:45+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_portal_sessions_table"
},
"2026_01_29_184331_create_portal_password_resets_table.php": {
"created_at": "2026-01-29T18:43:31+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_portal_password_resets_table"
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal users are external users (customers, clients, vendors) who access
* a site through the client portal. They are completely separate from
* internal system users (login_users).
*
* Key differences from login_users:
* - Site-scoped (no multi-tenant access)
* - Simpler auth model (no multi-site switching)
* - Invite-only registration
* - No remember_token (portal sessions are simpler)
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_users (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
is_verified TINYINT(1) NOT NULL DEFAULT 0,
status_id BIGINT NOT NULL DEFAULT 1,
metadata JSON NULL,
last_login TIMESTAMP(3) NULL DEFAULT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT DEFAULT NULL,
updated_by BIGINT DEFAULT NULL,
UNIQUE KEY uk_portal_users_site_email (site_id, email),
KEY idx_portal_users_site_id (site_id),
KEY idx_portal_users_email (email),
KEY idx_portal_users_status_id (status_id),
KEY idx_portal_users_is_verified (is_verified),
KEY idx_portal_users_created_at (created_at),
KEY idx_portal_users_last_login (last_login),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal invitations are single-use codes that allow external users
* to register for portal access. The invitation flow:
*
* 1. Admin creates invitation with email + optional metadata
* 2. System sends email with unique invitation code
* 3. User clicks link, lands on portal registration page
* 4. User sets password, account created with email verified
* 5. Invitation marked as used (used_at set)
*
* Metadata allows linking portal user to business entities
* (e.g., contact_id, client_id) - application-specific.
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_invitations (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
email VARCHAR(255) NOT NULL,
invitation_code VARCHAR(64) NOT NULL,
metadata JSON NULL,
expires_at TIMESTAMP(3) NOT NULL,
used_at TIMESTAMP(3) NULL DEFAULT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT DEFAULT NULL,
updated_by BIGINT DEFAULT NULL,
UNIQUE KEY uk_portal_invitations_code (invitation_code),
KEY idx_portal_invitations_site_id (site_id),
KEY idx_portal_invitations_email (email),
KEY idx_portal_invitations_site_email (site_id, email),
KEY idx_portal_invitations_expires_at (expires_at),
KEY idx_portal_invitations_used_at (used_at),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal sessions are completely separate from internal sessions.
* This ensures:
* - Different cookie names (no session bleeding)
* - Independent session management
* - Simpler structure (no experience_id, no multi-site)
*
* Portal sessions are site-scoped - a portal user can only
* be logged into one site at a time with a given session.
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_sessions (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
portal_user_id BIGINT NULL,
session_token VARCHAR(64) NOT NULL,
csrf_token VARCHAR(64) NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(255) NULL,
last_active TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_portal_sessions_token (session_token),
KEY idx_portal_sessions_site_id (site_id),
KEY idx_portal_sessions_portal_user_id (portal_user_id),
KEY idx_portal_sessions_last_active (last_active),
KEY idx_portal_sessions_site_user (site_id, portal_user_id),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
FOREIGN KEY (portal_user_id) REFERENCES portal_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal password reset tokens allow users to reset their password.
* Tokens are single-use and expire after a configurable time period.
*
* Flow:
* 1. User requests password reset with their email
* 2. System creates token and sends email with reset link
* 3. User clicks link, lands on password reset page
* 4. User sets new password
* 5. Token marked as used (used_at set)
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_password_resets (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
portal_user_id BIGINT NOT NULL,
token VARCHAR(128) NOT NULL,
expires_at TIMESTAMP(3) NOT NULL,
used_at TIMESTAMP(3) NULL DEFAULT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_portal_password_resets_token (token),
KEY idx_portal_password_resets_site_id (site_id),
KEY idx_portal_password_resets_portal_user_id (portal_user_id),
KEY idx_portal_password_resets_expires_at (expires_at),
KEY idx_portal_password_resets_used_at (used_at),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
FOREIGN KEY (portal_user_id) REFERENCES portal_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
};

View File

@@ -1048,9 +1048,10 @@ rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot
rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction
rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return)
rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console
rsx:debug /dashboard --portal --portal-user=1 # Portal route as portal user
```
Options: `--user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help`
Options: `--user=ID`, `--portal`, `--portal-user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help`
**SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side".