ARCHITECTURE.md 80 KB

Spry Users System - Architecture Document

1. Overview

The Spry Users System provides a comprehensive user management and authentication solution for Spry web applications. It offers cookie-based authentication with signed and encrypted session tokens, granular string-keyed permissions, and optional UI components for login and user management.

Key Features

  • Cookie-based Authentication: Secure session tokens using signed+encrypted cookies
  • User Management: CRUD operations for users with configurable password hashing
  • Granular Permissions: String-keyed permissions system (e.g., "admin", "user-management", "content.edit")
  • Application Data: Custom JSON-serializable data field per user
  • Optional UI Components: Pre-built LoginFormComponent and UserManagementInterface
  • Implexus Storage: All data stored in nested container at /spry/users

Design Principles

  1. Minimal Dependencies: Uses Invercargill.DataStructures instead of Libgee
  2. Security First: Leverages existing CryptographyProvider for token security
  3. Optional UI: Applications can use custom UI or provided components
  4. Configurable: Session expiry, permission defaults, and storage paths are configurable

2. File Structure

src/Users/
├── ARCHITECTURE.md           # This document
├── UsersModule.vala          # Module registration and configuration
├── UserService.vala          # Core user management service
├── SessionService.vala       # Session management and cookie handling
├── PermissionService.vala    # Permission checking and management
├── Models/
│   ├── User.vala             # User data model
│   ├── Session.vala          # Session data model
│   └── Permission.vala       # Permission constants and helpers
├── Components/
│   ├── LoginFormComponent.vala       # Optional login form
│   ├── UserListComponent.vala        # User list with search/filter
│   ├── UserListItemComponent.vala    # Individual user row with actions
│   ├── UserFormComponent.vala        # Create/edit user form
│   └── PermissionEditorComponent.vala # Permission management widget
├── Pages/
│   └── UserManagementPage.vala       # PageComponent orchestrating user management
└── meson.build               # Build configuration

File Descriptions

File Purpose
UsersModule.vala IoC registration, configuration, and module setup
UserService.vala User CRUD, password hashing, user queries
SessionService.vala Session creation, validation, cookie management, expiry
PermissionService.vala has_permission, set_permission, clear_permission operations
Models/User.vala User data structure with PropertyMapper for serialization
Models/Session.vala Session data structure with expiry tracking
Models/Permission.vala Permission constants and helper methods
Components/LoginFormComponent.vala HTMX-based login form with error handling
Components/UserListComponent.vala Displays user list with search/filter capabilities
Components/UserListItemComponent.vala Individual user row with inline action buttons
Components/UserFormComponent.vala Modal or inline form for create/edit user
Components/PermissionEditorComponent.vala Permission management widget with checkboxes
Pages/UserManagementPage.vala PageComponent orchestrating all user management components

3. Data Models

3.1 User Model

namespace Spry.Users.Models {

    public class User : Object {
        // Identity
        public string id { get; set; }              // UUID, also used as document name
        public string username { get; set; }        // Unique username
        public string email { get; set; }           // Unique email address
        public string password_hash { get; set; }   // Argon2id hashed password
        
        // Metadata
        public DateTime created_at { get; set; }
        public DateTime? last_login_at { get; set; }
        public DateTime? updated_at { get; set; }
        public bool is_active { get; set; default = true; }
        public bool is_verified { get; set; default = false; }
        
        // Permissions - stored as JSON array of strings
        public Series<string> permissions { get; set; default = new Series<string>(); }
        
        // Application-specific data - stored as JSON object
        public Properties? application_data { get; set; }
        
        // PropertyMapper for Implexus serialization
        public static PropertyMapper<User> get_mapper() {
            return PropertyMapper.build_for<User>(cfg => {
                cfg.map<string>("id", u => u.id, (u, v) => u.id = v);
                cfg.map<string>("username", u => u.username, (u, v) => u.username = v);
                cfg.map<string>("email", u => u.email, (u, v) => u.email = v);
                cfg.map<string>("password_hash", u => u.password_hash, (u, v) => u.password_hash = v);
                cfg.map<string>("created", u => u.created_at.format_iso8601(), 
                    (u, v) => u.created_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
                cfg.map<string?>("last_login", 
                    u => u.last_login_at != null ? ((!)u.last_login_at).format_iso8601() : null,
                    (u, v) => u.last_login_at = v != null ? new DateTime.from_iso8601((!)v, new TimeZone.utc()) : null);
                cfg.map<string?>("updated",
                    u => u.updated_at != null ? ((!)u.updated_at).format_iso8601() : null,
                    (u, v) => u.updated_at = v != null ? new DateTime.from_iso8601((!)v, new TimeZone.utc()) : null);
                cfg.map<bool>("active", u => u.is_active, (u, v) => u.is_active = v);
                cfg.map<bool>("verified", u => u.is_verified, (u, v) => u.is_verified = v);
                cfg.map<Series<string>>("permissions", u => u.permissions, (u, v) => u.permissions = v);
                cfg.map<Properties?>("app_data", u => u.application_data, (u, v) => u.application_data = v);
                cfg.set_constructor(() => new User());
            });
        }
    }
}

3.2 Session Model

namespace Spry.Users.Models {

    public class Session : Object {
        public string id { get; set; }              // UUID for session
        public string user_id { get; set; }         // Reference to User.id
        public DateTime created_at { get; set; }
        public DateTime expires_at { get; set; }
        public string? ip_address { get; set; }     // Optional IP binding
        public string? user_agent { get; set; }     // Optional UA tracking
        
        public bool is_expired {
            get { return expires_at.compare(new DateTime.now_utc()) <= 0; }
        }
        
        public static PropertyMapper<Session> get_mapper() {
            return PropertyMapper.build_for<Session>(cfg => {
                cfg.map<string>("id", s => s.id, (s, v) => s.id = v);
                cfg.map<string>("user", s => s.user_id, (s, v) => s.user_id = v);
                cfg.map<string>("created", s => s.created_at.format_iso8601(),
                    (s, v) => s.created_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
                cfg.map<string>("expires", s => s.expires_at.format_iso8601(),
                    (s, v) => s.expires_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
                cfg.map<string?>("ip", s => s.ip_address, (s, v) => s.ip_address = v);
                cfg.map<string?>("ua", s => s.user_agent, (s, v) => s.user_agent = v);
                cfg.set_constructor(() => new Session());
            });
        }
    }
}

3.3 Permission Constants

namespace Spry.Users.Models {

    public class Permission : Object {
        // Built-in permissions
        public const string USER_MANAGEMENT = "user-management";
        public const string ADMIN = "admin";
        
        // Helper to check if a permission implies another
        public static bool implies(string permission, string required) {
            // "admin" implies all permissions
            if (permission == ADMIN) return true;
            
            // Exact match
            if (permission == required) return true;
            
            // Prefix match: "content.*" implies "content.edit", "content.delete"
            if (permission.has_suffix(".*")) {
                string prefix = permission.substring(0, permission.length - 1);
                return required.has_prefix(prefix);
            }
            
            return false;
        }
        
        // Validate permission string format
        public static bool is_valid(string permission) {
            // Must be non-empty, alphanumeric with dots, dashes, underscores
            return /^[a-zA-Z0-9._-]+$/.match(permission);
        }
    }
}

4. Class Diagrams

4.1 Core Services

┌─────────────────────────────────────────────────────────────────┐
│                        UsersModule                               │
│  - Configures UserService, SessionService, PermissionService    │
│  - Registers components and pages                               │
│  - Provides UsersConfiguration for customization                │
└─────────────────────────────────────────────────────────────────┘
                                │
                                │ registers
                                ▼
┌──────────────────────┐  ┌──────────────────────┐  ┌──────────────────────┐
│     UserService      │  │    SessionService    │  │  PermissionService   │
├──────────────────────┤  ├──────────────────────┤  ├──────────────────────┤
│ + create_user        │  │ + create_session     │  │ + has_permission     │
│ + get_user_by_id     │  │ + validate_session   │  │ + set_permission     │
│ + get_user_by_name   │  │ + destroy_session    │  │ + clear_permission   │
│ + get_user_by_email  │  │ + get_current_user   │  │ + get_permissions    │
│ + update_user        │  │ + set_session_cookie │  │ + check_any          │
│ + delete_user        │  │ + clear_session_cookie│ │ + check_all          │
│ + authenticate       │  │ + get_session_from   │  │                      │
│ + list_users         │  │   cookie             │  │                      │
│ + hash_password      │  │ + cleanup_expired    │  │                      │
│ + verify_password    │  │                      │  │                      │
└──────────────────────┘  └──────────────────────┘  └──────────────────────┘
         │                          │                         │
         │ uses                     │ uses                    │ uses
         ▼                          ▼                         ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Implexus.Core.Engine                          │
│  Storage path: /spry/users                                       │
│  - /spry/users/users/{user_id}     - User documents              │
│  - /spry/users/sessions/{session_id} - Session documents         │
│  - /spry/users/by_username         - Category for username lookup│
│  - /spry/users/by_email            - Category for email lookup   │
└─────────────────────────────────────────────────────────────────┘

4.2 Component Hierarchy

┌─────────────────────────────────────────────────────────────────┐
│                       Component                                  │
│  (from Spry namespace)                                           │
└─────────────────────────────────────────────────────────────────┘
                                ▲
                                │ extends
        ┌───────────────────────┼───────────────────────┐
        │                       │                       │
┌───────┴───────┐     ┌────────┴────────┐     ┌───────┴───────┐
│ LoginFormComp │     │ UserListComp    │     │ PageComponent │
├───────────────┤     ├─────────────────┤     └───────┬───────┘
│ - username    │     │ - users: Series │             ▲
│ - error_msg   │     │ - search_query  │             │ extends
│ - redirect_url│     │ - current_user  │     ┌───────┴───────┐
├───────────────┤     ├─────────────────┤     │UserMgmtPage   │
│ + prepare()   │     │ + prepare()     │     ├───────────────┤
│ + handle_     │     │ + handle_action │     │ - list: inject│
│   action()    │     │   - Search      │     │ - form: inject│
│   - Login     │     │   - Refresh     │     │ + prepare()   │
│   - Logout    │     └────────┬────────┘     │ + handle_     │
└───────────────┘              │ creates      │   action()    │
                               ▼              │   - CreateUser│
                    ┌──────────────────┐      │   - EditUser  │
                    │UserListItemComp  │      │   - DeleteUser│
                    ├──────────────────┤      └───────────────┘
                    │ - user: User     │              │
                    │ - parent_list    │              │ contains
                    ├──────────────────┤              ▼
                    │ + prepare()      │      ┌──────────────────┐
                    │ + handle_action  │      │UserFormComponent │
                    │   - Edit         │      ├──────────────────┤
                    │   - Delete       │      │ - editing_user   │
                    │   - ToggleActive │      │ - is_modal       │
                    └──────────────────┘      │ - visible        │
                                              ├──────────────────┤
                                              │ + prepare()      │
                                              │ + handle_action  │
                                              │   - Save         │
                                              │   - Cancel       │
                                              │ + show()         │
                                              │ + hide()         │
                                              └────────┬─────────┘
                                                       │ contains
                                                       ▼
                                       ┌───────────────────────────┐
                                       │PermissionEditorComponent  │
                                       ├───────────────────────────┤
                                       │ - user: User              │
                                       │ - available_perms: Series │
                                       │ - selected_perms: Series  │
                                       ├───────────────────────────┤
                                       │ + prepare()               │
                                       │ + get_selected()          │
                                       │ + set_permissions()       │
                                       └───────────────────────────┘

4.3 Component Communication Flow

┌─────────────────────────────────────────────────────────────────┐
│                    UserManagementPage                            │
│  - Orchestrates all user management components                   │
│  - Handles cross-component actions                               │
│  - Manages modal state for UserFormComponent                     │
└─────────────────────────────────────────────────────────────────┘
         │                           │                      │
         │ contains                  │ contains             │ contains
         ▼                           ▼                      ▼
┌─────────────────┐     ┌──────────────────────┐  ┌─────────────────┐
│ UserListComponent│     │ UserFormComponent    │  │ Status messages │
│                 │     │ - modal overlay      │  │ - success/error │
│ - Search input  │     │ - create/edit form   │  │   alerts        │
│ - User table    │     │ - permission editor  │  │                 │
│ - Pagination    │     │ - password fields    │  │                 │
└────────┬────────┘     └──────────┬───────────┘  └─────────────────┘
         │                         │
         │ creates per user        │ contains
         ▼                         ▼
┌──────────────────────┐  ┌───────────────────────────┐
│ UserListItemComponent│  │ PermissionEditorComponent │
│ - Username/email     │  │ - Checkbox list           │
│ - Status badges      │  │ - Common permissions      │
│ - Action buttons     │  │ - Custom permission input │
│   - Edit → triggers  │  └───────────────────────────┘
│     form modal       │
│   - Delete           │
│   - Toggle active    │
└──────────────────────┘

Communication Pattern:
1. UserListItemComponent.Edit → UserManagementPage.handle_action("EditUser")
2. UserManagementPage shows UserFormComponent with user data
3. UserFormComponent.Save → UserService.update_user()
4. UserManagementPage refreshes UserListComponent via add_globals_from()

4.3 Data Relationships

┌───────────────────┐       ┌───────────────────┐
│       User        │       │      Session      │
├───────────────────┤       ├───────────────────┤
│ id: string        │◄──────│ user_id: string   │
│ username: string  │  1:N  │ id: string        │
│ email: string     │       │ expires_at: Date  │
│ password_hash     │       │ created_at: Date  │
│ permissions[]     │       │ ip_address: ?     │
│ application_data  │       │ user_agent: ?     │
│ created_at        │       └───────────────────┘
│ last_login_at     │
│ is_active         │
│ is_verified       │
└───────────────────┘
         │
         │ stored in
         ▼
┌───────────────────────────────────────────────────┐
│              Implexus Storage                     │
├───────────────────────────────────────────────────┤
│ /spry/users/                                       │
│   ├── users/                    [Container]       │
│   │   ├── {user_id_1}           [Document:User]   │
│   │   ├── {user_id_2}           [Document:User]   │
│   │   └── ...                                      │
│   ├── sessions/                 [Container]       │
│   │   ├── {session_id_1}        [Document:Session]│
│   │   └── ...                                      │
│   ├── by_username               [Category]        │
│   │   └── expression: "type=='User'"              │
│   │   └── indexed on: username                    │
│   └── by_email                  [Category]        │
│       └── expression: "type=='User'"              │
│       └── indexed on: email                       │
└───────────────────────────────────────────────────┘

5. API Design

5.1 UserService API

namespace Spry.Users {

    public errordomain UserError {
        USER_NOT_FOUND,
        DUPLICATE_USERNAME,
        DUPLICATE_EMAIL,
        INVALID_PASSWORD,
        INVALID_CREDENTIALS,
        USER_INACTIVE,
        PERMISSION_DENIED
    }

    public class UserService : Object {
        private Implexus.Core.Engine engine = inject<Implexus.Core.Engine>();
        private UsersConfiguration config = inject<UsersConfiguration>();
        
        // User CRUD
        public User create_user(string username, string email, string password, 
            Series<string>? permissions = null, Properties? app_data = null) throws Error;
        
        public User? get_user_by_id(string id);
        public User? get_user_by_username(string username);
        public User? get_user_by_email(string email);
        
        public void update_user(User user) throws Error;
        public void delete_user(string user_id) throws Error;
        
        public Series<User> list_users(int offset = 0, int limit = 100);
        public Series<User> search_users(string query, int limit = 50);
        
        // Authentication
        public User authenticate(string username_or_email, string password) throws Error;
        public void update_password(string user_id, string new_password) throws Error;
        public void record_login(string user_id);
        
        // Password hashing (using Argon2id via libsodium)
        public string hash_password(string password);
        public bool verify_password(string password, string hash);
        
        // Utility
        public bool username_exists(string username);
        public bool email_exists(string email);
        public int user_count();
    }
}

5.2 SessionService API

namespace Spry.Users {

    public errordomain SessionError {
        SESSION_NOT_FOUND,
        SESSION_EXPIRED,
        INVALID_SESSION_TOKEN,
        COOKIE_NOT_FOUND
    }

    public class SessionService : Object {
        private Implexus.Core.Engine engine = inject<Implexus.Core.Engine>();
        private CryptographyProvider crypto = inject<CryptographyProvider>();
        private UsersConfiguration config = inject<UsersConfiguration>();
        private HttpContext http_context = inject<HttpContext>();
        
        // Session Management
        public Session create_session(string user_id, 
            string? ip_address = null, string? user_agent = null) throws Error;
        
        public Session? validate_session(string session_id) throws Error;
        public void destroy_session(string session_id) throws Error;
        public void destroy_all_user_sessions(string user_id) throws Error;
        
        // Cookie Operations
        public void set_session_cookie(Session session);
        public void clear_session_cookie();
        public Session? get_session_from_cookie() throws Error;
        
        // Current User Context
        public User? get_current_user() throws Error;
        public string? get_current_user_id() throws Error;
        public bool is_authenticated() throws Error;
        
        // Maintenance
        public void cleanup_expired_sessions();
        public int active_session_count(string? user_id = null);
        
        // Session Token (signed + encrypted)
        public string create_session_token(Session session) throws Error;
        public Session? validate_session_token(string token) throws Error;
    }
}

5.3 PermissionService API

namespace Spry.Users {

    public class PermissionService : Object {
        private UserService user_service = inject<UserService>();
        private SessionService session_service = inject<SessionService>();
        
        // Permission Checking
        public bool has_permission(string user_id, string permission) throws Error;
        public bool has_any_permission(string user_id, Series<string> permissions) throws Error;
        public bool has_all_permissions(string user_id, Series<string> permissions) throws Error;
        
        // Current User Shortcuts
        public bool current_user_has(string permission) throws Error;
        public bool current_user_has_any(Series<string> permissions) throws Error;
        public bool current_user_has_all(Series<string> permissions) throws Error;
        
        // Permission Management
        public void set_permission(string user_id, string permission) throws Error;
        public void clear_permission(string user_id, string permission) throws Error;
        public void set_permissions(string user_id, Series<string> permissions) throws Error;
        public void clear_all_permissions(string user_id) throws Error;
        
        // Querying
        public Series<string> get_permissions(string user_id) throws Error;
        public Series<User> get_users_with_permission(string permission) throws Error;
        
        // Validation
        public void require_permission(string permission) throws Error;
        public void require_any(Series<string> permissions) throws Error;
        public void require_all(Series<string> permissions) throws Error;
    }
}

5.4 UsersConfiguration API

namespace Spry.Users {

    public class UsersConfiguration : Object {
        // Session Settings
        public TimeSpan session_duration { get; set; default = TimeSpan.HOUR * 24 * 7; } // 7 days
        public bool bind_to_ip { get; set; default = false; }
        public bool track_user_agent { get; set; default = true; }
        public string cookie_name { get; set; default = "spry_session"; }
        public bool cookie_secure { get; set; default = true; }
        public bool cookie_http_only { get; set; default = true; }
        public string? cookie_domain { get; set; default = null; }
        public string? cookie_path { get; set; default = "/"; }
        
        // Password Settings
        public int min_password_length { get; set; default = 8; }
        public bool require_uppercase { get; set; default = true; }
        public bool require_lowercase { get; set; default = true; }
        public bool require_digit { get; set; default = true; }
        public bool require_special { get; set; default = false; }
        
        // Storage Paths
        public string storage_base_path { get; set; default = "/spry/users"; }
        
        // Default Permissions for New Users
        public Series<string> default_permissions { get; set; default = new Series<string>(); }
        
        // Validation
        public bool is_valid_password(string password);
        public string? password_validation_message(string password);
    }
}

6. Storage Schema

6.1 Implexus Container Structure

All user data is stored under the /spry/users container:

/spry/users/                           [Container] - Root for all user data
├── users/                             [Container] - User documents
│   ├── {uuid-v4-user-id-1}            [Document:type=User]
│   ├── {uuid-v4-user-id-2}            [Document:type=User]
│   └── {uuid-v4-user-id-3}            [Document:type=User]
├── sessions/                          [Container] - Session documents
│   ├── {uuid-v4-session-id-1}         [Document:type=Session]
│   └── {uuid-v4-session-id-2}         [Document:type=Session]
├── by_username/                       [Category] - Username lookup index
│   └── Config: type=User, expr=username
└── by_email/                          [Category] - Email lookup index
    └── Config: type=User, expr=email

6.2 User Document Schema

{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "username": "johndoe",
    "email": "john@example.com",
    "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$...",
    "created": "2026-01-15T10:30:00Z",
    "last_login": "2026-03-13T15:45:00Z",
    "updated": null,
    "active": true,
    "verified": true,
    "permissions": ["user-management", "content.edit"],
    "app_data": {
        "display_name": "John Doe",
        "preferences": {
            "theme": "dark",
            "language": "en"
        }
    }
}

6.3 Session Document Schema

{
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "user": "550e8400-e29b-41d4-a716-446655440000",
    "created": "2026-03-13T10:00:00Z",
    "expires": "2026-03-20T10:00:00Z",
    "ip": "192.168.1.100",
    "ua": "Mozilla/5.0 ..."
}

6.4 Storage Initialization

public class UsersModule : Object {
    
    public void initialize_storage() throws Error {
        var engine = inject<Implexus.Core.Engine>();
        var config = inject<UsersConfiguration>();
        
        var root = engine.get_root();
        
        // Create base container: /spry/users
        var spry = root.create_container("spry");
        var users_container = spry.create_container("users");
        
        // Create users document container
        users_container.create_container("users");
        
        // Create sessions document container
        users_container.create_container("sessions");
        
        // Create username lookup category
        users_container.create_category("by_username", "User", "username");
        
        // Create email lookup category
        users_container.create_category("by_email", "User", "email");
    }
}

7. Cryptography Integration

7.1 Session Token Structure

Session tokens are created using the existing CryptographyProvider pattern:

public class SessionToken {
    public string session_id { get; set; }
    public string user_id { get; set; }
    public DateTime created_at { get; set; }
    public DateTime expires_at { get; set; }
    
    public static PropertyMapper<SessionToken> get_mapper() {
        return PropertyMapper.build_for<SessionToken>(cfg => {
            cfg.map<string>("sid", t => t.session_id, (t, v) => t.session_id = v);
            cfg.map<string>("uid", t => t.user_id, (t, v) => t.user_id = v);
            cfg.map<string>("cat", t => t.created_at.format_iso8601(),
                (t, v) => t.created_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
            cfg.map<string>("exp", t => t.expires_at.format_iso8601(),
                (t, v) => t.expires_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
            cfg.set_constructor(() => new SessionToken());
        });
    }
}

7.2 Token Creation and Validation

public class SessionService {
    
    private CryptographyProvider crypto = inject<CryptographyProvider>();
    
    public string create_session_token(Session session) throws Error {
        var token = new SessionToken() {
            session_id = session.id,
            user_id = session.user_id,
            created_at = session.created_at,
            expires_at = session.expires_at
        };
        
        var mapper = SessionToken.get_mapper();
        var properties = mapper.map_from(token);
        var json = new JsonElement.from_properties(properties);
        var blob = json.stringify(false);
        
        // Sign then seal (same pattern as ComponentContext)
        var signed = Sodium.Asymmetric.Signing.sign(blob.data, crypto.signing_secret_key);
        var @sealed = Sodium.Asymmetric.Sealing.seal(signed, crypto.sealing_public_key);
        
        // URL-safe Base64 encoding
        return Base64.encode(@sealed).replace("+", "-").replace("/", "_");
    }
    
    public SessionToken? validate_session_token(string token) throws Error {
        try {
            // URL-safe Base64 decode
            var decoded = Base64.decode(token.replace("-", "+").replace("_", "/"));
            
            // Unseal then verify signature
            var signed = Sodium.Asymmetric.Sealing.unseal(
                decoded, 
                crypto.sealing_public_key, 
                crypto.signing_secret_key
            );
            if (signed == null) {
                throw new SessionError.INVALID_SESSION_TOKEN("Could not unseal token");
            }
            
            var cleartext = Sodium.Asymmetric.Signing.verify(
                signed, 
                crypto.signing_public_key
            );
            if (cleartext == null) {
                throw new SessionError.INVALID_SESSION_TOKEN("Invalid token signature");
            }
            
            // Deserialize
            var json = new JsonElement.from_string(
                Wrap.byte_array(cleartext).to_raw_string()
            );
            var mapper = SessionToken.get_mapper();
            var session_token = mapper.materialise(json.as<JsonObject>());
            
            // Check expiry
            if (session_token.expires_at.compare(new DateTime.now_utc()) <= 0) {
                throw new SessionError.SESSION_EXPIRED("Session has expired");
            }
            
            return session_token;
        } catch (Error e) {
            return null;
        }
    }
}

7.3 Password Hashing

Uses Argon2id via libsodium (already available through existing dependencies):

public class UserService {
    
    public string hash_password(string password) {
        return Sodium.PasswordHashing.hash(
            password,
            Sodium.PasswordHashing.OPSLIMIT_MODERATE,
            Sodium.PasswordHashing.MEMLIMIT_MODERATE
        );
    }
    
    public bool verify_password(string password, string hash) {
        return Sodium.PasswordHashing.verify(hash, password);
    }
}

8. Component Design

The user management interface is built from multiple focused components that work together through the UserManagementPage orchestrator. This separation provides better maintainability, reusability, and cleaner code organization.

8.1 LoginFormComponent

A self-contained login form component with HTMX-based submission:

namespace Spry.Users.Components {

    public class LoginFormComponent : Component {
        private SessionService session_service = inject<SessionService>();
        private UserService user_service = inject<UserService>();
        private HttpContext http_context = inject<HttpContext>();
        
        // Properties for configuration
        public string redirect_url { get; set; default = "/"; }
        public string? error_message { get; private set; }
        
        public override string markup { get {
            return """
            <div class="spry-login-form" sid="login-form">
                <script spry-res="htmx.js"></script>
                
                <form sid="form" spry-action=":Login" spry-target="login-form" hx-swap="outerHTML">
                    <spry-context property="redirect_url"/>
                    
                    <div class="form-group">
                        <label for="username">Username or Email</label>
                        <input type="text" name="username" sid="username" required 
                               autocomplete="username"/>
                    </div>
                    
                    <div class="form-group">
                        <label for="password">Password</label>
                        <input type="password" name="password" sid="password" required
                               autocomplete="current-password"/>
                    </div>
                    
                    <div spry-if="this.error_message != null" class="error-message" sid="error">
                        <span content-expr="this.error_message"></span>
                    </div>
                    
                    <button type="submit" sid="submit-btn">Log In</button>
                </form>
            </div>
            """;
        }}
        
        public override async void prepare() throws Error {
            this["form"].set_attribute("hx-vals", @"{\"redirect_url\":\"$redirect_url\"}");
            if (error_message != null) {
                this["error"].text_content = error_message;
            }
        }
        
        public async override void handle_action(string action) throws Error {
            var query = http_context.request.query_params;
            
            if (action == "Login") {
                var username = query.get_any_or_default("username", "");
                var password = query.get_any_or_default("password", "");
                
                try {
                    var user = user_service.authenticate(username, password);
                    var ip = http_context.request.remote_address;
                    var ua = http_context.request.headers.get_any_or_default("User-Agent", null);
                    var session = session_service.create_session(user.id, ip, ua);
                    
                    this["form"].set_attribute("hx-refresh", "true");
                    session_service.set_session_cookie(session);
                } catch (UserError e) {
                    error_message = "Invalid username or password";
                }
            }
        }
    }
}

8.2 UserListComponent

Displays the list of users with search and filter capabilities. Creates UserListItemComponent instances for each user:

namespace Spry.Users.Components {

    public class UserListComponent : Component {
        private UserService user_service = inject<UserService>();
        private ComponentFactory factory = inject<ComponentFactory>();
        private HttpContext http_context = inject<HttpContext>();
        
        // State
        private string _search_query = "";
        private int _page = 0;
        private int _page_size = 20;
        private Series<User> _users = new Series<User>();
        private int _total_count = 0;
        
        public override string markup { get {
            return """
            <div class="spry-user-list" sid="user-list" spry-global="user-list">
                <script spry-res="htmx.js"></script>
                
                <!-- Search Bar -->
                <div class="search-bar" sid="search-bar">
                    <input type="text" name="search" sid="search-input" 
                           placeholder="Search users..."
                           spry-action=":Search" spry-target="user-list" hx-swap="outerHTML"/>
                    <button sid="clear-btn" spry-action=":ClearSearch" 
                            spry-target="user-list" hx-swap="outerHTML"
                            spry-if="this._search_query.length > 0">Clear</button>
                </div>
                
                <!-- User Table -->
                <table class="user-table" sid="table">
                    <thead>
                        <tr>
                            <th>Username</th>
                            <th>Email</th>
                            <th>Status</th>
                            <th>Created</th>
                            <th>Last Login</th>
                            <th>Actions</th>
                        </tr>
                    </thead>
                    <tbody sid="table-body">
                        <spry-outlet sid="users"/>
                    </tbody>
                </table>
                
                <!-- Pagination -->
                <div class="pagination" sid="pagination" spry-if="this._total_count > this._page_size">
                    <button sid="prev-btn" spry-action=":PrevPage" 
                            spry-target="user-list" hx-swap="outerHTML"
                            disabled-expr="this._page == 0 ? 'disabled' : null">
                        Previous
                    </button>
                    <span content-expr="format('Page %d of %d', this._page + 1, 
                        (this._total_count + this._page_size - 1) / this._page_size)"></span>
                    <button sid="next-btn" spry-action=":NextPage"
                            spry-target="user-list" hx-swap="outerHTML"
                            disabled-expr="(this._page + 1) * this._page_size >= this._total_count ? 'disabled' : null">
                        Next
                    </button>
                </div>
                
                <!-- Empty State -->
                <div spry-if="this._users.length == 0" class="empty-state" sid="empty">
                    <p>No users found</p>
                </div>
            </div>
            """;
        }}
        
        public override async void prepare() throws Error {
            // Load users based on current search and page
            if (_search_query.length > 0) {
                _users = user_service.search_users(_search_query, _page_size);
                _total_count = _users.length;
            } else {
                _users = user_service.list_users(_page * _page_size, _page_size);
                _total_count = user_service.user_count();
            }
            
            // Create user item components
            var items = new Series<Renderable>();
            foreach (var user in _users) {
                var item = factory.create<UserListItemComponent>();
                item.set_user(user);
                items.add(item);
            }
            set_outlet_children("users", items);
            
            this["search-input"].set_attribute("value", _search_query);
        }
        
        public async override void handle_action(string action) throws Error {
            var query = http_context.request.query_params;
            
            switch (action) {
                case "Search":
                    _search_query = query.get_any_or_default("search", "");
                    _page = 0;
                    break;
                case "ClearSearch":
                    _search_query = "";
                    _page = 0;
                    break;
                case "PrevPage":
                    if (_page > 0) _page--;
                    break;
                case "NextPage":
                    if ((_page + 1) * _page_size < _total_count) _page++;
                    break;
            }
        }
    }
}

8.3 UserListItemComponent

Individual user row with inline action buttons. Actions are delegated to the parent UserManagementPage:

namespace Spry.Users.Components {

    public class UserListItemComponent : Component {
        private User _user;
        
        public void set_user(User user) {
            _user = user;
        }
        
        public override string markup { get {
            return """
            <tr class="user-row" sid="user-row">
                <td class="username" sid="username"></td>
                <td class="email" sid="email"></td>
                <td class="status" sid="status">
                    <span sid="active-badge" class="badge badge-active">Active</span>
                    <span sid="inactive-badge" class="badge badge-inactive">Inactive</span>
                    <span sid="verified-badge" class="badge badge-verified">Verified</span>
                </td>
                <td class="created" sid="created"></td>
                <td class="last-login" sid="last-login"></td>
                <td class="actions" sid="actions">
                    <button sid="edit-btn" spry-action="UserManagementPage:EditUser"
                            hx-target="#user-form-container" hx-swap="innerHTML">
                        Edit
                    </button>
                    <button sid="toggle-btn" spry-action="UserManagementPage:ToggleActive"
                            hx-target="#user-list" hx-swap="outerHTML">
                        <span spry-if="this._user.is_active">Deactivate</span>
                        <span spry-else>Activate</span>
                    </button>
                    <button sid="delete-btn" spry-action="UserManagementPage:DeleteUser"
                            hx-target="#user-list" hx-swap="outerHTML"
                            hx-confirm="Are you sure you want to delete this user?">
                        Delete
                    </button>
                </td>
            </tr>
            """;
        }}
        
        public override async void prepare() throws Error {
            if (_user == null) return;
            
            this["username"].text_content = _user.username;
            this["email"].text_content = _user.email;
            this["created"].text_content = _user.created_at.format("%Y-%m-%d");
            
            if (_user.last_login_at != null) {
                this["last-login"].text_content = ((!)_user.last_login_at).format("%Y-%m-%d %H:%M");
            } else {
                this["last-login"].text_content = "Never";
            }
            
            // Set user ID on action buttons for cross-component actions
            var user_id_json = @"{\"user_id\":\"$(_user.id)\"}";
            this["edit-btn"].set_attribute("hx-vals", user_id_json);
            this["toggle-btn"].set_attribute("hx-vals", user_id_json);
            this["delete-btn"].set_attribute("hx-vals", user_id_json);
        }
    }
}

8.4 UserFormComponent

Modal form for creating and editing users. Contains PermissionEditorComponent:

namespace Spry.Users.Components {

    public class UserFormComponent : Component {
        private UserService user_service = inject<UserService>();
        private ComponentFactory factory = inject<ComponentFactory>();
        private HttpContext http_context = inject<HttpContext>();
        
        // State
        private User? _editing_user = null;
        private bool _is_visible = false;
        private string? _error_message = null;
        
        public void set_user(User? user) {
            _editing_user = user;
            _is_visible = true;
        }
        
        public void show_create() {
            _editing_user = null;
            _is_visible = true;
        }
        
        public void hide() {
            _is_visible = false;
            _editing_user = null;
            _error_message = null;
        }
        
        public bool is_creating { get { return _editing_user == null; } }
        
        public override string markup { get {
            return """
            <div class="spry-user-form-container" sid="form-container" spry-global="user-form">
                <script spry-res="htmx.js"></script>
                
                <!-- Modal Overlay (shown when visible) -->
                <div spry-if="this._is_visible" class="modal-overlay" sid="modal">
                    <div class="modal-content" sid="modal-content">
                        <div class="modal-header">
                            <h3 content-expr="this.is_creating ? 'Create User' : 'Edit User'"></h3>
                            <button sid="close-btn" spry-action=":Cancel" 
                                    spry-target="form-container" hx-swap="outerHTML"
                                    class="close-btn">&times;</button>
                        </div>
                        
                        <div spry-if="this._error_message != null" class="error" sid="error">
                            <span content-expr="this._error_message"></span>
                        </div>
                        
                        <form sid="form" spry-action=":Save" 
                              spry-target="form-container" hx-swap="outerHTML">
                            <input type="hidden" name="user_id" sid="user-id"/>
                            
                            <div class="form-group">
                                <label for="username">Username *</label>
                                <input type="text" name="username" sid="form-username" 
                                       required minlength="3" pattern="[a-zA-Z0-9_]+"/>
                                <small>Alphanumeric characters and underscores only</small>
                            </div>
                            
                            <div class="form-group">
                                <label for="email">Email *</label>
                                <input type="email" name="email" sid="form-email" required/>
                            </div>
                            
                            <div class="form-group" spry-if="this.is_creating">
                                <label for="password">Password *</label>
                                <input type="password" name="password" sid="form-password"
                                       required minlength="8"/>
                                <small>Minimum 8 characters</small>
                            </div>
                            
                            <div class="form-group" spry-if="!this.is_creating">
                                <label for="new_password">New Password (leave blank to keep current)</label>
                                <input type="password" name="new_password" sid="form-new-password"
                                       minlength="8"/>
                            </div>
                            
                            <div class="form-group">
                                <label>
                                    <input type="checkbox" name="is_active" sid="form-active" checked/>
                                    Active
                                </label>
                            </div>
                            
                            <div class="form-group">
                                <label>
                                    <input type="checkbox" name="is_verified" sid="form-verified"/>
                                    Email Verified
                                </label>
                            </div>
                            
                            <!-- Permission Editor -->
                            <div class="form-group">
                                <label>Permissions</label>
                                <spry-component name="PermissionEditorComponent" sid="permission-editor"/>
                            </div>
                            
                            <div class="form-actions">
                                <button type="submit" sid="save-btn">
                                    <span spry-if="this.is_creating">Create User</span>
                                    <span spry-else>Save Changes</span>
                                </button>
                                <button type="button" sid="cancel-btn" 
                                        spry-action=":Cancel" spry-target="form-container" 
                                        hx-swap="outerHTML">Cancel</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
            """;
        }}
        
        public override async void prepare() throws Error {
            if (!_is_visible) return;
            
            if (_editing_user != null) {
                this["user-id"].set_attribute("value", _editing_user.id);
                this["form-username"].set_attribute("value", _editing_user.username);
                this["form-email"].set_attribute("value", _editing_user.email);
                
                if (_editing_user.is_active) {
                    this["form-active"].set_attribute("checked", "checked");
                }
                if (_editing_user.is_verified) {
                    this["form-verified"].set_attribute("checked", "checked");
                }
                
                var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
                perm_editor.set_permissions(_editing_user.permissions);
            } else {
                var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
                perm_editor.set_permissions(new Series<string>());
            }
        }
        
        public async override void handle_action(string action) throws Error {
            switch (action) {
                case "Save":
                    yield save_user();
                    break;
                case "Cancel":
                    hide();
                    break;
            }
        }
        
        private async void save_user() throws Error {
            var query = http_context.request.query_params;
            var user_id = query.get_any_or_default("user_id", "");
            var username = query.get_any_or_default("username", "").strip();
            var email = query.get_any_or_default("email", "").strip();
            
            if (username.length < 3) {
                _error_message = "Username must be at least 3 characters";
                return;
            }
            
            var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
            var permissions = perm_editor.get_selected();
            
            try {
                if (user_id == "") {
                    var password = query.get_any_or_default("password", "");
                    var is_active = query.get_any_or_default("is_active", "") == "on";
                    var is_verified = query.get_any_or_default("is_verified", "") == "on";
                    
                    var user = user_service.create_user(username, email, password, permissions, null);
                    user.is_active = is_active;
                    user.is_verified = is_verified;
                    user_service.update_user(user);
                } else {
                    var user = user_service.get_user_by_id(user_id);
                    if (user == null) {
                        _error_message = "User not found";
                        return;
                    }
                    
                    user.username = username;
                    user.email = email;
                    user.is_active = query.get_any_or_default("is_active", "") == "on";
                    user.is_verified = query.get_any_or_default("is_verified", "") == "on";
                    user.permissions = permissions;
                    user.updated_at = new DateTime.now_utc();
                    
                    var new_password = query.get_any_or_default("new_password", "");
                    if (new_password.length > 0) {
                        user_service.update_password(user_id, new_password);
                    }
                    
                    user_service.update_user(user);
                }
                
                hide();
            } catch (UserError e) {
                _error_message = e.message;
            }
        }
    }
}

8.5 PermissionEditorComponent

Widget for managing user permissions with checkboxes for common permissions and input for custom ones:

namespace Spry.Users.Components {

    public class PermissionEditorComponent : Component {
        private static Series<string> COMMON_PERMISSIONS = new Series<string>.from({
            Permission.USER_MANAGEMENT,
            Permission.ADMIN,
            "content.read",
            "content.edit",
            "content.delete"
        });
        
        private Series<string> _selected_permissions = new Series<string>();
        private Series<string> _custom_permissions = new Series<string>();
        
        public void set_permissions(Series<string> permissions) {
            _selected_permissions = new Series<string>();
            _custom_permissions = new Series<string>();
            
            foreach (var perm in permissions) {
                if (COMMON_PERMISSIONS.contains(perm)) {
                    _selected_permissions.add(perm);
                } else {
                    _custom_permissions.add(perm);
                }
            }
        }
        
        public Series<string> get_selected() {
            var result = new Series<string>();
            result.add_all(_selected_permissions);
            result.add_all(_custom_permissions);
            return result;
        }
        
        public override string markup { get {
            return """
            <div class="spry-permission-editor" sid="perm-editor">
                <script spry-res="htmx.js"></script>
                
                <!-- Common Permissions -->
                <div class="permission-group" sid="common-group">
                    <h4>Common Permissions</h4>
                    <div class="permission-list" sid="common-list">
                        <spry-outlet sid="common-items"/>
                    </div>
                </div>
                
                <!-- Custom Permissions -->
                <div class="permission-group" sid="custom-group">
                    <h4>Custom Permissions</h4>
                    <div class="custom-permissions" sid="custom-list">
                        <spry-outlet sid="custom-items"/>
                    </div>
                    
                    <!-- Add Custom Permission -->
                    <div class="add-permission" sid="add-perm">
                        <input type="text" name="new_permission" sid="new-perm-input"
                               placeholder="e.g., reports.view"/>
                        <button sid="add-btn" spry-action=":AddPermission"
                                spry-target="perm-editor" hx-swap="outerHTML">
                            Add
                        </button>
                    </div>
                </div>
            </div>
            """;
        }}
        
        public override async void prepare() throws Error {
            var common_items = new Series<Renderable>();
            foreach (var perm in COMMON_PERMISSIONS) {
                var item = create_permission_checkbox(perm, _selected_permissions.contains(perm));
                common_items.add(item);
            }
            set_outlet_children("common-items", common_items);
            
            var custom_items = new Series<Renderable>();
            foreach (var perm in _custom_permissions) {
                var item = create_custom_permission_tag(perm);
                custom_items.add(item);
            }
            set_outlet_children("custom-items", custom_items);
        }
        
        private Renderable create_permission_checkbox(string permission, bool is_checked) {
            var safe_perm = permission.replace(".", "-");
            var checked_attr = is_checked ? "checked" : "";
            
            var doc = new MarkupDocument();
            doc.body.inner_html = @"<label class=\"permission-checkbox\">
                <input type=\"checkbox\" name=\"perm_$safe_perm\" value=\"$permission\" $checked_attr/>
                <span>$permission</span>
            </label>";
            
            return new InlineRenderable(doc);
        }
        
        private Renderable create_custom_permission_tag(string permission) {
            var doc = new MarkupDocument();
            doc.body.inner_html = @"<span class=\"permission-tag\">
                <span>$permission</span>
                <button type=\"button\" spry-action=\":RemovePermission:$permission\"
                        spry-target=\"perm-editor\" hx-swap=\"outerHTML\" 
                        class=\"remove-tag\">&times;</button>
            </span>";
            
            return new InlineRenderable(doc);
        }
        
        public async override void handle_action(string action) throws Error {
            var query = http_context.request.query_params;
            
            if (action == "AddPermission") {
                var new_perm = query.get_any_or_default("new_permission", "").strip();
                if (new_perm.length > 0 && Permission.is_valid(new_perm)) {
                    if (!_custom_permissions.contains(new_perm) && 
                        !_selected_permissions.contains(new_perm)) {
                        _custom_permissions.add(new_perm);
                    }
                }
            } else if (action.has_prefix("RemovePermission:")) {
                var perm_to_remove = action.substring(17);
                _custom_permissions.remove(perm_to_remove);
            }
            
            // Update selected from checkboxes
            _selected_permissions.clear();
            foreach (var perm in COMMON_PERMISSIONS) {
                var safe_perm = perm.replace(".", "-");
                if (query.get_any_or_default(@"perm_$safe_perm", "") == perm) {
                    _selected_permissions.add(perm);
                }
            }
        }
    }
    
    // Helper class for inline HTML rendering
    internal class InlineRenderable : Object, Renderable {
        private MarkupDocument _doc;
        
        public InlineRenderable(MarkupDocument doc) {
            _doc = doc;
        }
        
        public async MarkupDocument to_document() throws Error {
            return _doc;
        }
    }
}

8.6 UserManagementPage

Orchestrates all user management components. Handles cross-component actions:

namespace Spry.Users.Pages {

    public class UserManagementPage : PageComponent {
        private PermissionService permission_service = inject<PermissionService>();
        private UserService user_service = inject<UserService>();
        private SessionService session_service = inject<SessionService>();
        private ComponentFactory factory = inject<ComponentFactory>();
        private HttpContext http_context = inject<HttpContext>();
        
        // State
        private string? _success_message = null;
        private string? _error_message = null;
        
        public override string markup { get {
            return """
            <!DOCTYPE html>
            <html>
            <head>
                <title>User Management</title>
                <link rel="stylesheet" href="/static/admin.css"/>
                <style>
                    .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; 
                                    background: rgba(0,0,0,0.5); display: flex; 
                                    align-items: center; justify-content: center; }
                    .modal-content { background: white; padding: 2rem; border-radius: 8px; 
                                    max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; }
                    .badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; margin-right: 0.25rem; }
                    .badge-active { background: #d4edda; color: #155724; }
                    .badge-inactive { background: #f8d7da; color: #721c24; }
                    .badge-verified { background: #cce5ff; color: #004085; }
                    .permission-tag { display: inline-flex; align-items: center; 
                                     background: #e9ecef; padding: 0.25rem 0.5rem; 
                                     border-radius: 4px; margin: 0.25rem; }
                </style>
            </head>
            <body>
                <div class="admin-container" sid="admin-container">
                    <header class="admin-header">
                        <h1>User Management</h1>
                        <div class="header-actions">
                            <button sid="create-btn" spry-action=":CreateUser"
                                    hx-target="#user-form-container" hx-swap="innerHTML"
                                    class="btn btn-primary">
                                Create User
                            </button>
                        </div>
                    </header>
                    
                    <!-- Status Messages -->
                    <div spry-if="this._success_message != null" class="alert alert-success" 
                         sid="success-alert" spry-global="success-alert">
                        <span content-expr="this._success_message"></span>
                    </div>
                    
                    <div spry-if="this._error_message != null" class="alert alert-error"
                         sid="error-alert" spry-global="error-alert">
                        <span content-expr="this._error_message"></span>
                    </div>
                    
                    <!-- User List Component -->
                    <spry-component name="UserListComponent" sid="user-list"/>
                    
                    <!-- User Form Container (for modal) -->
                    <div id="user-form-container" sid="user-form-container">
                        <spry-component name="UserFormComponent" sid="user-form"/>
                    </div>
                </div>
            </body>
            </html>
            """;
        }}
        
        public override async void prepare() throws Error {
            permission_service.require_permission(Permission.USER_MANAGEMENT);
            
            var user_form = get_component_child<UserFormComponent>("user-form");
            user_form.hide();
        }
        
        public async override void handle_action(string action) throws Error {
            var query = http_context.request.query_params;
            
            switch (action) {
                case "CreateUser":
                    var user_form = get_component_child<UserFormComponent>("user-form");
                    user_form.show_create();
                    break;
                    
                case "EditUser":
                    var user_id = query.get_any_or_default("user_id", "");
                    var user = user_service.get_user_by_id(user_id);
                    if (user != null) {
                        var user_form = get_component_child<UserFormComponent>("user-form");
                        user_form.set_user(user);
                    }
                    break;
                    
                case "ToggleActive":
                    var toggle_id = query.get_any_or_default("user_id", "");
                    var toggle_user = user_service.get_user_by_id(toggle_id);
                    if (toggle_user != null) {
                        toggle_user.is_active = !toggle_user.is_active;
                        user_service.update_user(toggle_user);
                        _success_message = toggle_user.is_active ? 
                            "User activated" : "User deactivated";
                        
                        var user_list = get_component_child<UserListComponent>("user-list");
                        add_globals_from(user_list);
                    }
                    break;
                    
                case "DeleteUser":
                    var delete_id = query.get_any_or_default("user_id", "");
                    
                    var current_user_id = session_service.get_current_user_id();
                    if (current_user_id == delete_id) {
                        _error_message = "Cannot delete your own account";
                        break;
                    }
                    
                    user_service.delete_user(delete_id);
                    _success_message = "User deleted successfully";
                    
                    var user_list = get_component_child<UserListComponent>("user-list");
                    add_globals_from(user_list);
                    break;
            }
        }
    }
}

9. Integration Points

9.1 Module Registration

Applications integrate the Users system by registering the module:

// In application startup
var application = new Application();
application.add_module<SpryModule>();
application.add_module<Spry.Users.UsersModule>();

// Optional: Configure settings
var config = application.resolve<Spry.Users.UsersConfiguration>();
config.session_duration = TimeSpan.HOUR * 24; // 1 day
config.min_password_length = 12;
config.default_permissions.add("user");

9.2 UsersModule Implementation

namespace Spry.Users {

    public class UsersModule : Object, Inversion.Module {
        
        public void register(Inversion.Application application) {
            // Register configuration (singleton)
            application.add_singleton<UsersConfiguration>();
            
            // Register services (scoped for per-request isolation)
            application.add_scoped<UserService>();
            application.add_scoped<SessionService>();
            application.add_scoped<PermissionService>();
            
            // Register components (transient)
            var spry_cfg = application.configure_with<SpryConfigurator>();
            spry_cfg.add_component<Components.LoginFormComponent>();
            spry_cfg.add_component<Components.UserListComponent>();
            spry_cfg.add_component<Components.UserListItemComponent>();
            spry_cfg.add_component<Components.UserFormComponent>();
            spry_cfg.add_component<Components.PermissionEditorComponent>();
            
            // Register pages (scoped)
            spry_cfg.add_page<Pages.UserManagementPage>(
                new EndpointRoute("/admin/users"));
        }
        
        public void initialize(Inversion.Application application) {
            // Initialize storage structure
            try {
                var engine = application.resolve<Implexus.Core.Engine>();
                initialize_storage(engine);
            } catch (Error e) {
                error("Failed to initialize Users storage: %s", e.message);
            }
        }
        
        private void initialize_storage(Implexus.Core.Engine engine) throws Error {
            var root = engine.get_root();
            
            // Create /spry/users container hierarchy
            var spry = get_or_create_container(root, "spry");
            var users_base = get_or_create_container(spry, "users");
            
            get_or_create_container(users_base, "users");
            get_or_create_container(users_base, "sessions");
            get_or_create_category(users_base, "by_username", "User", "username");
            get_or_create_category(users_base, "by_email", "User", "email");
        }
        
        private Implexus.Core.Entity get_or_create_container(
            Implexus.Core.Entity parent, string name) throws Error {
            var child = parent.get_child(name);
            if (child != null) return (!)child;
            return (!)parent.create_container(name);
        }
        
        private void get_or_create_category(
            Implexus.Core.Entity parent, string name, 
            string type_label, string expression) throws Error {
            var child = parent.get_child(name);
            if (child != null) return;
            parent.create_category(name, type_label, expression);
        }
    }
}

9.3 Protecting Routes

Applications can protect routes using the PermissionService:

public class AdminPage : PageComponent {
    private PermissionService permissions = inject<PermissionService>();
    
    public override async void prepare() throws Error {
        // Will throw PermissionDenied if not authorized
        permissions.require_permission("admin");
    }
}

9.4 Accessing Current User

public class DashboardPage : PageComponent {
    private SessionService sessions = inject<SessionService>();
    
    public override async void prepare() throws Error {
        var user = sessions.get_current_user();
        if (user == null) {
            // Redirect to login
            return;
        }
        
        // Use user data
        this["welcome"].text_content = @"Welcome, $(user.username)!";
    }
}

9.5 Custom Application Data

// Store custom data
var user = user_service.get_user_by_id(user_id);
user.application_data = new Properties();
user.application_data["theme"] = new NativeElement<string>("dark");
user.application_data["notifications"] = new NativeElement<bool?>(true);
user_service.update_user(user);

// Retrieve custom data
var theme = user.application_data?.get_any_or_default("theme", "light");

10. Security Considerations

10.1 Password Security

  • Hashing: Argon2id with moderate ops/mem limits via libsodium
  • Never store plaintext: Password field only accepts hashes
  • Verification timing-safe: Uses libsodium's constant-time comparison

10.2 Session Token Security

  • Signed: Ed25519 signatures prevent token tampering
  • Encrypted: X25519-Seal prevents token inspection
  • URL-safe encoding: Base64 with +- and /_ substitution
  • Expiry validation: Server-side expiry check in addition to token expiry

10.3 Cookie Security

  • HttpOnly: Prevents JavaScript access to session cookie
  • Secure: Only transmitted over HTTPS (configurable for development)
  • SameSite: Strict by default to prevent CSRF
  • Path-limited: Cookie scoped to application path

10.4 Permission Checks

  • Deny by default: No permissions unless explicitly granted
  • Admin override: Users with "admin" permission bypass all checks
  • Wildcard support: "content.*" implies "content.edit", "content.delete", etc.
  • Server-side enforcement: All permission checks happen server-side

10.5 Input Validation

  • Username: Alphanumeric, underscores, minimum 3 characters
  • Email: Basic email format validation
  • Password: Configurable complexity requirements
  • Permission strings: Validated against regex ^[a-zA-Z0-9._-]+$

10.6 Session Management

  • Session invalidation: On password change, user deletion, or explicit logout
  • IP binding: Optional binding to client IP (disabled by default)
  • User agent tracking: Optional for anomaly detection
  • Automatic cleanup: Expired sessions cleaned up periodically

10.7 Known Limitations

  • No rate limiting: Applications should implement their own rate limiting on login endpoints
  • No account lockout: No automatic lockout after failed attempts (application responsibility)
  • No password reset: Built-in password reset flow not included (application responsibility)
  • No two-factor authentication: Not included in this implementation

11. Mermaid Diagrams

11.1 Authentication Flow

sequenceDiagram
    participant Client
    participant LoginFormComponent
    participant UserService
    participant SessionService
    participant Implexus
    
    Client->>LoginFormComponent: Submit login form
    LoginFormComponent->>UserService: authenticate username/password
    UserService->>Implexus: Query user by username
    Implexus-->>UserService: User document
    UserService->>UserService: verify_password
    UserService-->>LoginFormComponent: User authenticated
    LoginFormComponent->>SessionService: create_session user_id
    SessionService->>Implexus: Store session document
    SessionService->>SessionService: create_session_token
    SessionService->>Client: Set session cookie
    LoginFormComponent-->>Client: Redirect to dashboard

11.2 Permission Check Flow

flowchart TD
    A[Request to protected resource] --> B{Is authenticated?}
    B -->|No| C[Redirect to login]
    B -->|Yes| D{Has admin permission?}
    D -->|Yes| E[Allow access]
    D -->|No| F{Has required permission?}
    F -->|Yes| E
    F -->|No| G[Permission denied error]
    
    E --> H[Process request]

11.3 Storage Hierarchy

graph TD
    Root[Root Container /] --> Spry[spry/]
    Spry --> Users[users/]
    Users --> UserDocs[users/ - User Documents]
    Users --> SessionDocs[sessions/ - Session Documents]
    Users --> ByUsername[by_username - Category]
    Users --> ByEmail[by_email - Category]
    
    UserDocs --> User1[uuid-1 - type: User]
    UserDocs --> User2[uuid-2 - type: User]
    
    SessionDocs --> Session1[uuid-s1 - type: Session]
    SessionDocs --> Session2[uuid-s2 - type: Session]

12. Dependencies

12.1 Meson Build Configuration

# src/Users/meson.build

users_sources = files(
    'UsersModule.vala',
    'UserService.vala',
    'SessionService.vala',
    'PermissionService.vala',
    'Models/User.vala',
    'Models/Session.vala',
    'Models/Permission.vala',
    'Components/LoginFormComponent.vala',
    'Components/UserListComponent.vala',
    'Components/UserListItemComponent.vala',
    'Components/UserFormComponent.vala',
    'Components/PermissionEditorComponent.vala',
    'Pages/UserManagementPage.vala'
)

# Users library
libspry_users = static_library('spry-users',
    users_sources,
    dependencies: [spry_dep, implexus_dep, sodium_deps],
    include_directories: include_directories('..')
)

spry_users_dep = declare_dependency(
    link_with: libspry_users,
    dependencies: [spry_dep, implexus_dep]
)

12.2 Required Dependencies

Dependency Purpose
spry-0.1 Core framework (Component, PageComponent, CryptographyProvider)
implexus-0.1 Document storage
invercargill-1 Data structures (Series, Dictionary, HashSet)
invercargill-json JSON serialization
inversion-0.1 IoC container
libsodium Cryptography (password hashing, signing, encryption)
astralis-0.1 HTTP handling

12.3 Root meson.build Update

Add to root meson.build:

implexus_dep = dependency('implexus-0.1')
subdir('src/Users')

13. Future Considerations

Potential Enhancements

  1. OAuth Integration: Add support for external OAuth providers
  2. Two-Factor Authentication: TOTP or WebAuthn support
  3. Password Reset Flow: Email-based password recovery
  4. Account Verification: Email verification workflow
  5. Audit Logging: Track user management actions
  6. Rate Limiting: Built-in login attempt limiting
  7. API Token Support: For API-only authentication
  8. Role-Based Access: Group permissions into roles

14. Implementation Order

Recommended implementation sequence:

  1. Models: User, Session, Permission data classes with PropertyMappers
  2. UsersConfiguration: Configuration class with defaults
  3. UserService: Core user CRUD and password hashing
  4. SessionService: Session management and cookie handling
  5. PermissionService: Permission checking and management
  6. UsersModule: IoC registration and storage initialization
  7. LoginFormComponent: Basic login form
  8. UserListItemComponent: Individual user row component
  9. UserListComponent: User list with search and pagination
  10. PermissionEditorComponent: Permission management widget
  11. UserFormComponent: Create/edit user modal form
  12. UserManagementPage: Page orchestrating all components
  13. Testing: Unit tests and integration tests
  14. Documentation: Usage examples and API docs