Forráskód Böngészése

refactor(auth): migrate from implexus to sqlite with new auth system

Replace Implexus document storage with SQLite-based authentication using
InvercargillSql. Introduce new Authentication and Authorisation modules
with repository pattern (SqlUserRepository, SqlSessionRepository).

Key changes:
- Add AuthorisationContext for request-scoped permission checking
- Replace UserManagementPage with UserAdminPage using UserManagementComponent
- Update format strings from printf-style to template syntax across demos
- Register repositories and auth services in dependency injection
- Add sqlite3 and invercargill-sql dependencies to build configuration
Billy Barrow 1 hónapja
szülő
commit
77a87ecfc2
43 módosított fájl, 11944 hozzáadás és 79 törlés
  1. 2 2
      demo/DemoComponents/ProgressDemo.vala
  2. 1 1
      demo/Pages/ComponentsContinuationsPage.vala
  3. 2 2
      demo/Pages/ComponentsTemplateSyntaxPage.vala
  4. 1 1
      examples/ProgressExample.vala
  5. 169 61
      examples/UsersExample.vala
  6. 2 2
      examples/meson.build
  7. 4 4
      important-details.md
  8. 453 0
      invercargill-format-migration-details.md
  9. 3 2
      meson.build
  10. 1324 0
      plans/auth-refactor-architecture.md
  11. 658 0
      plans/authentication-implexus-analysis.md
  12. 1258 0
      plans/invercargill-sql-migration-plan.md
  13. 804 0
      plans/user-management-component-redesign.md
  14. 1878 0
      src/Authentication/ARCHITECTURE.md
  15. 267 0
      src/Authentication/Components/LoginFormComponent.vala
  16. 347 0
      src/Authentication/Components/NewUserComponent.vala
  17. 507 0
      src/Authentication/Components/UserDetailsComponent.vala
  18. 223 0
      src/Authentication/Components/UserManagementComponent.vala
  19. 93 0
      src/Authentication/CreateAuthTables.vala
  20. 258 0
      src/Authentication/PermissionService.vala
  21. 98 0
      src/Authentication/Session.vala
  22. 83 0
      src/Authentication/SessionRepository.vala
  23. 578 0
      src/Authentication/SessionService.vala
  24. 189 0
      src/Authentication/SqlSessionRepository.vala
  25. 401 0
      src/Authentication/SqlUserRepository.vala
  26. 291 0
      src/Authentication/User.vala
  27. 62 0
      src/Authentication/UserIdentityProvider.vala
  28. 160 0
      src/Authentication/UserRepository.vala
  29. 401 0
      src/Authentication/UserService.vala
  30. 30 0
      src/Authentication/meson.build
  31. 272 0
      src/Authorisation/AuthorisationContext.vala
  32. 35 0
      src/Authorisation/AuthorisationError.vala
  33. 340 0
      src/Authorisation/AuthorisationService.vala
  34. 281 0
      src/Authorisation/AuthorisationToken.vala
  35. 223 0
      src/Authorisation/AuthorisationTokenService.vala
  36. 41 0
      src/Authorisation/Identity.vala
  37. 31 0
      src/Authorisation/IdentityProvider.vala
  38. 143 0
      src/Authorisation/PermissionMatcher.vala
  39. 23 0
      src/Authorisation/meson.build
  40. 2 2
      src/Component.vala
  41. 2 1
      src/ComponentEndpoint.vala
  42. 1 1
      src/CryptographyProvider.vala
  43. 3 0
      src/meson.build

+ 2 - 2
demo/DemoComponents/ProgressDemo.vala

@@ -21,12 +21,12 @@ public class ProgressDemo : Component {
         <div sid="progress" spry-continuation class="demo-progress" style="padding: 20px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
             <div spry-dynamic="progress-bar" class="progress-bar-container" style="background: #e0e0e0; border-radius: 8px; overflow: hidden; margin: 15px 0; height: 24px;">
                 <div sid="progress-bar" class="progress-bar"
-                     style-width-expr='format("%i%%", this.percent)'
+                     style-width-expr='format("{{this.percent}}%")'
                      style="height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 12px; min-width: 40px;">
                 </div>
             </div>
             <div spry-dynamic="status" class="progress-info" style="display: flex; justify-content: space-between; align-items: center; margin: 15px 0;">
-                <span sid="percent-text" class="progress-percent" spry-unique content-expr='format("%i%%", this.percent)' style="font-weight: bold; color: #333; font-size: 18px;">0%</span>
+                <span sid="percent-text" class="progress-percent" spry-unique content-expr='format("{{this.percent}}%")' style="font-weight: bold; color: #333; font-size: 18px;">0%</span>
                 <span sid="status-text" class="progress-status" spry-unique content-expr="this.status" style="color: #666; font-size: 14px; padding: 6px 12px; background: #e9ecef; border-radius: 4px; border-left: 3px solid #2196F3;">Ready</span>
             </div>
             <div class="progress-controls" style="display: flex; gap: 10px; margin-top: 15px;">

+ 1 - 1
demo/Pages/ComponentsContinuationsPage.vala

@@ -190,7 +190,7 @@ public class ComponentsContinuationsPage : PageComponent {
         continuation_attr.code = "<div spry-continuation>\n" +
             "    <!-- Children can receive SSE updates -->\n" +
             "    <div spry-dynamic=\"progress-bar\">\n" +
-            "        <div class=\"progress-bar\" style-width-expr='format(\"%i%%\", this.percent)'>\n" +
+            "        <div class=\"progress-bar\" style-width-expr='format(\"{{this.percent}}%\")'>\n" +
             "        </div>\n" +
             "    </div>\n" +
             "    <div spry-dynamic=\"status\">\n" +

+ 2 - 2
demo/Pages/ComponentsTemplateSyntaxPage.vala

@@ -257,10 +257,10 @@ add_globals_from(header);  // Includes header in response for OOB swap""";
         var expr_example = get_component_child<CodeBlockComponent>("expr-example");
         expr_example.language = "HTML";
         expr_example.code = """<!-- Content expression -->
-<span content-expr='format("%i%%", this.percent)'>0%</span>
+<span content-expr='format("{{this.percent}}%")'>0%</span>
 
 <!-- Style expression -->
-<div style-width-expr='format("%i%%", this.percent)'></div>
+<div style-width-expr='format("{{this.percent}}%")'></div>
 
 <!-- Conditional class -->
 <div class-completed-expr="this.is_completed">Task</div>

+ 1 - 1
examples/ProgressExample.vala

@@ -81,7 +81,7 @@ class ProgressComponent : Component {
         <div spry-continuation>
             <div class="progress-container" spry-dynamic="progress-bar">
                 <div class="progress-bar" spry-unique
-                content-expr='format("%i%%", this.percent)' style-width-expr='format("%i%%", this.percent)'>
+                content-expr='format("{{this.percent}}%")' style-width-expr='format("{{this.percent}}%")'>
                     0%
                 </div>
             </div>

+ 169 - 61
examples/UsersExample.vala

@@ -1,27 +1,26 @@
 using Astralis;
 using Invercargill;
 using Invercargill.DataStructures;
+using InvercargillSql;
 using Inversion;
-using Implexus;
-using Implexus.Core;
-using Implexus.Engine;
-using Implexus.Migrations;
 using Spry;
-using Spry.Users;
-using Spry.Users.Components;
+using Spry.Authentication;
+using Spry.Authentication.Components;
+using Spry.Authorisation;
 
 /**
- * UsersExample.vala - Complete example demonstrating the Spry Users system
+ * UsersExample.vala - Complete example demonstrating the Spry Authentication system
  * 
  * This example demonstrates:
- * 1. Application Migration - Extends UsersMigration with a specific version
+ * 1. Application Migration - Extends AuthenticationMigration with a specific version
  * 2. Service Setup - Using Inversion's inject<T>() pattern
  * 3. User Registration - Creating a new user with username/email/password
  * 4. User Authentication - Login flow with session creation
  * 5. Permission Management - Setting and checking permissions
  * 6. Protected Content - Pages that require authentication
  * 7. Login Form - Using the built-in LoginFormComponent
- * 8. User Management - Using UserManagementPage (for users with permission)
+ * 8. User Management - Using UserManagementComponent (for users with permission)
+ * 9. Authorisation Context - Using AuthorisationContext for permission checking
  * 
  * Route structure:
  *   /              -> HomePage (public landing page)
@@ -29,26 +28,59 @@ using Spry.Users.Components;
  *   /login         -> LoginPage (uses LoginFormComponent)
  *   /logout        -> LogoutEndpoint (clears session and redirects)
  *   /dashboard     -> DashboardPage (protected - requires authentication)
- *   /admin/users   -> UserManagementPage (protected - requires "user-management" permission)
+ *   /admin/users   -> UserAdminPage (protected - requires "user-management" permission, uses UserManagementComponent)
  */
 
 // =============================================================================
-// MIGRATION - Sets up the Users system storage structure
+// APPLICATION PERMISSIONS - Define permissions available in this application
 // =============================================================================
 
 /**
- * MyAppUsersMigration - Application-specific migration for the Users system
- * 
- * This creates:
- * - /spry/users/users - Container for user documents
- * - /spry/users/sessions - Container for session documents
- * - /spry/users/users/by_username - Catalogue for username lookups
- * - /spry/users/users/by_email - Catalogue for email lookups
- * 
- * The version string must be unique within your application's migration system.
+ * Creates a Vector of permissions for the UsersExample application.
+ *
+ * These permissions are passed to the authentication components so they can
+ * display appropriate checkboxes. The components themselves do NOT hardcode
+ * any permissions - they must be provided by the application.
+ *
+ * Returns a Vector<string> which is required for proper property binding
+ * in the authentication components.
+ */
+public Vector<string> get_application_permissions() {
+    var permissions = new Vector<string>();
+    permissions.add("user-management");
+    permissions.add("user.create");
+    permissions.add("user.read");
+    permissions.add("user.update");
+    permissions.add("user.delete");
+    permissions.add("admin");
+    return permissions;
+}
+
+// =============================================================================
+// DATABASE SETUP - Sets up the Authentication system SQLite database
+// =============================================================================
+
+/**
+ * Database file path for the authentication database.
+ */
+private const string AUTH_DB_PATH = "spry_auth.db";
+
+/**
+ * Creates and returns a database connection.
  */
-public class MyAppUsersMigration : UsersMigration {
-    public override string version { owned get { return "2026031501"; } }
+private async Connection create_database_connection() throws Error {
+    var connection = new SqliteConnection(AUTH_DB_PATH);
+    yield connection.open_async();
+    return connection;
+}
+
+/**
+ * Initializes the database schema using CreateAuthTables.
+ */
+private async void initialize_database_schema(Connection connection) throws Error {
+    var create_tables = new CreateAuthTables(connection);
+    yield create_tables.migrate();
+    print("Database schema initialized successfully.\n");
 }
 
 // =============================================================================
@@ -277,7 +309,7 @@ public class MainLayoutTemplate : PageTemplate {
         <head>
             <meta charset="UTF-8">
             <meta name="viewport" content="width=device-width, initial-scale=1.0">
-            <title>Spry Users Example</title>
+            <title>Spry Authentication Example</title>
             <link rel="stylesheet" href="/styles/main.css">
             <script spry-res="htmx.js"></script>
         </head>
@@ -296,7 +328,7 @@ public class MainLayoutTemplate : PageTemplate {
                 <spry-template-outlet />
             </main>
             <footer>
-                <p>Built with Spry Framework - Users Example</p>
+                <p>Built with Spry Framework - Authentication Example</p>
             </footer>
         </body>
         </html>
@@ -324,7 +356,7 @@ public class MainLayoutTemplate : PageTemplate {
  * HomePage - Public landing page
  * 
  * This page is accessible to everyone, authenticated or not.
- * It provides an overview of the Users system features.
+ * It provides an overview of the Authentication system features.
  */
 public class HomePage : PageComponent {
     
@@ -339,8 +371,8 @@ public class HomePage : PageComponent {
     public override string markup { get {
         return """
         <div class="card">
-            <h1>Welcome to the Spry Users Example</h1>
-            <p>This example demonstrates the complete Spry Users system including:</p>
+            <h1>Welcome to the Spry Authentication Example</h1>
+            <p>This example demonstrates the complete Spry Authentication system including:</p>
             
             <h2>Features</h2>
             <ul>
@@ -349,6 +381,7 @@ public class HomePage : PageComponent {
                 <li><strong>Permissions</strong> - Granular permission system with wildcard support</li>
                 <li><strong>Protected Pages</strong> - Pages that require authentication</li>
                 <li><strong>User Management</strong> - Admin interface for managing users</li>
+                <li><strong>Authorisation Context</strong> - Request-scoped permission checking</li>
             </ul>
             
             <h2>Try It Out</h2>
@@ -613,6 +646,7 @@ public class LogoutEndpoint : Object, Endpoint {
  * - Accessing the current user's information
  * - Checking permissions
  * - Displaying user-specific content
+ * - Using AuthorisationContext for permission checking
  */
 public class DashboardPage : PageComponent {
     
@@ -620,6 +654,7 @@ public class DashboardPage : PageComponent {
     private UserService _user_service = inject<UserService>();
     private PermissionService _permission_service = inject<PermissionService>();
     private HttpContext _http_context = inject<HttpContext>();
+    private AuthorisationContext _auth_context = inject<AuthorisationContext>();
     
     public User? current_user { get; private set; }
     public bool is_admin { get; private set; default = false; }
@@ -665,6 +700,10 @@ public class DashboardPage : PageComponent {
                     <a href="/admin/users" style="color: #155724;">Go to User Management</a>
                 </div>
                 
+                <div spry-if="this.uses_auth_context" class="alert alert-success" style="margin-top: 1.5rem;">
+                    <strong>AuthorisationContext:</strong> Permission checking via the new Authorisation system is active.
+                </div>
+                
                 <p style="margin-top: 1.5rem;">
                     <a href="/" class="btn btn-secondary">Back to Home</a>
                 </p>
@@ -676,6 +715,9 @@ public class DashboardPage : PageComponent {
     // Track if user is authenticated (for template binding)
     public bool is_authenticated { get; private set; default = false; }
     
+    // Track if using AuthorisationContext for permission checking
+    public bool uses_auth_context { get; private set; default = false; }
+    
     public override async void prepare() throws Error {
         // Authenticate the request
         var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
@@ -693,6 +735,15 @@ public class DashboardPage : PageComponent {
         permissions = _permission_service.get_permissions(current_user);
         is_admin = _permission_service.has_permission(current_user, PermissionService.ADMIN);
         
+        // Demonstrate using AuthorisationContext for permission checking
+        // This shows how the new Authorisation system can be used alongside
+        // the traditional PermissionService approach
+        if (_auth_context.is_authorised) {
+            uses_auth_context = true;
+            // The AuthorisationContext provides an alternative way to check permissions
+            // is_admin = _auth_context.has_permission(PermissionService.ADMIN);
+        }
+        
         // Populate user info
         this["welcome-heading"].text_content = @"Welcome, $(current_user.username)!";
         this["username"].text_content = current_user.username;
@@ -700,19 +751,70 @@ public class DashboardPage : PageComponent {
         this["user-id"].text_content = current_user.id;
         this["created-at"].text_content = current_user.created_at.format("%Y-%m-%d %H:%M:%S UTC");
         
-        // Populate permissions
+        // Populate permissions - user.permissions returns string[] now
         var perm_text = "";
-        foreach (var perm in permissions) {
+        var user_permissions = current_user.permissions;
+        foreach (var perm in user_permissions) {
             var badge_class = perm == PermissionService.ADMIN ? "permission-badge admin" : "permission-badge";
             perm_text += @"<span class=\"$badge_class\">$perm</span> ";
         }
-        if (permissions.length == 0) {
+        if (user_permissions.length == 0) {
             perm_text = "<span class=\"permission-badge\">No permissions assigned</span>";
         }
         this["permission-list"].inner_html = perm_text;
     }
 }
 
+// =============================================================================
+// PAGE COMPONENTS - Admin pages
+// =============================================================================
+
+/**
+ * UserAdminPage - Admin page for user management
+ * 
+ * This page demonstrates how to use UserManagementComponent within your own
+ * page layout. Unlike the old UserManagementPage (which was a PageComponent
+ * that generated a full HTML document), UserManagementComponent is a regular
+ * Component that can be placed anywhere.
+ * 
+ * Applications now have full control over:
+ * - The page layout and navigation
+ * - CSS styling via their own stylesheets
+ * - Where the user management component appears
+ */
+public class UserAdminPage : PageComponent {
+    
+    private ComponentFactory _factory = inject<ComponentFactory>();
+    private UserManagementComponent _user_management;
+    
+    public const string ROUTE = "/admin/users";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <h1>User Administration</h1>
+            <p>Manage user accounts, permissions, and access control.</p>
+            <spry-outlet sid="user-management-outlet"/>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Create the user management component
+        _user_management = _factory.create<UserManagementComponent>();
+        
+        // Pass the application-defined permissions to the component
+        // This allows the component to display appropriate checkboxes
+        _user_management.available_permissions = get_application_permissions();
+        
+        // Add it to our outlet
+        add_outlet_child("user-management-outlet", _user_management);
+        
+        // Share globals with the component (required for action handling)
+        add_globals_from(_user_management);
+    }
+}
+
 // =============================================================================
 // SEED DATA - Creates initial admin user
 // =============================================================================
@@ -771,13 +873,13 @@ public class SeedData : Object {
 // =============================================================================
 
 private MainLoop main_loop;
-private Implexus.Core.Engine global_engine;
+private Connection global_connection;
 
 public static int main(string[] args) {
     int port = args.length > 1 ? int.parse(args[1]) : 8080;
     
     print("═══════════════════════════════════════════════════════════════\n");
-    print("              Spry Users Example - Complete Demo\n");
+    print("              Spry Authentication Example - Complete Demo\n");
     print("═══════════════════════════════════════════════════════════════\n");
     print("  Port: %d\n", port);
     print("═══════════════════════════════════════════════════════════════\n");
@@ -802,17 +904,13 @@ public static int main(string[] args) {
     main_loop = new MainLoop();
     
     try {
-        // 1. Create the embedded engine FIRST
-        print("Creating database engine...\n");
-        global_engine = EngineFactory.create_embedded();
-        
-        // 2. Run migrations to set up containers and catalogues
-        print("Running migrations...\n");
-        run_migrations.begin((obj, res) => {
+        // 1. Create the database connection FIRST
+        print("Creating database connection...\n");
+        initialize_database.begin((obj, res) => {
             try {
-                run_migrations.end(res);
+                initialize_database.end(res);
                 
-                // 3. Now start the application
+                // 2. Now start the application
                 start_application.begin(port, (obj, res) => {
                     try {
                         start_application.end(res);
@@ -822,7 +920,7 @@ public static int main(string[] args) {
                     }
                 });
             } catch (Error e) {
-                printerr("Migration error: %s\n", e.message);
+                printerr("Database initialization error: %s\n", e.message);
                 main_loop.quit();
             }
         });
@@ -837,23 +935,30 @@ public static int main(string[] args) {
 }
 
 /**
- * Run database migrations to set up the Users system structure.
+ * Initialize database connection and create schema.
  */
-private async void run_migrations() throws Error {
-    var migration = new MyAppUsersMigration();
-    yield migration.up_async(global_engine);
-    print("Migrations completed successfully.\n");
+private async void initialize_database() throws Error {
+    global_connection = yield create_database_connection();
+    yield initialize_database_schema(global_connection);
+    print("Database initialized successfully.\n");
 }
 
 /**
- * Start the web application after migrations are complete.
+ * Start the web application after database is initialized.
  */
 private async void start_application(int port) throws Error {
     var application = new WebApplication(port);
     
-    // Register the Engine in the container FIRST before any services
-    // This is critical - services use inject<Engine>() and need it to be available
-    application.add_singleton<Implexus.Core.Engine>(() => global_engine);
+    // Register the database Connection in the container FIRST before any services
+    // This is critical - repositories use inject<Connection>() and need it to be available
+    application.add_singleton<Connection>(() => global_connection);
+    
+    // Register repositories (new InvercargillSql-based implementations)
+    // Register the concrete implementation and expose it via the interface
+    application.container.register_singleton<SqlUserRepository>((scope) => new SqlUserRepository(scope.resolve<Connection>()))
+        .as<UserRepository>();
+    application.container.register_singleton<SqlSessionRepository>((scope) => new SqlSessionRepository(scope.resolve<Connection>()))
+        .as<SessionRepository>();
     
     // Enable compression
     application.use_compression();
@@ -861,12 +966,16 @@ private async void start_application(int port) throws Error {
     // Add Spry module for component actions
     application.add_module<SpryModule>();
     
-    // Register Users system services
-    // These use inject<Engine>() internally, so Engine must be registered first
+    // Register Authentication system services
+    // These now use repositories instead of Engine directly
     application.add_singleton<UserService>();
     application.add_singleton<SessionService>();
     application.add_singleton<PermissionService>();
     
+    // Register Authorisation system services
+    application.add_singleton<AuthorisationTokenService>();
+    application.add_singleton<AuthorisationContext>();
+    
     // Seed initial data (admin user, test user)
     application.add_singleton<CryptographyProvider>();
     seed_initial_data.begin(application.container);
@@ -892,18 +1001,17 @@ private async void start_application(int port) throws Error {
     // Register logout endpoint
     application.add_endpoint<LogoutEndpoint>(new EndpointRoute("/logout"));
     
-    // Register UserManagementPage (admin page)
-    application.add_transient<UserManagementPage>();
-    application.add_endpoint<UserManagementPage>(new EndpointRoute("/admin/users"));
+    // Register UserAdminPage (admin page wrapping UserManagementComponent)
+    application.add_transient<UserAdminPage>();
+    application.add_endpoint<UserAdminPage>(new EndpointRoute(UserAdminPage.ROUTE));
     
     // Register LoginFormComponent (used by LoginPage)
     application.add_transient<LoginFormComponent>();
     
-    // Register child components used by UserManagementPage
-    application.add_transient<UserListComponent>();
-    application.add_transient<UserListItemComponent>();
-    application.add_transient<UserFormComponent>();
-    application.add_transient<PermissionEditorComponent>();
+    // Register new user management components
+    application.add_transient<UserManagementComponent>();
+    application.add_transient<UserDetailsComponent>();
+    application.add_transient<NewUserComponent>();
     
     // Register CSS as FastResource
     application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/main.css"), () => {

+ 2 - 2
examples/meson.build

@@ -33,11 +33,11 @@ executable('progress-example',
     install: false
 )
 
-# UsersExample - demonstrates the complete Spry Users system
+# UsersExample - demonstrates the complete Spry Authentication/Authorisation system
 # Includes: user registration, authentication, permissions, protected pages
 executable('users-example',
     'UsersExample.vala',
-    dependencies: [spry_dep, spry_users_dep, astralis_dep, invercargill_dep, inversion_dep, implexus_dep],
+    dependencies: [spry_dep, spry_authentication_dep, spry_authorisation_dep, astralis_dep, invercargill_dep, inversion_dep, invercargill_sql_dep, sqlite_dep],
     install: false
 )
 

+ 4 - 4
important-details.md

@@ -121,8 +121,8 @@ public override string markup { get {
     <div spry-continuation>
         <div class="progress-container" spry-dynamic="progress-bar">
             <div class="progress-bar" spry-unique
-                 content-expr='format("%i%%", this.percent)' 
-                 style-width-expr='format("%i%%", this.percent)'>
+                 content-expr='format("{{this.percent}}%")' 
+                 style-width-expr='format("{{this.percent}}%")'>
                 0%
             </div>
         </div>
@@ -280,8 +280,8 @@ class ProgressComponent : Component {
     public override string markup { get {
         return """
         <div class="progress-bar" 
-             content-expr='format("%i%%", this.percent)' 
-             style-width-expr='format("%i%%", this.percent)'>
+             content-expr='format("{{this.percent}}%")' 
+             style-width-expr='format("{{this.percent}}%")'>
             0%
         </div>
         """;

+ 453 - 0
invercargill-format-migration-details.md

@@ -0,0 +1,453 @@
+# Format Function v2 Migration Guide
+
+## Overview
+
+The `format()` global function in the Invercargill expression system has been redesigned from printf-style formatting to handlebars-style interpolation. This is a **breaking change** that affects all code using the `format()` function.
+
+### What Changed
+
+| Aspect | Before (v1) | After (v2) |
+|--------|-------------|------------|
+| Syntax | `%s`, `%d`, `%f`, etc. | `{{expression}}` |
+| Arguments | Template + variadic args | Template only |
+| Value source | Positional arguments | EvaluationContext |
+| Precision | `%.2f`, `%5d` supported | No format specifiers |
+
+### Why This Change
+
+1. **Consistency**: Handlebars-style interpolation is more widely recognized in modern templating
+2. **Expressiveness**: Full expressions can now be evaluated inside interpolation blocks
+3. **Context integration**: Values come from the EvaluationContext, enabling more dynamic templates
+4. **Readability**: `{{name}}` is more self-documenting than `%s` with positional arguments
+
+---
+
+## Breaking Changes
+
+### Removed Features
+
+1. **Format specifiers** - All printf-style specifiers removed:
+   - `%s` (string)
+   - `%d`, `%i` (integer)
+   - `%f` (float)
+   - `%.Nf` (float with precision)
+   - `%x`, `%X` (hexadecimal)
+   - `%o` (octal)
+   - `%%` (literal percent)
+
+2. **Precision/width control** - No more `%.2f` or `%10s` formatting
+
+3. **Positional arguments** - No additional arguments after the template
+
+4. **Length modifiers** - No more `ll`, `h`, `l`, etc.
+
+### Error Handling Changes
+
+| Scenario | Old Behavior | New Behavior |
+|----------|--------------|--------------|
+| Missing argument | Error at runtime | N/A - no positional args |
+| Unclosed `{{` | N/A | `INVALID_SYNTAX` error |
+| Invalid expression in `{{}}` | N/A | `INVALID_SYNTAX` with details |
+| Missing variable | N/A | `NON_EXISTANT_PROPERTY` error |
+
+---
+
+## Migration Examples
+
+### Basic String Substitution
+
+**Before:**
+```javascript
+format("Hello, %s!", name)
+```
+
+**After:**
+```javascript
+format("Hello, {{name}}!")
+```
+
+### Integer Formatting
+
+**Before:**
+```javascript
+format("You have %d items", count)
+format("ID: %i", id)
+```
+
+**After:**
+```javascript
+format("You have {{count}} items")
+format("ID: {{id}}")
+```
+
+### Multiple Arguments
+
+**Before:**
+```javascript
+format("%s #%i: %s", category, id, title)
+```
+
+**After:**
+```javascript
+format("{{category}} #{{id}}: {{title}}")
+```
+
+### Float/Double Values
+
+**Before:**
+```javascript
+format("Price: %.2f", price)  // With precision
+format("Value: %f", amount)   // Without precision
+```
+
+**After:**
+```javascript
+format("Price: {{price}}")    // No precision control
+format("Value: {{amount}}")
+```
+
+> **Note:** Precision formatting is not currently supported. If you need formatted numbers, consider pre-formatting values before passing to the expression context.
+
+### Hexadecimal Output
+
+**Before:**
+```javascript
+format("Hex: 0x%x", value)
+format("HEX: 0x%X", value)
+```
+
+**After:**
+```javascript
+// No direct equivalent - use stringify with pre-formatted value
+// Or add a hex() helper function to your context
+```
+
+### Literal Percent Sign
+
+**Before:**
+```javascript
+format("100%% complete")
+```
+
+**After:**
+```javascript
+format("100% complete")  // % is now a literal character
+```
+
+---
+
+## New Features
+
+### Expression Evaluation
+
+The new `format()` supports full expressions inside interpolation blocks:
+
+```javascript
+// Arithmetic
+format("Sum: {{a + b}}")
+format("Product: {{x * y}}")
+format("Total: {{price * quantity}}")
+
+// Comparisons (outputs "true" or "false")
+format("Is equal: {{a == b}}")
+format("Is valid: {{age >= 18}}")
+
+// Ternary expressions
+format("Status: {{active ? 'active' : 'inactive'}}")
+format("Role: {{isAdmin ? 'Administrator' : 'User'}}")
+
+// Complex expressions
+format("Result: {{(a + b) * c}}")
+```
+
+### Property Access
+
+Access nested properties directly in templates:
+
+```javascript
+// Object property access
+format("User: {{user.name}}")
+format("Email: {{user.email}}")
+
+// Deep nesting
+format("City: {{user.address.city}}")
+format("ZIP: {{user.address.zipCode}}")
+
+// Chained method calls
+format("First: {{items.first().name}}")
+format("Count: {{items.count()}}")
+```
+
+### Whitespace Handling
+
+Whitespace inside `{{}}` is automatically trimmed:
+
+```javascript
+format("Value: {{name}}")      // Standard
+format("Value: {{ name }}")    // With spaces - equivalent
+format("Value: {{  name  }}")  // Multiple spaces - equivalent
+```
+
+### Empty Expressions
+
+Empty expressions produce empty strings:
+
+```javascript
+format("Hello{{}}World")       // → "HelloWorld"
+format("Hello{{ }}World")      // → "HelloWorld"
+```
+
+---
+
+## Escape Sequences
+
+To include literal braces in your output, use backslash escaping:
+
+### Literal `{{`
+
+```javascript
+format("Template syntax: \\{{name}}")
+// Output: "Template syntax: {{name}}"
+```
+
+### Literal `}}`
+
+```javascript
+format("End of block: \\}}")
+// Output: "End of block: }}"
+```
+
+### Literal Backslash
+
+```javascript
+format("Path: C:\\\\Users")
+// Output: "Path: C:\Users"
+```
+
+### Escape Sequence Summary
+
+| Sequence | Output |
+|----------|--------|
+| `\{{` | `{{` |
+| `\}}` | `}}` |
+| `\\` | `\` |
+
+---
+
+## The `stringify()` Function
+
+A new `stringify()` global function is available as an alternative for simple concatenation:
+
+### Basic Usage
+
+```javascript
+stringify(42)                          // → "42"
+stringify("Hello", " ", "World")       // → "Hello World"
+stringify(1, " + ", 2, " = ", 3)       // → "1 + 2 = 3"
+```
+
+### With Variables
+
+```javascript
+stringify("The answer is: ", answer)
+stringify(name, " is ", age, " years old")
+```
+
+### With Expressions
+
+```javascript
+stringify("Sum: ", 2 + 3)              // → "Sum: 5"
+stringify("Is active: ", active)       // → "Is active: true"
+```
+
+### Null Handling
+
+```javascript
+stringify(null)                        // → "" (empty string)
+stringify("Value: ", null)             // → "Value: "
+```
+
+### Boolean Output
+
+```javascript
+stringify(true, " or ", false)         // → "true or false"
+```
+
+### When to Use `stringify()` vs `format()`
+
+| Use `format()` when... | Use `stringify()` when... |
+|------------------------|---------------------------|
+| You have a template string | You need simple concatenation |
+| You want inline expressions | Values are pre-computed |
+| The structure is complex | The output is straightforward |
+| You need escape sequences | You don't need templating |
+
+---
+
+## Error Handling
+
+### Common Errors
+
+#### Unclosed Expression
+
+```javascript
+format("Hello {{name")
+// Error: INVALID_SYNTAX - "Unclosed expression at position 6: missing }}"
+```
+
+**Fix:** Ensure all `{{` have matching `}}`
+
+```javascript
+format("Hello {{name}}")
+```
+
+#### Invalid Expression Syntax
+
+```javascript
+format("Value: {{1 +}}")
+// Error: INVALID_SYNTAX - "Error in expression '1 +': ..."
+```
+
+**Fix:** Ensure expressions are syntactically valid
+
+```javascript
+format("Value: {{1 + 2}}")
+```
+
+#### Missing Variable
+
+```javascript
+format("Hello {{name}}")
+// Error: NON_EXISTANT_PROPERTY if 'name' not in context
+```
+
+**Fix:** Ensure all referenced variables exist in the EvaluationContext
+
+```javascript
+// Before evaluating, ensure context has the variable:
+props.set("name", new NativeElement<string?>("World"));
+```
+
+---
+
+## Migration Checklist
+
+- [ ] Identify all `format()` calls in your codebase
+- [ ] Replace `%s` with `{{variableName}}`
+- [ ] Replace `%d`, `%i` with `{{variableName}}`
+- [ ] Replace `%f` with `{{variableName}}`
+- [ ] Remove variadic arguments from `format()` calls
+- [ ] Ensure variables are available in EvaluationContext
+- [ ] Update any `%%` to single `%`
+- [ ] Remove precision specifiers (`%.2f`) - pre-format if needed
+- [ ] Consider using `stringify()` for simple concatenation
+- [ ] Test all format strings with edge cases
+
+---
+
+## FAQ
+
+### Q: How do I format numbers with precision?
+
+**A:** Precision formatting is not currently supported in `format()`. Options:
+
+1. Pre-format values before adding to context:
+   ```vala
+   var formatted_price = "%.2f".printf(price);
+   props.set("price", new NativeElement<string?>(formatted_price));
+   ```
+   
+2. Use `stringify()` with pre-formatted values:
+   ```javascript
+   stringify("$", formatted_price)
+   ```
+
+### Q: How do I output a literal `{{` in my template?
+
+**A:** Use the escape sequence `\{{`:
+```javascript
+format("Use \\{{variable}} for interpolation")
+// Output: "Use {{variable}} for interpolation"
+```
+
+### Q: What happens if a variable is missing from the context?
+
+**A:** A `NON_EXISTANT_PROPERTY` error is thrown. Ensure all variables referenced in templates exist in the EvaluationContext before evaluation.
+
+### Q: Can I use nested expressions like `{{a {{b}} c}}`?
+
+**A:** No, nested expressions are not supported. The first `}}` will close the expression block. Consider restructuring your template or pre-computing values.
+
+### Q: How do I convert from hexadecimal formatting?
+
+**A:** There is no direct equivalent. Pre-format the hexadecimal string before adding to context:
+```vala
+var hex_value = "0x%x".printf(value);
+props.set("hex", new NativeElement<string?>(hex_value));
+```
+
+### Q: Can I still use `format()` with no placeholders?
+
+**A:** Yes, templates without `{{}}` are returned as-is:
+```javascript
+format("Just a plain string")  // → "Just a plain string"
+```
+
+### Q: What is the difference between `to_string()` and `stringify()`?
+
+**A:** 
+- `Element.to_string()` returns a debug representation like `Element[string]`
+- `Element.stringify()` returns the actual value as a string (e.g., `"hello"` or `"42"`)
+- `stringify()` global function concatenates multiple stringified values
+
+### Q: How do null values behave?
+
+**A:** Null values produce empty strings:
+```javascript
+format("Value: '{{value}}'")  // With null value → "Value: ''"
+stringify(null)                // → ""
+```
+
+---
+
+## Complete Migration Example
+
+### Before (v1)
+
+```vala
+// Setting up context
+var props = new PropertyDictionary();
+props.set("category", new NativeElement<string?>("Product"));
+props.set("id", new NativeElement<int?>(123));
+props.set("title", new NativeElement<string?>("Widget"));
+props.set("price", new NativeElement<double?>(19.99));
+props.set("discount", new NativeElement<double?>(0.15));
+var context = new EvaluationContext(props);
+
+// Format call with multiple arguments
+var expr = ExpressionParser.parse(
+    "format(\"%s #%d: %s - $%.2f (%.0f%% off)\", category, id, title, price, discount * 100)"
+);
+var result = expr.evaluate(context);
+// Output: "Product #123: Widget - $19.99 (15% off)"
+```
+
+### After (v2)
+
+```vala
+// Setting up context - same as before
+var props = new PropertyDictionary();
+props.set("category", new NativeElement<string?>("Product"));
+props.set("id", new NativeElement<int?>(123));
+props.set("title", new NativeElement<string?>("Widget"));
+props.set("price", new NativeElement<double?>(19.99));
+props.set("discount", new NativeElement<double?>(0.15));
+var context = new EvaluationContext(props);
+
+// Format call - template only, expressions inline
+var expr = ExpressionParser.parse(
+    "format(\"{{category}} #{{id}}: {{title}} - ${{price}} ({{discount * 100}}% off)\")"
+);
+var result = expr.evaluate(context);
+// Output: "Product #123: Widget - $19.99 (15% off)"
+```

+ 3 - 2
meson.build

@@ -13,8 +13,9 @@ inversion_dep = dependency('inversion-0.1')
 astralis_dep = dependency('astralis-0.1')
 json_glib_dep = dependency('json-glib-1.0')
 invercargill_json_dep = dependency('invercargill-json')
+invercargill_sql_dep = dependency('invercargill-sql', required: true)
+sqlite_dep = dependency('sqlite3')
 libxml_dep = dependency('libxml-2.0')
-implexus_dep = dependency('implexus-0.1')
 sodium_vapi = files('vapi/libsodium.vapi')
 sodium_c_lib = meson.get_compiler('c').find_library('sodium', required: true)
 sodium_deps = declare_dependency(sources: sodium_vapi, dependencies: sodium_c_lib)
@@ -23,7 +24,7 @@ sodium_deps = declare_dependency(sources: sodium_vapi, dependencies: sodium_c_li
 add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 
 subdir('src')
-subdir('src/Users')
+subdir('src/Authentication')
 subdir('examples')
 subdir('tools')
 subdir('website')

+ 1324 - 0
plans/auth-refactor-architecture.md

@@ -0,0 +1,1324 @@
+# Spry Authentication & Authorisation Refactor Architecture
+
+## 1. Overview
+
+This document describes the architecture for refactoring Spry's `src/Users` system into two separate concerns:
+
+- **`src/Authentication`** (renamed from `src/Users`): ONE method of authentication provided by Spry
+- **`src/Authorisation`**: Token generation, validation, and permission checking
+
+### Key Design Principles
+
+1. **Separation of Concerns**: Authentication (who you are) is separate from Authorisation (what you can do)
+2. **Identity Abstraction**: Authorisation works with a generic `Identity` interface, not a concrete User model
+3. **Stateless Tokens**: Tokens are self-contained and validated cryptographically
+4. **Multiple Token Delivery**: Same token via cookie or Bearer header
+5. **Extensibility**: Applications can implement custom authentication while using Spry's Authorisation
+
+---
+
+## 2. High-Level Architecture
+
+```mermaid
+flowchart TB
+    subgraph Request Flow
+        Request[HTTP Request] --> TokenExtractor
+        TokenExtractor --> TokenValidator
+        TokenValidator --> AuthorisationContext
+    end
+    
+    subgraph Authorisation System
+        TokenExtractor[TokenExtractor]
+        TokenValidator[TokenValidator]
+        AuthorisationContext[AuthorisationContext]
+        AuthorisationService[AuthorisationService]
+    end
+    
+    subgraph Authentication System
+        IdentityProvider[IdentityProvider Interface]
+        UserIdentityProvider[UserIdentityProvider]
+        UserService[UserService]
+        SessionService[SessionService]
+        LoginFormComponent[LoginFormComponent]
+    end
+    
+    subgraph Storage
+        Implexus[(Implexus Storage)]
+    end
+    
+    TokenExtractor --> |Cookie or Bearer| TokenValidator
+    TokenValidator --> |Valid Token| AuthorisationContext
+    AuthorisationContext --> |get_current_identity| IdentityProvider
+    IdentityProvider --> |implemented by| UserIdentityProvider
+    UserIdentityProvider --> UserService
+    UserIdentityProvider --> SessionService
+    UserService --> Implexus
+    SessionService --> Implexus
+    LoginFormComponent --> UserService
+    LoginFormComponent --> SessionService
+    LoginFormComponent --> AuthorisationService
+```
+
+---
+
+## 3. File Structure
+
+### 3.1 New `src/Authorisation` Structure
+
+```
+src/Authorisation/
+├── ARCHITECTURE.md              # This document
+├── meson.build                  # Build configuration
+├── Identity.vala                # Identity interface
+├── AuthorisationContext.vala    # Request-scoped authorisation state
+├── AuthorisationService.vala    # Token generation and validation
+├── TokenPayload.vala            # Token data structure
+├── AuthorisationError.vala      # Error domain
+└── AuthorisationModule.vala     # IoC module registration
+```
+
+### 3.2 Refactored `src/Authentication` Structure (renamed from `src/Users`)
+
+```
+src/Authentication/
+├── ARCHITECTURE.md              # Architecture documentation
+├── meson.build                  # Build configuration
+├── User.vala                    # User data model
+├── Session.vala                 # Session data model
+├── UserService.vala             # User CRUD and authentication
+├── SessionService.vala          # Session management
+├── PermissionService.vala       # Permission management on users
+├── UsersMigration.vala          # Database migrations
+├── UserIdentityProvider.vala    # IdentityProvider implementation for User
+├── AuthenticationModule.vala    # IoC module registration
+└── Components/
+    ├── LoginFormComponent.vala
+    ├── UserFormComponent.vala
+    ├── UserListComponent.vala
+    ├── UserListItemComponent.vala
+    ├── PermissionEditorComponent.vala
+    └── UserManagementPage.vala
+```
+
+---
+
+## 4. Core Interfaces and Classes
+
+### 4.1 Identity Interface
+
+The `Identity` interface is the contract between Authentication and Authorisation. Any authentication provider must implement this interface.
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * Interface representing an authenticated identity.
+     * 
+     * Implementations provide identity data that gets embedded in tokens
+     * and can be retrieved on subsequent requests.
+     * 
+     * Built-in implementation: Spry.Authentication.UserIdentityProvider
+     * Custom implementations: OAuth providers, certificate auth, etc.
+     */
+    public interface Identity : GLib.Object {
+
+        /**
+         * Unique identifier for this identity.
+         * Used to look up the full identity object.
+         */
+        public abstract string id { get; }
+
+        /**
+         * Human-readable name for this identity.
+         * Typically username or email.
+         */
+        public abstract string username { get; }
+
+        /**
+         * Permissions granted to this identity.
+         * Returns ImmutableLot for thread-safety.
+         */
+        public abstract ImmutableLot<string> permissions { get; }
+
+        /**
+         * Additional data to embed in the token.
+         * Implementation-specific data (e.g., roles, preferences).
+         */
+        public abstract Properties token_data { get; }
+    }
+}
+```
+
+### 4.2 IdentityProvider Interface
+
+The `IdentityProvider` interface allows the Authorisation system to retrieve full identity objects.
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * Interface for retrieving Identity objects by ID.
+     * 
+     * The AuthorisationContext uses this to get_current_identity().
+     * Applications register their implementation during startup.
+     */
+    public interface IdentityProvider : GLib.Object {
+
+        /**
+         * Retrieves an Identity by its unique ID.
+         * 
+         * @param id The identity ID from the token
+         * @return The Identity, or null if not found/inactive
+         */
+        public abstract async Identity? get_identity_async(string id) throws Error;
+    }
+}
+```
+
+### 4.3 TokenPayload Model
+
+The `TokenPayload` contains all data embedded in the encrypted token.
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * Data structure embedded in authorisation tokens.
+     * 
+     * Token contents (JSON, encrypted and signed):
+     * - id: Identity unique identifier
+     * - username: Human-readable name
+     * - permissions: Array of permission strings
+     * - data: Additional properties
+     * - issued_at: Token issuance timestamp
+     * - expires_at: Token expiry timestamp
+     */
+    public class TokenPayload : GLib.Object {
+
+        // Identity fields
+        public string id { get; set; }
+        public string username { get; set; }
+        public ImmutableLot<string> permissions { get; set; }
+        public Properties data { get; set; }
+
+        // Token metadata
+        public DateTime issued_at { get; set; }
+        public DateTime expires_at { get; set; }
+
+        /**
+         * Creates a TokenPayload from an Identity.
+         */
+        public TokenPayload.from_identity(Identity identity, TimeSpan duration) {
+            GLib.Object(
+                id: identity.id,
+                username: identity.username,
+                permissions: identity.permissions,
+                data: identity.data,
+                issued_at: new DateTime.now_utc(),
+                expires_at: new DateTime.now_utc().add(duration)
+            );
+        }
+
+        /**
+         * Checks if the token has expired.
+         */
+        public bool is_expired() {
+            return expires_at.compare(new DateTime.now_utc()) <= 0;
+        }
+
+        /**
+         * Serializes to JSON for token encryption.
+         */
+        public Json.Object to_json() {
+            var obj = new Json.Object();
+            obj.set_string_member("id", id ?? "");
+            obj.set_string_member("username", username ?? "");
+            
+            // Permissions array
+            var perms_array = new Json.Array();
+            foreach (var perm in permissions) {
+                perms_array.add_string_element(perm);
+            }
+            obj.set_array_member("permissions", perms_array);
+            
+            // Data object
+            var data_obj = new Json.Object();
+            if (data != null) {
+                var iter = data.iterator();
+                while (iter.next()) {
+                    var pair = iter.get();
+                    // Serialize based on type
+                    data_obj.set_string_member(pair.key, pair.value.to_string());
+                }
+            }
+            obj.set_object_member("data", data_obj);
+            
+            // Metadata
+            obj.set_string_member("issued_at", issued_at.format_iso8601());
+            obj.set_string_member("expires_at", expires_at.format_iso8601());
+            
+            return obj;
+        }
+
+        /**
+         * Deserializes from JSON after token decryption.
+         */
+        public static TokenPayload from_json(Json.Object obj) {
+            var payload = new TokenPayload();
+            payload.id = obj.get_string_member("id") ?? "";
+            payload.username = obj.get_string_member("username") ?? "";
+            
+            // Permissions
+            var perms = new LotBuilder<string>();
+            if (obj.has_member("permissions")) {
+                var arr = obj.get_array_member("permissions");
+                foreach (var element in arr.get_elements()) {
+                    perms.add(element.get_string() ?? "");
+                }
+            }
+            payload.permissions = perms.to_immutable();
+            
+            // Data
+            payload.data = new PropertyDictionary();
+            if (obj.has_member("data")) {
+                var data_obj = obj.get_object_member("data");
+                foreach (var member in data_obj.get_members()) {
+                    payload.data.set(member, new NativeElement<string>(
+                        data_obj.get_string_member(member) ?? ""
+                    ));
+                }
+            }
+            
+            // Metadata
+            if (obj.has_member("issued_at")) {
+                payload.issued_at = new DateTime.from_iso8601(
+                    obj.get_string_member("issued_at"), 
+                    new TimeZone.utc()
+                );
+            }
+            if (obj.has_member("expires_at")) {
+                payload.expires_at = new DateTime.from_iso8601(
+                    obj.get_string_member("expires_at"), 
+                    new TimeZone.utc()
+                );
+            }
+            
+            return payload;
+        }
+    }
+}
+```
+
+### 4.4 AuthorisationContext
+
+The `AuthorisationContext` is the primary interface for applications to check authorisation.
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * Request-scoped authorisation context.
+     * 
+     * Provides access to the current identity's authorisation state.
+     * Automatically populated from cookie or Bearer token on each request.
+     * 
+     * Usage:
+     *   var auth = inject<AuthorisationContext>();
+     *   if (!auth.is_authorised) {
+     *       // Redirect to login
+     *   }
+     *   if (auth.has_permission("admin")) {
+     *       // Show admin content
+     *   }
+     */
+    public class AuthorisationContext : GLib.Object {
+
+        private TokenPayload? _payload = null;
+        private IdentityProvider? _identity_provider = null;
+        private Identity? _cached_identity = null;
+
+        /**
+         * Whether the request has a valid authorisation token.
+         */
+        public bool is_authorised { get { return _payload != null; } }
+
+        /**
+         * The identity ID from the token.
+         * Returns null if not authorised.
+         */
+        public string? user_id { 
+            get { return _payload?.id; } 
+        }
+
+        /**
+         * The username from the token.
+         * Returns null if not authorised.
+         */
+        public string? username { 
+            get { return _payload?.username; } 
+        }
+
+        /**
+         * The permissions from the token.
+         * Returns empty lot if not authorised.
+         */
+        public ImmutableLot<string> permissions { 
+            get { return _payload?.permissions ?? new ImmutableLot<string>(); } 
+        }
+
+        /**
+         * Additional data from the token.
+         * Returns empty properties if not authorised.
+         */
+        public Properties data { 
+            get { return _payload?.data ?? new PropertyDictionary(); } 
+        }
+
+        /**
+         * The token payload (for advanced use).
+         */
+        public TokenPayload? payload { get { return _payload; } }
+
+        /**
+         * Sets the token payload (called by AuthorisationService).
+         */
+        internal void set_payload(TokenPayload? payload) {
+            _payload = payload;
+            _cached_identity = null;
+        }
+
+        /**
+         * Sets the identity provider (called by IoC during initialization).
+         */
+        internal void set_identity_provider(IdentityProvider? provider) {
+            _identity_provider = provider;
+        }
+
+        /**
+         * Checks if the current identity has a specific permission.
+         * 
+         * Supports wildcard matching:
+         * - "admin" matches everything
+         * - "user-*" matches "user-create", "user-delete", etc.
+         * - "*" matches everything
+         * 
+         * @param permission The permission to check
+         * @return true if the identity has the permission
+         */
+        public bool has_permission(string permission) {
+            if (_payload == null) return false;
+
+            foreach (var user_perm in _payload.permissions) {
+                if (permission_matches(user_perm, permission)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Requires a specific permission, throws if not present.
+         * 
+         * @param permission The required permission
+         * @throws AuthorisationError.PERMISSION_DENIED if not authorised
+         */
+        public void require_permission(string permission) throws Error {
+            if (!is_authorised) {
+                throw new AuthorisationError.NOT_AUTHORISED(
+                    "Authentication required"
+                );
+            }
+            if (!has_permission(permission)) {
+                throw new AuthorisationError.PERMISSION_DENIED(
+                    @"Permission '$permission' required"
+                );
+            }
+        }
+
+        /**
+         * Checks if the identity has ANY of the specified permissions.
+         */
+        public bool has_any_permission(Lot<string> permissions) {
+            foreach (var perm in permissions) {
+                if (has_permission(perm)) return true;
+            }
+            return false;
+        }
+
+        /**
+         * Checks if the identity has ALL of the specified permissions.
+         */
+        public bool has_all_permissions(Lot<string> permissions) {
+            foreach (var perm in permissions) {
+                if (!has_permission(perm)) return false;
+            }
+            return true;
+        }
+
+        /**
+         * Retrieves the full Identity object from the provider.
+         * 
+         * This calls the registered IdentityProvider to get the complete
+         * identity object (e.g., User from Spry.Authentication).
+         * 
+         * @return The Identity, or null if not found
+         */
+        public async Identity? get_current_identity_async() throws Error {
+            if (_payload == null) return null;
+            if (_cached_identity != null) return _cached_identity;
+            if (_identity_provider == null) return null;
+
+            _cached_identity = yield _identity_provider.get_identity_async(_payload.id);
+            return _cached_identity;
+        }
+
+        /**
+         * Synchronous version for cases where async is not available.
+         */
+        public Identity? get_current_identity() {
+            if (_payload == null) return null;
+            if (_cached_identity != null) return _cached_identity;
+            // Cannot call async - return null
+            return null;
+        }
+
+        // Private helper for permission matching
+        private bool permission_matches(string pattern, string permission) {
+            if (pattern == "admin" || pattern == "*") return true;
+            if (pattern == permission) return true;
+            if (pattern.has_suffix("*")) {
+                string prefix = pattern.substring(0, pattern.length - 1);
+                return permission.has_prefix(prefix);
+            }
+            return false;
+        }
+    }
+}
+```
+
+### 4.5 AuthorisationService
+
+The `AuthorisationService` handles token generation, extraction, and validation.
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * Service for generating and validating authorisation tokens.
+     * 
+     * Token Sources (in order of precedence):
+     * 1. Authorization: Bearer <token> header
+     * 2. Cookie named in cookie_name property
+     * 
+     * Token Format:
+     * - JSON payload containing identity data and metadata
+     * - Signed with Ed25519 (server signing key)
+     * - Encrypted with X25519 (server sealing key)
+     * - Base64url encoded
+     */
+    public class AuthorisationService : GLib.Object {
+
+        private CryptographyProvider _crypto = inject<CryptographyProvider>();
+        private AuthorisationContext _context = inject<AuthorisationContext>();
+        private IdentityProvider? _identity_provider = inject<IdentityProvider>();
+        private HttpContext _http_context = inject<HttpContext>();
+
+        // Configuration
+        private string _cookie_name = "spry_session";
+        private bool _cookie_secure = true;
+        private TimeSpan _token_duration = TimeSpan.HOUR * 24;
+
+        /**
+         * Cookie name for session tokens.
+         */
+        public string cookie_name { 
+            get { return _cookie_name; } 
+            set { _cookie_name = value; }
+        }
+
+        /**
+         * Whether cookies should be Secure (HTTPS only).
+         */
+        public bool cookie_secure { 
+            get { return _cookie_secure; } 
+            set { _cookie_secure = value; }
+        }
+
+        /**
+         * Default token validity duration.
+         */
+        public TimeSpan token_duration { 
+            get { return _token_duration; } 
+            set { _token_duration = value; }
+        }
+
+        /**
+         * Creates a new AuthorisationService.
+         */
+        public AuthorisationService() {
+            // Set up identity provider in context if available
+            if (_identity_provider != null) {
+                _context.set_identity_provider(_identity_provider);
+            }
+        }
+
+        // =========================================================================
+        // Token Generation
+        // =========================================================================
+
+        /**
+         * Generates an authorisation token for an identity.
+         * 
+         * @param identity The identity to create a token for
+         * @param duration Optional custom duration (defaults to token_duration)
+         * @return The encrypted token string
+         */
+        public string generate_token(Identity identity, TimeSpan? duration = null) {
+            var actual_duration = duration ?? _token_duration;
+            var payload = new TokenPayload.from_identity(identity, actual_duration);
+            return generate_token_from_payload(payload);
+        }
+
+        /**
+         * Generates a token from an existing payload.
+         */
+        public string generate_token_from_payload(TokenPayload payload) {
+            var json_obj = payload.to_json();
+            var node = new Json.Node(Json.NodeType.OBJECT);
+            node.set_object(json_obj);
+            var json_str = Json.to_string(node, false);
+
+            // Sign and seal using CryptographyProvider
+            return _crypto.sign_then_seal_token(json_str, payload.expires_at);
+        }
+
+        // =========================================================================
+        // Token Validation
+        // =========================================================================
+
+        /**
+         * Validates a token string and returns the payload.
+         * 
+         * @param token The encrypted token string
+         * @return The TokenPayload, or null if invalid
+         */
+        public TokenPayload? validate_token(string token) {
+            try {
+                var result = _crypto.unseal_then_verify_token(token);
+                
+                if (!result.is_valid || result.is_expired) {
+                    return null;
+                }
+
+                var json_str = result.payload;
+                if (json_str == null) return null;
+
+                var parser = new Json.Parser();
+                parser.load_from_data((!)json_str);
+                var root = parser.get_root();
+                
+                if (root.get_node_type() != Json.NodeType.OBJECT) {
+                    return null;
+                }
+
+                var payload = TokenPayload.from_json(root.get_object());
+                
+                // Double-check expiry
+                if (payload.is_expired()) {
+                    return null;
+                }
+
+                return payload;
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        // =========================================================================
+        // Request Processing
+        // =========================================================================
+
+        /**
+         * Extracts and validates token from the current HTTP request.
+         * 
+         * Checks Authorization header first, then cookie.
+         * Populates the AuthorisationContext if valid token found.
+         */
+        public async void process_request_async() throws Error {
+            string? token = null;
+
+            // 1. Check Authorization: Bearer header
+            var auth_header = _http_context.request.headers.get_any_or_default(
+                "Authorization", 
+                null
+            );
+            if (auth_header != null && auth_header.has_prefix("Bearer ")) {
+                token = ((!)auth_header).substring(7).strip();
+            }
+
+            // 2. Check cookie
+            if (token == null) {
+                token = _http_context.request.get_cookie(_cookie_name);
+            }
+
+            // 3. Validate and populate context
+            if (token != null) {
+                var payload = validate_token((!)token);
+                if (payload != null) {
+                    _context.set_payload(payload);
+                }
+            }
+        }
+
+        // =========================================================================
+        // Cookie Management
+        // =========================================================================
+
+        /**
+         * Sets the authorisation cookie on an HTTP response.
+         * 
+         * @param result The HttpResult to set the cookie on
+         * @param token The token to set
+         */
+        public void set_cookie(HttpResult result, string token) {
+            var max_age = (int)(_token_duration / TimeSpan.SECOND);
+            var cookie_value = @"$_cookie_name=$token; Path=/; Max-Age=$max_age; HttpOnly";
+            
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+            
+            cookie_value += "; SameSite=Strict";
+            
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        /**
+         * Clears the authorisation cookie.
+         */
+        public void clear_cookie(HttpResult result) {
+            var cookie_value = @"$_cookie_name=; Path=/; Max-Age=0; HttpOnly";
+            
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+            
+            cookie_value += "; SameSite=Strict";
+            
+            result.set_header("Set-Cookie", cookie_value);
+        }
+    }
+}
+```
+
+### 4.6 AuthorisationError
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * Error domain for authorisation-related errors.
+     */
+    public errordomain AuthorisationError {
+        NOT_AUTHORISED,
+        PERMISSION_DENIED,
+        INVALID_TOKEN,
+        TOKEN_EXPIRED,
+        IDENTITY_NOT_FOUND
+    }
+}
+```
+
+---
+
+## 5. Authentication System Refactoring
+
+### 5.1 UserIdentityProvider
+
+The `UserIdentityProvider` implements `IdentityProvider` for the User model.
+
+```vala
+namespace Spry.Authentication {
+
+    /**
+     * IdentityProvider implementation for Spry's built-in User model.
+     * 
+     * This bridges the Authentication User model with the Authorisation
+     * Identity interface.
+     */
+    public class UserIdentityProvider : GLib.Object, Authorisation.IdentityProvider {
+
+        private UserService _user_service = inject<UserService>();
+
+        /**
+         * Retrieves a User as an Identity by ID.
+         */
+        public async Authorisation.Identity? get_identity_async(string id) throws Error {
+            var user = yield _user_service.get_user_async(id);
+            if (user == null) return null;
+            
+            // User implements Identity, so we can return directly
+            return user as Authorisation.Identity;
+        }
+    }
+}
+```
+
+### 5.2 User Model (Updated)
+
+The `User` model now implements the `Identity` interface.
+
+```vala
+namespace Spry.Authentication {
+
+    /**
+     * User data model implementing the Identity interface.
+     */
+    public class User : GLib.Object, Authorisation.Identity {
+
+        // Identity fields (from interface)
+        public string id { get; set; }
+        public string username { get; set; }
+        
+        // User-specific fields
+        public string email { get; set; }
+        public string password_hash { get; set; }
+        public DateTime created_at { get; set; }
+        public DateTime? updated_at { get; set; }
+
+        // Permissions stored on user
+        private Vector<string> _permissions = new Vector<string>();
+        
+        // Application data
+        private Dictionary<string, string> _app_data = new Dictionary<string, string>();
+
+        // =========================================================================
+        // Identity Interface Implementation
+        // =========================================================================
+
+        /**
+         * Unique identifier (Identity interface).
+         */
+        public string identity_id { get { return id; } }
+
+        /**
+         * Human-readable name (Identity interface).
+         */
+        public string identity_username { get { return username; } }
+
+        /**
+         * Permissions as ImmutableLot (Identity interface).
+         */
+        public ImmutableLot<string> identity_permissions {
+            get {
+                var builder = new LotBuilder<string>();
+                foreach (var perm in _permissions) {
+                    builder.add(perm);
+                }
+                return builder.to_immutable();
+            }
+        }
+
+        /**
+         * Additional token data (Identity interface).
+         */
+        public Properties identity_data {
+            get {
+                var props = new PropertyDictionary();
+                props.set("email", new NativeElement<string>(email ?? ""));
+                var iter = _app_data.iterator();
+                while (iter.next()) {
+                    props.set(iter.get().key, new NativeElement<string>(iter.get().value));
+                }
+                return props;
+            }
+        }
+
+        // =========================================================================
+        // User-Specific Properties
+        // =========================================================================
+
+        /**
+         * Mutable permissions collection for management.
+         */
+        public Vector<string> permissions {
+            get { return _permissions; }
+            set { 
+                _permissions.clear();
+                foreach (var perm in value) {
+                    _permissions.add(perm);
+                }
+            }
+        }
+
+        /**
+         * Mutable application data for management.
+         */
+        public Dictionary<string, string> app_data {
+            get { return _app_data; }
+            set {
+                _app_data.clear();
+                var iter = value.iterator();
+                while (iter.next()) {
+                    _app_data.set(iter.get().key, iter.get().value);
+                }
+            }
+        }
+
+        // ... existing methods (from_json, to_json) ...
+    }
+}
+```
+
+### 5.3 Updated LoginFormComponent
+
+The login form now uses the AuthorisationService for token generation.
+
+```vala
+namespace Spry.Authentication.Components {
+
+    public class LoginFormComponent : Component {
+
+        private UserService _user_service = inject<UserService>();
+        private AuthorisationService _auth_service = inject<AuthorisationService>();
+        private HttpContext _http_context = inject<HttpContext>();
+
+        public string redirect_url { get; set; default = "/"; }
+        public string? error_message { get; private set; default = null; }
+
+        private async void handle_login_async() throws Error {
+            var query = _http_context.request.query_params;
+            var username = query.get_any_or_default("username", "").strip();
+            var password = query.get_any_or_default("password", "");
+
+            // Authenticate user
+            var user = yield _user_service.authenticate_async(username, password);
+            if (user == null) {
+                error_message = "Invalid username or password";
+                return;
+            }
+
+            // Generate authorisation token
+            var token = _auth_service.generate_token(user);
+
+            // Set cookie and redirect
+            response.set_cookie(
+                _auth_service.cookie_name,
+                token,
+                (int)(_auth_service.token_duration / TimeSpan.SECOND),
+                "/",
+                _auth_service.cookie_secure,
+                true,  // HttpOnly
+                "Strict"  // SameSite
+            );
+            response.redirect(redirect_url);
+        }
+    }
+}
+```
+
+---
+
+## 6. IoC Module Registration
+
+### 6.1 AuthorisationModule
+
+```vala
+namespace Spry.Authorisation {
+
+    /**
+     * IoC module for the Authorisation system.
+     * 
+     * Registration order:
+     * 1. AuthorisationContext (scoped - per request)
+     * 2. AuthorisationService (scoped - per request)
+     * 3. IdentityProvider (optional - provided by Authentication or custom)
+     */
+    public class AuthorisationModule : GLib.Object, Inversion.Module {
+
+        public void register(Inversion.Application application) {
+            // Context is scoped (per-request)
+            application.add_scoped<AuthorisationContext>();
+
+            // Service is scoped (per-request, needs HttpContext)
+            application.add_scoped<AuthorisationService>();
+        }
+
+        public void initialize(Inversion.Application application) {
+            // Wire up request processing
+            // This would integrate with Spry's request pipeline
+        }
+    }
+}
+```
+
+### 6.2 AuthenticationModule
+
+```vala
+namespace Spry.Authentication {
+
+    /**
+     * IoC module for the Authentication system.
+     * 
+     * Depends on: AuthorisationModule (must be registered first)
+     */
+    public class AuthenticationModule : GLib.Object, Inversion.Module {
+
+        public void register(Inversion.Application application) {
+            // Register as IdentityProvider for Authorisation
+            application.add_scoped<UserIdentityProvider>();
+            application.add_alias<Authorisation.IdentityProvider, UserIdentityProvider>();
+
+            // User management services
+            application.add_scoped<UserService>();
+            application.add_scoped<SessionService>();
+            application.add_scoped<PermissionService>();
+
+            // Register UI components
+            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>();
+            spry_cfg.add_page<Components.UserManagementPage>(
+                new EndpointRoute("/admin/users")
+            );
+        }
+
+        public void initialize(Inversion.Application application) {
+            // Initialize storage structure
+            try {
+                var engine = application.resolve<Implexus.Core.Engine>();
+                initialize_storage_async.begin(engine);
+            } catch (Error e) {
+                error("Failed to initialize Authentication storage: %s", e.message);
+            }
+        }
+
+        private async void initialize_storage_async(Implexus.Core.Engine engine) throws Error {
+            // Create /spry/users container hierarchy
+            // ... (same as current UsersModule)
+        }
+    }
+}
+```
+
+### 6.3 Application Startup
+
+```vala
+// In application startup
+var application = new Inversion.Application();
+
+// 1. Register core Spry modules
+application.add_module<SpryModule>();
+
+// 2. Register Authorisation (must be before Authentication)
+application.add_module<AuthorisationModule>();
+
+// 3. Register built-in Authentication (optional)
+application.add_module<AuthenticationModule>();
+
+// OR: Register custom authentication provider
+// application.add_module<MyCustomAuthModule>();
+```
+
+---
+
+## 7. Custom Authentication Provider Integration
+
+### 7.1 Implementing a Custom Provider
+
+To implement custom authentication (e.g., OAuth, certificates):
+
+```vala
+namespace MyApp.Auth {
+
+    /**
+     * Custom identity for OAuth authentication.
+     */
+    public class OAuthIdentity : GLib.Object, Authorisation.Identity {
+        
+        public string id { get; set; }
+        public string username { get; set; }
+        public ImmutableLot<string> permissions { get; set; }
+        public Properties data { get; set; }
+        
+        // OAuth-specific fields
+        public string provider { get; set; }  // e.g., "google", "github"
+        public string? avatar_url { get; set; }
+    }
+
+    /**
+     * Custom identity provider for OAuth.
+     */
+    public class OAuthIdentityProvider : GLib.Object, Authorisation.IdentityProvider {
+        
+        private OAuthService _oauth_service = inject<OAuthService>();
+        
+        public async Authorisation.Identity? get_identity_async(string id) throws Error {
+            return yield _oauth_service.get_identity_async(id);
+        }
+    }
+
+    /**
+     * Custom authentication module.
+     */
+    public class OAuthModule : GLib.Object, Inversion.Module {
+        
+        public void register(Inversion.Application application) {
+            // Register as IdentityProvider
+            application.add_scoped<OAuthIdentityProvider>();
+            application.add_alias<Authorisation.IdentityProvider, OAuthIdentityProvider>();
+            
+            // OAuth-specific services
+            application.add_singleton<OAuthService>();
+            application.add_scoped<OAuthCallbackHandler>();
+        }
+    }
+}
+```
+
+### 7.2 OAuth Login Flow Example
+
+```vala
+public class OAuthCallbackComponent : Component {
+    
+    private OAuthService _oauth = inject<OAuthService>();
+    private AuthorisationService _auth = inject<AuthorisationService>();
+    
+    public override async void prepare() throws Error {
+        var code = _http_context.request.query_params.get_any_or_default("code", "");
+        
+        // Exchange code for OAuth identity
+        var identity = yield _oauth.exchange_code_async(code);
+        if (identity == null) {
+            response.redirect("/login?error=oauth_failed");
+            return;
+        }
+        
+        // Generate Spry authorisation token
+        var token = _auth.generate_token(identity);
+        
+        // Set cookie
+        response.set_cookie(_auth.cookie_name, token, ...);
+        response.redirect("/dashboard");
+    }
+}
+```
+
+---
+
+## 8. Request Processing Flow
+
+### 8.1 Sequence Diagram
+
+```mermaid
+sequenceDiagram
+    participant Client
+    participant Spry as Spry Framework
+    participant AuthZ as AuthorisationService
+    participant Context as AuthorisationContext
+    participant Provider as IdentityProvider
+    participant AuthN as UserService
+    
+    Client->>Spry: HTTP Request with Cookie/Bearer
+    Spry->>AuthZ: process_request_async
+    AuthZ->>AuthZ: Extract token from header/cookie
+    AuthZ->>AuthZ: Validate token signature/encryption
+    AuthZ->>Context: set_payload - TokenPayload
+    Spry->>Context: has_permission or require_permission
+    Context->>Context: Check permissions in payload
+    
+    alt Full identity needed
+        Spry->>Context: get_current_identity_async
+        Context->>Provider: get_identity_async
+        Provider->>AuthN: get_user_async
+        AuthN-->>Provider: User
+        Provider-->>Context: Identity
+        Context-->>Spry: Identity
+    end
+    
+    Spry-->>Client: Response
+```
+
+### 8.2 Component Diagram
+
+```mermaid
+graph TB
+    subgraph Authorisation System
+        AuthorisationModule[AuthorisationModule]
+        AuthorisationService[AuthorisationService]
+        AuthorisationContext[AuthorisationContext]
+        TokenPayload[TokenPayload]
+        Identity[Identity Interface]
+        IdentityProvider[IdentityProvider Interface]
+    end
+    
+    subgraph Authentication System
+        AuthenticationModule[AuthenticationModule]
+        UserService[UserService]
+        SessionService[SessionService]
+        PermissionService[PermissionService]
+        User[User Model]
+        UserIdentityProvider[UserIdentityProvider]
+        LoginFormComponent[LoginFormComponent]
+    end
+    
+    subgraph Custom Auth Example
+        OAuthModule[OAuthModule]
+        OAuthIdentityProvider[OAuthIdentityProvider]
+        OAuthIdentity[OAuthIdentity]
+    end
+    
+    AuthorisationModule --> AuthorisationService
+    AuthorisationModule --> AuthorisationContext
+    
+    AuthorisationService --> AuthorisationContext
+    AuthorisationService --> IdentityProvider
+    
+    AuthorisationContext --> IdentityProvider
+    AuthorisationContext --> TokenPayload
+    
+    IdentityProvider -.implements.-> IdentityProvider
+    Identity -.implements.-> Identity
+    
+    AuthenticationModule --> UserService
+    AuthenticationModule --> UserIdentityProvider
+    AuthenticationModule --> LoginFormComponent
+    
+    User -.implements.-> Identity
+    UserIdentityProvider -.implements.-> IdentityProvider
+    UserIdentityProvider --> UserService
+    UserIdentityProvider --> User
+    
+    LoginFormComponent --> UserService
+    LoginFormComponent --> AuthorisationService
+    
+    OAuthModule --> OAuthIdentityProvider
+    OAuthIdentity -.implements.-> Identity
+    OAuthIdentityProvider -.implements.-> IdentityProvider
+    OAuthIdentityProvider --> OAuthIdentity
+```
+
+---
+
+## 9. Migration Guide
+
+### 9.1 For Existing Applications
+
+1. **Update imports**: Change `Spry.Users` to `Spry.Authentication`
+2. **Update module registration**: Register `AuthorisationModule` before `AuthenticationModule`
+3. **Update permission checks**: Use `inject<AuthorisationContext>()` instead of `inject<PermissionService>()`
+
+### 9.2 Before/After Comparison
+
+**Before (current system):**
+```vala
+// Module registration
+application.add_module<Spry.Users.UsersModule>();
+
+// Permission check
+var perms = inject<PermissionService>();
+if (!perms.has_permission(user, "admin")) {
+    throw new UserError.PERMISSION_DENIED("Admin required");
+}
+
+// Getting current user
+var session = inject<SessionService>();
+var user = yield session.get_current_user_async();
+```
+
+**After (refactored system):**
+```vala
+// Module registration
+application.add_module<Spry.Authorisation.AuthorisationModule>();
+application.add_module<Spry.Authentication.AuthenticationModule>();
+
+// Permission check
+var auth = inject<AuthorisationContext>();
+auth.require_permission("admin");  // Throws if not authorised
+
+// Getting current user
+var auth = inject<AuthorisationContext>();
+var user = yield auth.get_current_identity_async() as User;
+```
+
+---
+
+## 10. Implementation Checklist
+
+### Phase 1: Authorisation System
+- [ ] Create `src/Authorisation/` directory structure
+- [ ] Implement `Identity` interface
+- [ ] Implement `IdentityProvider` interface
+- [ ] Implement `TokenPayload` class
+- [ ] Implement `AuthorisationContext` class
+- [ ] Implement `AuthorisationService` class
+- [ ] Implement `AuthorisationError` domain
+- [ ] Implement `AuthorisationModule` class
+- [ ] Create `meson.build` configuration
+
+### Phase 2: Authentication Refactoring
+- [ ] Rename `src/Users/` to `src/Authentication/`
+- [ ] Update `User` to implement `Identity` interface
+- [ ] Create `UserIdentityProvider` class
+- [ ] Update `UserService` namespace
+- [ ] Update `SessionService` namespace
+- [ ] Update `PermissionService` namespace
+- [ ] Update all component namespaces
+- [ ] Create `AuthenticationModule` class
+- [ ] Update `LoginFormComponent` to use `AuthorisationService`
+- [ ] Update `meson.build` configuration
+
+### Phase 3: Integration
+- [ ] Update root `meson.build` to include both modules
+- [ ] Update demo application
+- [ ] Update documentation
+- [ ] Create migration guide for existing applications
+
+---
+
+## 11. Open Questions
+
+1. **Token Refresh**: Should we support token refresh without re-authentication?
+2. **Multiple Sessions**: Should we track active sessions per identity?
+3. **Permission Caching**: Should permissions be cached beyond token lifetime?
+4. **Token Revocation**: Future support for revoking tokens before expiry?
+
+---
+
+## 12. Appendix: Token Format Details
+
+### Token Structure (Internal)
+
+```
+Base64url(
+  X25519-Seal(
+    Ed25519-Sign(
+      JSON {
+        "id": "user-uuid",
+        "username": "johndoe",
+        "permissions": ["admin", "user-management"],
+        "data": {
+          "email": "john@example.com"
+        },
+        "issued_at": "2026-03-15T12:00:00Z",
+        "expires_at": "2026-03-16T12:00:00Z"
+      }
+    )
+  )
+)
+```
+
+### Cookie Format
+
+```
+Set-Cookie: spry_session=<token>; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Strict
+```
+
+### Bearer Header Format
+
+```
+Authorization: Bearer <token>
+```

+ 658 - 0
plans/authentication-implexus-analysis.md

@@ -0,0 +1,658 @@
+# Authentication System Implexus Usage Analysis
+
+## Executive Summary
+
+This document provides a comprehensive analysis of the current authentication system's usage of the Implexus database and maps out the migration path to InvercargillSql. The authentication system uses Implexus as a document store with catalogue-based indexing for user and session management.
+
+## 1. Implexus Usage Map
+
+### 1.1 Files with Direct Implexus Dependencies
+
+| File | Implexus Usage | Complexity |
+|------|----------------|------------|
+| [`UserService.vala`](../src/Authentication/UserService.vala) | Heavy - all CRUD operations | High |
+| [`SessionService.vala`](../src/Authentication/SessionService.vala) | Heavy - all CRUD operations | High |
+| [`AuthenticationMigration.vala`](../src/Authentication/AuthenticationMigration.vala) | Medium - schema setup | Medium |
+| [`PermissionService.vala`](../src/Authentication/PermissionService.vala) | None - delegates to UserService | N/A |
+| [`User.vala`](../src/Authentication/User.vala) | None - data model only | N/A |
+| [`Session.vala`](../src/Authentication/Session.vala) | None - data model only | N/A |
+| [`UserIdentityProvider.vala`](../src/Authentication/UserIdentityProvider.vala) | None - adapter only | N/A |
+| `Components/*.vala` | None - use inject<> for services | N/A |
+
+### 1.2 Implexus Import Patterns
+
+```vala
+// Standard imports in files using Implexus
+using Implexus.Core;                    // Engine, EntityPath, Container, Document
+using Invercargill;                     // Element, Properties
+using Invercargill.DataStructures;      // Vector, Dictionary, Series
+```
+
+### 1.3 Storage Paths and Structure
+
+```mermaid
+graph TB
+    subgraph Implexus Storage Structure
+        Root["/spry"]
+        Auth["/spry/authentication"]
+        Users["/spry/authentication/users"]
+        Sessions["/spry/authentication/sessions"]
+        SessionsByUser["/spry/authentication/sessions_by_user"]
+    end
+    
+    Root --> Auth
+    Auth --> Users
+    Auth --> Sessions
+    Auth --> SessionsByUser
+    
+    subgraph User Document
+        UserDoc["Document: {user_id}"]
+        UserProps["Properties: username, email, password_hash, etc."]
+    end
+    
+    subgraph Session Document
+        SessionDoc["Document: {session_id}"]
+        SessionProps["Properties: user_id, created_at, expires_at, etc."]
+    end
+    
+    Users --> UserDoc
+    UserDoc --> UserProps
+    
+    Sessions --> SessionDoc
+    SessionDoc --> SessionProps
+```
+
+### 1.4 Catalogue Configuration
+
+| Catalogue | Path | Indexed Property | Purpose |
+|-----------|------|------------------|---------|
+| `by_username` | `/spry/authentication/users` | `username` | Unique username lookup |
+| `by_email` | `/spry/authentication/users` | `email` | Unique email lookup |
+
+---
+
+## 2. Data Models and Schemas
+
+### 2.1 User Model
+
+**File:** [`src/Authentication/User.vala`](../src/Authentication/User.vala)
+
+```vala
+public class Spry.Authentication.User : Object, Authorisation.Identity {
+    public string id { get; set; }
+    public string username { get; set; }
+    public string email { get; set; }
+    public string password_hash { get; set; }
+    public Vector<string> permissions { get; set; }
+    public Dictionary<string, string> app_data { get; set; }
+    public bool is_active { get; set; }
+    public DateTime created_at { get; set; }
+    public DateTime? updated_at { get; set; }
+    public DateTime? last_login_at { get; set; }
+}
+```
+
+**Implexus Property Mapping:**
+
+| Property | Implexus Storage | Type |
+|----------|------------------|------|
+| `id` | Document ID | `string` |
+| `username` | Property | `string` |
+| `email` | Property | `string` |
+| `password_hash` | Property | `string` |
+| `permissions` | Property (JSON array) | `Vector<string>` |
+| `app_data` | Property (JSON object) | `Dictionary<string, string>` |
+| `is_active` | Property | `bool` |
+| `created_at` | Property (ISO 8601) | `DateTime` |
+| `updated_at` | Property (ISO 8601) | `DateTime?` |
+| `last_login_at` | Property (ISO 8601) | `DateTime?` |
+
+### 2.2 Session Model
+
+**File:** [`src/Authentication/Session.vala`](../src/Authentication/Session.vala)
+
+```vala
+public class Spry.Authentication.Session : Object {
+    public string id { get; set; }
+    public string user_id { get; set; }
+    public DateTime created_at { get; set; }
+    public DateTime expires_at { get; set; }
+    public string? ip_address { get; set; }
+    public string? user_agent { get; set; }
+}
+```
+
+**Implexus Property Mapping:**
+
+| Property | Implexus Storage | Type |
+|----------|------------------|------|
+| `id` | Document ID | `string` |
+| `user_id` | Property | `string` |
+| `created_at` | Property (ISO 8601) | `DateTime` |
+| `expires_at` | Property (ISO 8601) | `DateTime` |
+| `ip_address` | Property | `string?` |
+| `user_agent` | Property | `string?` |
+
+---
+
+## 3. Implexus API Usage Patterns
+
+### 3.1 UserService Implexus Operations
+
+**File:** [`src/Authentication/UserService.vala`](../src/Authentication/UserService.vala)
+
+#### Create User
+```vala
+// Current Implexus pattern
+var user_path = new EntityPath("/spry/authentication/users");
+var user_doc = yield engine.create_document_async(user_path);
+
+// Set properties
+yield engine.set_entity_property_async(user_doc.path, "username", new NativeElement<string>(username));
+yield engine.set_entity_property_async(user_doc.path, "email", new NativeElement<string>(email));
+yield engine.set_entity_property_async(user_doc.path, "password_hash", new NativeElement<string>(hash));
+// ... more properties
+```
+
+#### Read User
+```vala
+// Current Implexus pattern
+var user_path = new EntityPath("/spry/authentication/users/%s".printf(id));
+var user_doc = yield engine.get_entity_or_null_async(user_path);
+if (user_doc == null) return null;
+
+var props = yield engine.get_properties_async(user_doc.path);
+var user = new User();
+user.id = user_doc.id;
+user.username = props.get("username")?.as_string_or_null();
+// ... more properties
+```
+
+#### Update User
+```vala
+// Current Implexus pattern - property by property updates
+yield engine.set_entity_property_async(user_path, "email", new NativeElement<string>(email));
+yield engine.set_entity_property_async(user_path, "updated_at", new NativeElement<string>(now));
+```
+
+#### Delete User
+```vala
+// Current Implexus pattern
+var user_path = new EntityPath("/spry/authentication/users/%s".printf(id));
+yield engine.delete_entity_async(user_path);
+```
+
+#### Query by Username (Catalogue)
+```vala
+// Current Implexus pattern
+var catalogue = yield engine.get_catalogue_async("/spry/authentication/users/by_username");
+var entry = catalogue.get(username);
+if (entry != null) {
+    var user_id = entry.value.as_string_or_null();
+    // Then fetch user by ID
+}
+```
+
+### 3.2 SessionService Implexus Operations
+
+**File:** [`src/Authentication/SessionService.vala`](../src/Authentication/SessionService.vala)
+
+#### Create Session
+```vala
+// Current Implexus pattern
+var session_path = new EntityPath("/spry/authentication/sessions");
+var session_doc = yield engine.create_document_async(session_path);
+
+// Set properties
+yield engine.set_entity_property_async(session_doc.path, "user_id", new NativeElement<string>(user_id));
+yield engine.set_entity_property_async(session_doc.path, "created_at", new NativeElement<string>(created_at));
+// ... more properties
+
+// Also update sessions_by_user index
+var index_path = new EntityPath("/spry/authentication/sessions_by_user/%s".printf(user_id));
+// ... index management
+```
+
+#### Get Sessions by User
+```vala
+// Current Implexus pattern - uses secondary index
+var index_path = new EntityPath("/spry/authentication/sessions_by_user/%s".printf(user_id));
+var index_doc = yield engine.get_entity_or_null_async(index_path);
+// Parse session IDs from index
+```
+
+### 3.3 Migration Implexus Operations
+
+**File:** [`src/Authentication/AuthenticationMigration.vala`](../src/Authentication/AuthenticationMigration.vala)
+
+```vala
+public class Spry.Authentication.AuthenticationMigration : Implexus.Migrations.Migration {
+    public override async void up_async(Engine engine) throws Error {
+        // Create containers
+        var users_path = new EntityPath("/spry/authentication/users");
+        yield engine.create_container_async(users_path);
+        
+        var sessions_path = new EntityPath("/spry/authentication/sessions");
+        yield engine.create_container_async(sessions_path);
+        
+        // Create catalogues for unique constraints
+        yield engine.create_catalogue_async(users_path, "by_username", "username");
+        yield engine.create_catalogue_async(users_path, "by_email", "email");
+    }
+}
+```
+
+---
+
+## 4. Key Interfaces to Maintain
+
+### 4.1 Public Service Interfaces
+
+These interfaces must be preserved during migration:
+
+#### UserService Public API
+```vala
+public async User? get_user_by_id_async(string id)
+public async User? get_user_by_username_async(string username)
+public async User? get_user_by_email_async(string email)
+public async User create_user_async(string username, string email, string password)
+public async User update_user_async(User user)
+public async void delete_user_async(string id)
+public async bool validate_credentials_async(string username, string password)
+public async bool username_exists_async(string username)
+public async bool email_exists_async(string email)
+```
+
+#### SessionService Public API
+```vala
+public async Session create_session_async(string user_id, string? ip_address, string? user_agent)
+public async Session? get_session_async(string session_id)
+public async void delete_session_async(string session_id)
+public async void delete_user_sessions_async(string user_id)
+public async Vector<Session> get_user_sessions_async(string user_id)
+public async bool validate_session_async(string session_id)
+public async Session? refresh_session_async(string session_id)
+```
+
+#### PermissionService Public API
+```vala
+public async void grant_permission_async(string user_id, string permission)
+public async void revoke_permission_async(string user_id, string permission)
+public async bool has_permission_async(string user_id, string permission)
+public async Vector<string> get_user_permissions_async(string user_id)
+```
+
+### 4.2 Dependency Injection Contracts
+
+```vala
+// Inversion IoC registration pattern
+container.register<UserService>().as_singleton();
+container.register<SessionService>().as_singleton();
+container.register<PermissionService>().as_singleton();
+container.register<UserIdentityProvider>().as_singleton();
+```
+
+---
+
+## 5. Implexus vs InvercargillSql Comparison
+
+### 5.1 Architectural Differences
+
+```mermaid
+graph LR
+    subgraph Implexus - Document Store
+        I1[Engine]
+        I2[Container]
+        I3[Document]
+        I4[Properties]
+        I5[Catalogue]
+    end
+    
+    subgraph InvercargillSql - SQL Database
+        S1[Connection]
+        S2[Command]
+        S3[Table/Row]
+        S4[Properties]
+        S5[Index]
+    end
+    
+    I1 --> I2
+    I2 --> I3
+    I3 --> I4
+    I2 --> I5
+    
+    S1 --> S2
+    S2 --> S3
+    S3 --> S4
+    S1 --> S5
+```
+
+### 5.2 Feature Comparison
+
+| Feature | Implexus | InvercargillSql |
+|---------|----------|-----------------|
+| **Data Model** | Hierarchical document store | Relational tables |
+| **Schema** | Schemaless properties | Fixed schema with migrations |
+| **Indexing** | Catalogues (auto-maintained) | SQL indexes (manual) |
+| **Querying** | Path-based + catalogues | SQL queries |
+| **Transactions** | Not explicit | Full transaction support |
+| **Async** | Native async API | Thread-based async |
+| **Result Type** | `Properties` | `Enumerable<Properties>` |
+| **Parameter Binding** | N/A | Fluent `with_parameter<T>()` |
+| **Relationships** | Manual (via paths) | Foreign keys |
+
+### 5.3 API Mapping
+
+| Operation | Implexus | InvercargillSql |
+|-----------|----------|-----------------|
+| **Connect** | `Engine` construction | `ConnectionFactory.create()` + `open()` |
+| **Create** | `create_document_async()` | `INSERT` via `execute_non_query()` |
+| **Read** | `get_entity_or_null_async()` + `get_properties_async()` | `SELECT` via `execute_query()` |
+| **Update** | `set_entity_property_async()` | `UPDATE` via `execute_non_query()` |
+| **Delete** | `delete_entity_async()` | `DELETE` via `execute_non_query()` |
+| **Query** | Catalogue lookup | `SELECT WHERE` via `execute_query()` |
+| **Transaction** | N/A | `begin_transaction()` + `commit()`/`rollback()` |
+
+### 5.4 Code Pattern Comparison
+
+#### Create Operation
+
+**Implexus (Current):**
+```vala
+var path = new EntityPath("/spry/authentication/users");
+var doc = yield engine.create_document_async(path);
+yield engine.set_entity_property_async(doc.path, "username", new NativeElement<string>(username));
+yield engine.set_entity_property_async(doc.path, "email", new NativeElement<string>(email));
+// ... more properties
+return doc.id;
+```
+
+**InvercargillSql (Target):**
+```vala
+var sql = """
+    INSERT INTO users (id, username, email, password_hash, is_active, created_at)
+    VALUES (:id, :username, :email, :password_hash, :is_active, :created_at)
+""";
+yield conn.create_command(sql)
+    .with_parameter("id", generate_uuid())
+    .with_parameter("username", username)
+    .with_parameter("email", email)
+    .with_parameter("password_hash", hash)
+    .with_parameter("is_active", true)
+    .with_parameter("created_at", new DateTime.now_utc().format_iso8601())
+    .execute_non_query_async();
+return conn.last_insert_rowid.to_string();
+```
+
+#### Read Operation
+
+**Implexus (Current):**
+```vala
+var path = new EntityPath("/spry/authentication/users/%s".printf(id));
+var doc = yield engine.get_entity_or_null_async(path);
+if (doc == null) return null;
+var props = yield engine.get_properties_async(doc.path);
+return user_from_properties(props);
+```
+
+**InvercargillSql (Target):**
+```vala
+var sql = "SELECT * FROM users WHERE id = :id";
+var results = yield conn.create_command(sql)
+    .with_parameter("id", id)
+    .execute_query_async();
+var row = results.first_or_default();
+if (row == null) return null;
+return user_from_row(row);
+```
+
+#### Query by Index
+
+**Implexus (Current):**
+```vala
+var catalogue = yield engine.get_catalogue_async("/spry/authentication/users/by_username");
+var entry = catalogue.get(username);
+if (entry == null) return null;
+return yield get_user_by_id_async(entry.value.as_string_or_null());
+```
+
+**InvercargillSql (Target):**
+```vala
+var sql = "SELECT * FROM users WHERE username = :username";
+var results = yield conn.create_command(sql)
+    .with_parameter("username", username)
+    .execute_query_async();
+var row = results.first_or_default();
+if (row == null) return null;
+return user_from_row(row);
+```
+
+---
+
+## 6. Migration Strategy Recommendations
+
+### 6.1 Proposed SQL Schema
+
+```sql
+-- Users table
+CREATE TABLE users (
+    id TEXT PRIMARY KEY,
+    username TEXT NOT NULL UNIQUE,
+    email TEXT NOT NULL UNIQUE,
+    password_hash TEXT NOT NULL,
+    is_active INTEGER NOT NULL DEFAULT 1,
+    created_at TEXT NOT NULL,
+    updated_at TEXT,
+    last_login_at TEXT
+);
+
+-- User permissions (normalized)
+CREATE TABLE user_permissions (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL,
+    permission TEXT NOT NULL,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+    UNIQUE(user_id, permission)
+);
+
+-- User app data (key-value store)
+CREATE TABLE user_app_data (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL,
+    key TEXT NOT NULL,
+    value TEXT,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+    UNIQUE(user_id, key)
+);
+
+-- Sessions table
+CREATE TABLE sessions (
+    id TEXT PRIMARY KEY,
+    user_id TEXT NOT NULL,
+    created_at TEXT NOT NULL,
+    expires_at TEXT NOT NULL,
+    ip_address TEXT,
+    user_agent TEXT,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Indexes for common queries
+CREATE INDEX idx_users_username ON users(username);
+CREATE INDEX idx_users_email ON users(email);
+CREATE INDEX idx_sessions_user_id ON sessions(user_id);
+CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
+```
+
+### 6.2 Migration Phases
+
+```mermaid
+flowchart TB
+    subgraph Phase 1 - Infrastructure
+        A1[Create SQL schema migration]
+        A2[Create ConnectionProvider]
+        A3[Update meson.build dependencies]
+    end
+    
+    subgraph Phase 2 - Data Access Layer
+        B1[Create UserRepository interface]
+        B2[Implement SqlUserRepository]
+        B3[Create SessionRepository interface]
+        B4[Implement SqlSessionRepository]
+    end
+    
+    subgraph Phase 3 - Service Migration
+        C1[Refactor UserService to use repository]
+        C2[Refactor SessionService to use repository]
+        C3[Update PermissionService]
+    end
+    
+    subgraph Phase 4 - Cleanup
+        D1[Remove Implexus dependency]
+        D2[Delete AuthenticationMigration]
+        D3[Update documentation]
+    end
+    
+    A1 --> A2 --> A3 --> B1
+    B1 --> B2 --> B3 --> B4
+    B4 --> C1 --> C2 --> C3
+    C3 --> D1 --> D2 --> D3
+```
+
+### 6.3 Recommended Repository Pattern
+
+To minimize impact on existing code and allow for future database changes, implement a repository pattern:
+
+```vala
+// Abstract repository interfaces
+public interface UserRepository : Object {
+    public abstract async User? get_by_id_async(string id);
+    public abstract async User? get_by_username_async(string username);
+    public abstract async User? get_by_email_async(string email);
+    public abstract async User create_async(User user);
+    public abstract async User update_async(User user);
+    public abstract async void delete_async(string id);
+    public abstract async bool username_exists_async(string username);
+    public abstract async bool email_exists_async(string email);
+}
+
+public interface SessionRepository : Object {
+    public abstract async Session create_async(Session session);
+    public abstract async Session? get_by_id_async(string id);
+    public abstract async void delete_async(string id);
+    public abstract async void delete_by_user_async(string user_id);
+    public abstract async Vector<Session> get_by_user_async(string user_id);
+}
+```
+
+### 6.4 Key Migration Considerations
+
+1. **Data Type Mapping:**
+   - `DateTime` → ISO 8601 string storage
+   - `Vector<string>` (permissions) → Separate table with foreign key
+   - `Dictionary<string, string>` (app_data) → Separate key-value table
+
+2. **Catalogue Replacement:**
+   - `by_username` catalogue → `UNIQUE` constraint + index
+   - `by_email` catalogue → `UNIQUE` constraint + index
+   - `sessions_by_user` index → Foreign key + index on `user_id`
+
+3. **Transaction Support:**
+   - InvercargillSql provides explicit transactions
+   - Use transactions for multi-step operations (e.g., create user + permissions)
+
+4. **Async Patterns:**
+   - Both use async/await
+   - InvercargillSql uses thread-based async (transparent to caller)
+
+5. **Connection Management:**
+   - Use singleton Connection injected via IoC
+   - Consider connection pooling for future scalability
+
+---
+
+## 7. Files Requiring Modification
+
+### 7.1 Files to Modify
+
+| File | Changes Required |
+|------|------------------|
+| `src/Authentication/UserService.vala` | Replace Implexus with repository |
+| `src/Authentication/SessionService.vala` | Replace Implexus with repository |
+| `src/Authentication/PermissionService.vala` | May need updates if directly accessing user permissions |
+| `src/Authentication/meson.build` | Replace `implexus_dep` with `invercargill_sql_dep` |
+| `meson.build` (root) | Update dependency declaration |
+
+### 7.2 Files to Create
+
+| File | Purpose |
+|------|---------|
+| `src/Authentication/Repositories/UserRepository.vala` | Abstract user repository interface |
+| `src/Authentication/Repositories/SqlUserRepository.vala` | SQLite implementation |
+| `src/Authentication/Repositories/SessionRepository.vala` | Abstract session repository interface |
+| `src/Authentication/Repositories/SqlSessionRepository.vala` | SQLite implementation |
+| `src/Authentication/Migrations/CreateAuthTables.vala` | SQL schema migration |
+
+### 7.3 Files to Delete
+
+| File | Reason |
+|------|--------|
+| `src/Authentication/AuthenticationMigration.vala` | Replaced by SQL migration |
+
+---
+
+## 8. Dependency Changes
+
+### 8.1 Current Dependencies (meson.build)
+
+```meson
+# src/Authentication/meson.build
+dependencies: [spry_dep, spry_authorisation_dep, implexus_dep, sodium_deps, invercargill_dep, astralis_dep]
+```
+
+### 8.2 Proposed Dependencies
+
+```meson
+# src/Authentication/meson.build
+dependencies: [spry_dep, spry_authorisation_dep, invercargill_sql_dep, sodium_deps, invercargill_dep, astralis_dep]
+```
+
+### 8.3 Root meson.build Changes
+
+```meson
+# Current
+implexus_dep = dependency('implexus-0.1')
+
+# Proposed
+invercargill_sql_dep = dependency('invercargill-sql-1')
+```
+
+---
+
+## 9. Risk Assessment
+
+| Risk | Impact | Mitigation |
+|------|--------|------------|
+| Data loss during migration | High | Implement data migration script, backup strategy |
+| Breaking existing API | High | Maintain service interfaces, use repository pattern |
+| Performance regression | Medium | Benchmark critical paths, optimize queries |
+| Async behavior differences | Low | Both use async/await patterns consistently |
+| Transaction semantics | Low | InvercargillSql provides more robust transactions |
+
+---
+
+## 10. Summary
+
+The authentication system's Implexus usage is concentrated in two main service classes: `UserService` and `SessionService`. The migration to InvercargillSql is straightforward from an API perspective since both libraries:
+
+1. Use the `Properties` interface for data representation
+2. Support async operations
+3. Are part of the Invercargill ecosystem
+
+The main differences are:
+
+1. **Query paradigm**: Path-based document lookup → SQL queries
+2. **Indexing**: Catalogues → SQL indexes with UNIQUE constraints
+3. **Transactions**: Implicit → Explicit with commit/rollback
+4. **Schema**: Schemaless → Fixed schema with migrations
+
+Implementing a repository pattern will isolate the database implementation details and allow the services to remain largely unchanged in their public API.

+ 1258 - 0
plans/invercargill-sql-migration-plan.md

@@ -0,0 +1,1258 @@
+# InvercargillSql Migration Implementation Plan
+
+## Overview
+
+This document provides a step-by-step implementation plan for migrating the Spry Authentication module from Implexus (document store) to InvercargillSql (relational database). The migration preserves all public APIs while replacing the storage backend.
+
+## Reference Documents
+
+- Analysis: [`plans/authentication-implexus-analysis.md`](authentication-implexus-analysis.md)
+- Current UserService: [`src/Authentication/UserService.vala`](../src/Authentication/UserService.vala)
+- Current SessionService: [`src/Authentication/SessionService.vala`](../src/Authentication/SessionService.vala)
+
+---
+
+## 1. Repository Interface Design
+
+### 1.1 UserRepository Interface
+
+**File:** `src/Authentication/Repositories/UserRepository.vala`
+
+```vala
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication {
+
+    /**
+     * Repository interface for User persistence operations.
+     * Abstracts the storage mechanism from the service layer.
+     */
+    public interface UserRepository : GLib.Object {
+
+        // =========================================================================
+        // Retrieval Operations
+        // =========================================================================
+
+        /**
+         * Gets a user by their unique ID.
+         *
+         * @param id The user's unique identifier
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async User? get_by_id_async(string id) throws Error;
+
+        /**
+         * Gets a user by their username.
+         *
+         * @param username The username to look up
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async User? get_by_username_async(string username) throws Error;
+
+        /**
+         * Gets a user by their email address.
+         *
+         * @param email The email address to look up
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async User? get_by_email_async(string email) throws Error;
+
+        // =========================================================================
+        // Mutation Operations
+        // =========================================================================
+
+        /**
+         * Creates a new user.
+         *
+         * @param user The user to create (id should be pre-generated)
+         * @return The created User
+         * @throws UserError.DUPLICATE_USERNAME if username exists
+         * @throws UserError.DUPLICATE_EMAIL if email exists
+         * @throws Error on storage failure
+         */
+        public abstract async User create_async(User user) throws Error;
+
+        /**
+         * Updates an existing user.
+         *
+         * @param user The user to update
+         * @throws UserError.USER_NOT_FOUND if user doesn't exist
+         * @throws UserError.DUPLICATE_USERNAME if new username conflicts
+         * @throws UserError.DUPLICATE_EMAIL if new email conflicts
+         * @throws Error on storage failure
+         */
+        public abstract async void update_async(User user) throws Error;
+
+        /**
+         * Deletes a user by their unique ID.
+         *
+         * @param id The user's unique identifier
+         * @throws UserError.USER_NOT_FOUND if user doesn't exist
+         * @throws Error on storage failure
+         */
+        public abstract async void delete_async(string id) throws Error;
+
+        // =========================================================================
+        // Query Operations
+        // =========================================================================
+
+        /**
+         * Checks if a username already exists.
+         *
+         * @param username The username to check
+         * @return true if the username exists
+         * @throws Error on storage failure
+         */
+        public abstract async bool username_exists_async(string username) throws Error;
+
+        /**
+         * Checks if an email already exists.
+         *
+         * @param email The email to check
+         * @return true if the email exists
+         * @throws Error on storage failure
+         */
+        public abstract async bool email_exists_async(string email) throws Error;
+
+        /**
+         * Lists users with pagination support.
+         *
+         * @param offset The number of users to skip
+         * @param limit The maximum number of users to return
+         * @return A Vector of users
+         * @throws Error on storage failure
+         */
+        public abstract async Vector<User> list_async(int offset = 0, int limit = 100) throws Error;
+
+        /**
+         * Gets the total count of users.
+         *
+         * @return The number of users
+         * @throws Error on storage failure
+         */
+        public abstract async int count_async() throws Error;
+
+        // =========================================================================
+        // Permission Operations
+        // =========================================================================
+
+        /**
+         * Gets all permissions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of permission strings
+         * @throws Error on storage failure
+         */
+        public abstract async Vector<string> get_permissions_async(string user_id) throws Error;
+
+        /**
+         * Adds a permission to a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to add
+         * @throws Error on storage failure
+         */
+        public abstract async void add_permission_async(string user_id, string permission) throws Error;
+
+        /**
+         * Removes a permission from a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to remove
+         * @throws Error on storage failure
+         */
+        public abstract async void remove_permission_async(string user_id, string permission) throws Error;
+
+        /**
+         * Clears all permissions from a user.
+         *
+         * @param user_id The user's unique identifier
+         * @throws Error on storage failure
+         */
+        public abstract async void clear_permissions_async(string user_id) throws Error;
+
+        // =========================================================================
+        // App Data Operations
+        // =========================================================================
+
+        /**
+         * Gets app data for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Dictionary of app data key-value pairs
+         * @throws Error on storage failure
+         */
+        public abstract async Dictionary<string, string> get_app_data_async(string user_id) throws Error;
+
+        /**
+         * Sets an app data value for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param key The app data key
+         * @param value The app data value
+         * @throws Error on storage failure
+         */
+        public abstract async void set_app_data_value_async(string user_id, string key, string value) throws Error;
+
+        /**
+         * Removes an app data key from a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param key The app data key to remove
+         * @throws Error on storage failure
+         */
+        public abstract async void remove_app_data_value_async(string user_id, string key) throws Error;
+    }
+}
+```
+
+### 1.2 SessionRepository Interface
+
+**File:** `src/Authentication/Repositories/SessionRepository.vala`
+
+```vala
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication {
+
+    /**
+     * Repository interface for Session persistence operations.
+     * Abstracts the storage mechanism from the service layer.
+     */
+    public interface SessionRepository : GLib.Object {
+
+        // =========================================================================
+        // Retrieval Operations
+        // =========================================================================
+
+        /**
+         * Gets a session by its unique ID.
+         *
+         * @param id The session's unique identifier
+         * @return The Session, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async Session? get_by_id_async(string id) throws Error;
+
+        /**
+         * Gets all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of sessions (excluding expired)
+         * @throws Error on storage failure
+         */
+        public abstract async Vector<Session> get_by_user_async(string user_id) throws Error;
+
+        // =========================================================================
+        // Mutation Operations
+        // =========================================================================
+
+        /**
+         * Creates a new session.
+         *
+         * @param session The session to create (id should be pre-generated)
+         * @return The created Session
+         * @throws Error on storage failure
+         */
+        public abstract async Session create_async(Session session) throws Error;
+
+        /**
+         * Updates an existing session.
+         *
+         * @param session The session to update
+         * @throws SessionError.SESSION_NOT_FOUND if session doesn't exist
+         * @throws Error on storage failure
+         */
+        public abstract async void update_async(Session session) throws Error;
+
+        /**
+         * Deletes a session by its unique ID.
+         *
+         * @param id The session's unique identifier
+         * @throws Error on storage failure
+         */
+        public abstract async void delete_async(string id) throws Error;
+
+        /**
+         * Deletes all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @throws Error on storage failure
+         */
+        public abstract async void delete_by_user_async(string user_id) throws Error;
+
+        // =========================================================================
+        // Cleanup Operations
+        // =========================================================================
+
+        /**
+         * Removes all expired sessions from storage.
+         *
+         * @throws Error on storage failure
+         */
+        public abstract async void cleanup_expired_async() throws Error;
+    }
+}
+```
+
+---
+
+## 2. SQL Schema Design
+
+### 2.1 Users Table
+
+```sql
+CREATE TABLE users (
+    id TEXT PRIMARY KEY NOT NULL,
+    username TEXT NOT NULL UNIQUE,
+    email TEXT NOT NULL UNIQUE,
+    password_hash TEXT NOT NULL,
+    is_active INTEGER NOT NULL DEFAULT 1,
+    created_at TEXT NOT NULL,
+    updated_at TEXT,
+    last_login_at TEXT
+);
+
+-- Index for username lookups (also enforces uniqueness)
+CREATE UNIQUE INDEX idx_users_username ON users(username);
+
+-- Index for email lookups (also enforces uniqueness)
+CREATE UNIQUE INDEX idx_users_email ON users(email);
+```
+
+### 2.2 User Permissions Table
+
+```sql
+CREATE TABLE user_permissions (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL,
+    permission TEXT NOT NULL,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+    UNIQUE(user_id, permission)
+);
+
+-- Index for permission lookups by user
+CREATE INDEX idx_user_permissions_user_id ON user_permissions(user_id);
+```
+
+### 2.3 User App Data Table
+
+```sql
+CREATE TABLE user_app_data (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL,
+    key TEXT NOT NULL,
+    value TEXT,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+    UNIQUE(user_id, key)
+);
+
+-- Index for app data lookups by user
+CREATE INDEX idx_user_app_data_user_id ON user_app_data(user_id);
+```
+
+### 2.4 Sessions Table
+
+```sql
+CREATE TABLE sessions (
+    id TEXT PRIMARY KEY NOT NULL,
+    user_id TEXT NOT NULL,
+    created_at TEXT NOT NULL,
+    expires_at TEXT NOT NULL,
+    ip_address TEXT,
+    user_agent TEXT,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Index for session lookups by user
+CREATE INDEX idx_sessions_user_id ON sessions(user_id);
+
+-- Index for expired session cleanup
+CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
+```
+
+### 2.5 Schema Diagram
+
+```mermaid
+erDiagram
+    users ||--o{ user_permissions : has
+    users ||--o{ user_app_data : has
+    users ||--o{ sessions : has
+
+    users {
+        TEXT id PK
+        TEXT username UK
+        TEXT email UK
+        TEXT password_hash
+        INTEGER is_active
+        TEXT created_at
+        TEXT updated_at
+        TEXT last_login_at
+    }
+
+    user_permissions {
+        INTEGER id PK
+        TEXT user_id FK
+        TEXT permission
+    }
+
+    user_app_data {
+        INTEGER id PK
+        TEXT user_id FK
+        TEXT key UK
+        TEXT value
+    }
+
+    sessions {
+        TEXT id PK
+        TEXT user_id FK
+        TEXT created_at
+        TEXT expires_at
+        TEXT ip_address
+        TEXT user_agent
+    }
+```
+
+---
+
+## 3. Implementation Details
+
+### 3.1 SqlUserRepository Implementation
+
+**File:** `src/Authentication/Repositories/SqlUserRepository.vala`
+
+**Constructor:**
+```vala
+public class SqlUserRepository : GLib.Object, UserRepository {
+
+    private InvercargillSql.Connection _connection;
+
+    public SqlUserRepository(InvercargillSql.Connection connection) {
+        _connection = connection;
+    }
+    
+    // ... implementation
+}
+```
+
+**Key Implementation Patterns:**
+
+1. **INSERT with Parameters:**
+```vala
+public async User create_async(User user) throws Error {
+    var sql = """
+        INSERT INTO users (id, username, email, password_hash, is_active, created_at)
+        VALUES (:id, :username, :email, :password_hash, :is_active, :created_at)
+    """;
+    
+    yield _connection.create_command(sql)
+        .with_parameter("id", user.id)
+        .with_parameter("username", user.username)
+        .with_parameter("email", user.email)
+        .with_parameter("password_hash", user.password_hash)
+        .with_parameter("is_active", 1)
+        .with_parameter("created_at", user.created_at.format_iso8601())
+        .execute_non_query_async();
+    
+    return user;
+}
+```
+
+2. **SELECT with Properties Mapping:**
+```vala
+public async User? get_by_id_async(string id) throws Error {
+    var sql = "SELECT * FROM users WHERE id = :id";
+    
+    var results = yield _connection.create_command(sql)
+        .with_parameter("id", id)
+        .execute_query_async();
+    
+    var row = results.first_or_default();
+    if (row == null) return null;
+    
+    return user_from_properties(row);
+}
+
+private User user_from_properties(Properties props) {
+    var user = new User();
+    user.set_id(props.get("id").as<string>());
+    user.set_username(props.get("username").as<string>());
+    user.email = props.get("email").as<string>();
+    user.password_hash = props.get("password_hash").as<string>());
+    
+    var created_str = props.get("created_at").as<string>();
+    user.created_at = new DateTime.from_iso8601(created_str, new TimeZone.utc());
+    
+    // Handle nullable fields
+    if (props.has("updated_at") && props.get("updated_at") != null) {
+        var updated_str = props.get("updated_at").as<string>();
+        user.updated_at = new DateTime.from_iso8601(updated_str, new TimeZone.utc());
+    }
+    
+    return user;
+}
+```
+
+3. **Permissions as Sub-query:**
+```vala
+public async User? get_by_id_async(string id) throws Error {
+    // Get user with permissions in single query
+    var sql = """
+        SELECT u.*, GROUP_CONCAT(up.permission, ',') as permissions
+        FROM users u
+        LEFT JOIN user_permissions up ON u.id = up.user_id
+        WHERE u.id = :id
+        GROUP BY u.id
+    """;
+    
+    // ... execute and parse
+}
+```
+
+### 3.2 SqlSessionRepository Implementation
+
+**File:** `src/Authentication/Repositories/SqlSessionRepository.vala`
+
+**Constructor:**
+```vala
+public class SqlSessionRepository : GLib.Object, SessionRepository {
+
+    private InvercargillSql.Connection _connection;
+
+    public SqlSessionRepository(InvercargillSql.Connection connection) {
+        _connection = connection;
+    }
+    
+    // ... implementation
+}
+```
+
+**Key Implementation Patterns:**
+
+1. **Create Session:**
+```vala
+public async Session create_async(Session session) throws Error {
+    var sql = """
+        INSERT INTO sessions (id, user_id, created_at, expires_at, ip_address, user_agent)
+        VALUES (:id, :user_id, :created_at, :expires_at, :ip_address, :user_agent)
+    """;
+    
+    yield _connection.create_command(sql)
+        .with_parameter("id", session.id)
+        .with_parameter("user_id", session.user_id)
+        .with_parameter("created_at", session.created_at.format_iso8601())
+        .with_parameter("expires_at", session.expires_at.format_iso8601())
+        .with_parameter("ip_address", session.ip_address ?? "")
+        .with_parameter("user_agent", session.user_agent ?? "")
+        .execute_non_query_async();
+    
+    return session;
+}
+```
+
+2. **Get Sessions by User:**
+```vala
+public async Vector<Session> get_by_user_async(string user_id) throws Error {
+    var sql = """
+        SELECT * FROM sessions 
+        WHERE user_id = :user_id AND expires_at > :now
+        ORDER BY created_at DESC
+    """;
+    
+    var now = new DateTime.now_utc().format_iso8601();
+    
+    var results = yield _connection.create_command(sql)
+        .with_parameter("user_id", user_id)
+        .with_parameter("now", now)
+        .execute_query_async();
+    
+    var sessions = new Vector<Session>();
+    foreach (var row in results) {
+        sessions.add(session_from_properties(row));
+    }
+    return sessions;
+}
+```
+
+3. **Cleanup Expired:**
+```vala
+public async void cleanup_expired_async() throws Error {
+    var sql = "DELETE FROM sessions WHERE expires_at < :now";
+    var now = new DateTime.now_utc().format_iso8601();
+    
+    yield _connection.create_command(sql)
+        .with_parameter("now", now)
+        .execute_non_query_async();
+}
+```
+
+### 3.3 Schema Migration Class
+
+**File:** `src/Authentication/Migrations/CreateAuthTables.vala`
+
+```vala
+namespace Spry.Authentication.Migrations {
+
+    /**
+     * Creates the authentication database schema.
+     * Run once during application initialization.
+     */
+    public class CreateAuthTables : GLib.Object {
+
+        private InvercargillSql.Connection _connection;
+
+        public CreateAuthTables(InvercargillSql.Connection connection) {
+            _connection = connection;
+        }
+
+        /**
+         * Creates all authentication tables if they don't exist.
+         */
+        public async void migrate_async() throws Error {
+            // Users table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS users (
+                    id TEXT PRIMARY KEY NOT NULL,
+                    username TEXT NOT NULL UNIQUE,
+                    email TEXT NOT NULL UNIQUE,
+                    password_hash TEXT NOT NULL,
+                    is_active INTEGER NOT NULL DEFAULT 1,
+                    created_at TEXT NOT NULL,
+                    updated_at TEXT,
+                    last_login_at TEXT
+                )
+            """).execute_non_query_async();
+
+            // User permissions table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS user_permissions (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    user_id TEXT NOT NULL,
+                    permission TEXT NOT NULL,
+                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+                    UNIQUE(user_id, permission)
+                )
+            """).execute_non_query_async();
+
+            // User app data table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS user_app_data (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    user_id TEXT NOT NULL,
+                    key TEXT NOT NULL,
+                    value TEXT,
+                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+                    UNIQUE(user_id, key)
+                )
+            """).execute_non_query_async();
+
+            // Sessions table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS sessions (
+                    id TEXT PRIMARY KEY NOT NULL,
+                    user_id TEXT NOT NULL,
+                    created_at TEXT NOT NULL,
+                    expires_at TEXT NOT NULL,
+                    ip_address TEXT,
+                    user_agent TEXT,
+                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+                )
+            """).execute_non_query_async();
+
+            // Create indexes
+            yield create_index_async("idx_users_username", "users(username)");
+            yield create_index_async("idx_users_email", "users(email)");
+            yield create_index_async("idx_user_permissions_user_id", "user_permissions(user_id)");
+            yield create_index_async("idx_user_app_data_user_id", "user_app_data(user_id)");
+            yield create_index_async("idx_sessions_user_id", "sessions(user_id)");
+            yield create_index_async("idx_sessions_expires_at", "sessions(expires_at)");
+        }
+
+        private async void create_index_async(string name, string definition) throws Error {
+            yield _connection.create_command(
+                "CREATE INDEX IF NOT EXISTS %s ON %s".printf(name, definition)
+            ).execute_non_query_async();
+        }
+    }
+}
+```
+
+---
+
+## 4. Code Migration Strategy
+
+### 4.1 UserService Changes
+
+**Current Dependencies (to remove):**
+```vala
+using Implexus.Core;
+private Engine _engine = inject<Engine>();
+```
+
+**New Dependencies (to add):**
+```vala
+private UserRepository _user_repository = inject<UserRepository>();
+```
+
+**Methods to Modify:**
+
+| Method | Current Implementation | New Implementation |
+|--------|----------------------|-------------------|
+| [`create_user_async()`](../src/Authentication/UserService.vala:65) | Uses `create_document_async()`, `set_entity_property_async()` | Call `_user_repository.create_async()` |
+| [`get_user_async()`](../src/Authentication/UserService.vala:116) | Uses `get_entity_or_null_async()`, `get_properties_async()` | Call `_user_repository.get_by_id_async()` |
+| [`get_user_by_username_async()`](../src/Authentication/UserService.vala:137) | Uses catalogue path lookup | Call `_user_repository.get_by_username_async()` |
+| [`get_user_by_email_async()`](../src/Authentication/UserService.vala:170) | Uses catalogue path lookup | Call `_user_repository.get_by_email_async()` |
+| [`update_user_async()`](../src/Authentication/UserService.vala:207) | Uses `set_entity_property_async()` | Call `_user_repository.update_async()` |
+| [`delete_user_async()`](../src/Authentication/UserService.vala:256) | Uses `delete_async()` on entity | Call `_user_repository.delete_async()` |
+| [`list_users_async()`](../src/Authentication/UserService.vala:281) | Iterates container children | Call `_user_repository.list_async()` |
+| [`username_exists_async()`](../src/Authentication/UserService.vala:414) | Uses `entity_exists_async()` on catalogue path | Call `_user_repository.username_exists_async()` |
+| [`email_exists_async()`](../src/Authentication/UserService.vala:426) | Uses `entity_exists_async()` on catalogue path | Call `_user_repository.email_exists_async()` |
+| [`user_count_async()`](../src/Authentication/UserService.vala:437) | Counts document children | Call `_user_repository.count_async()` |
+
+**Methods to Remove (Private Helpers):**
+- [`get_users_container_async()`](../src/Authentication/UserService.vala:459) - No longer needed
+- [`store_user_in_document_async()`](../src/Authentication/UserService.vala:483) - No longer needed
+- [`load_user_from_document_async()`](../src/Authentication/UserService.vala:493) - No longer needed
+- [`json_to_element()`](../src/Authentication/UserService.vala:503) - No longer needed
+- [`properties_to_json()`](../src/Authentication/UserService.vala:534) - No longer needed
+- [`element_to_json()`](../src/Authentication/UserService.vala:544) - No longer needed
+
+**Refactored UserService (simplified):**
+```vala
+using Invercargill.DataStructures;
+using Inversion;
+
+namespace Spry.Authentication {
+
+    public class UserService : GLib.Object {
+
+        private UserRepository _user_repository = inject<UserRepository>();
+        private CryptographyProvider _crypto = inject<CryptographyProvider>();
+
+        public async User create_user_async(string username, string email, string password) throws Error {
+            // Validate username uniqueness
+            if (yield _user_repository.username_exists_async(username)) {
+                throw new UserError.DUPLICATE_USERNAME("Username already exists");
+            }
+
+            // Validate email uniqueness
+            if (yield _user_repository.email_exists_async(email)) {
+                throw new UserError.DUPLICATE_EMAIL("Email already exists");
+            }
+
+            // Hash password
+            var password_hash = hash_password(password);
+            if (password_hash == null) {
+                throw new UserError.STORAGE_ERROR("Failed to hash password");
+            }
+
+            // Create user object
+            var user = new User();
+            user.set_id(generate_uuid());
+            user.set_username(username);
+            user.email = email;
+            user.password_hash = (!)password_hash;
+            user.created_at = new DateTime.now_utc();
+
+            // Persist via repository
+            return yield _user_repository.create_async(user);
+        }
+
+        public async User? get_user_async(string user_id) throws Error {
+            return yield _user_repository.get_by_id_async(user_id);
+        }
+
+        public async User? get_user_by_username_async(string username) throws Error {
+            return yield _user_repository.get_by_username_async(username);
+        }
+
+        public async User? get_user_by_email_async(string email) throws Error {
+            return yield _user_repository.get_by_email_async(email);
+        }
+
+        public async void update_user_async(User user) throws Error {
+            // Check for username/email conflicts before update
+            var existing = yield _user_repository.get_by_id_async(user.id);
+            if (existing == null) {
+                throw new UserError.USER_NOT_FOUND("User not found");
+            }
+
+            if (existing.username != user.username) {
+                if (yield _user_repository.username_exists_async(user.username)) {
+                    throw new UserError.DUPLICATE_USERNAME("Username already exists");
+                }
+            }
+
+            if (existing.email != user.email) {
+                if (yield _user_repository.email_exists_async(user.email)) {
+                    throw new UserError.DUPLICATE_EMAIL("Email already exists");
+                }
+            }
+
+            user.updated_at = new DateTime.now_utc();
+            yield _user_repository.update_async(user);
+        }
+
+        public async void delete_user_async(string user_id) throws Error {
+            yield _user_repository.delete_async(user_id);
+        }
+
+        public async Vector<User> list_users_async(int offset = 0, int limit = 100) throws Error {
+            return yield _user_repository.list_async(offset, limit);
+        }
+
+        public async bool username_exists_async(string username) throws Error {
+            return yield _user_repository.username_exists_async(username);
+        }
+
+        public async bool email_exists_async(string email) throws Error {
+            return yield _user_repository.email_exists_async(email);
+        }
+
+        public async int user_count_async() throws Error {
+            return yield _user_repository.count_async();
+        }
+
+        // Password methods remain unchanged
+        public string? hash_password(string password) {
+            return Sodium.PasswordHashing.hash(password);
+        }
+
+        public bool verify_password(User user, string password) {
+            return Sodium.PasswordHashing.check(user.password_hash, password);
+        }
+
+        public async void set_password_async(User user, string new_password) throws Error {
+            var password_hash = hash_password(new_password);
+            if (password_hash == null) {
+                throw new UserError.STORAGE_ERROR("Failed to hash password");
+            }
+            user.password_hash = (!)password_hash;
+            user.updated_at = new DateTime.now_utc();
+            yield update_user_async(user);
+        }
+
+        public async User? authenticate_async(string username_or_email, string password) throws Error {
+            User? user = yield get_user_by_username_async(username_or_email);
+            if (user == null) {
+                user = yield get_user_by_email_async(username_or_email);
+            }
+            if (user == null) return null;
+
+            if (!verify_password(user, password)) return null;
+            return user;
+        }
+
+        private string generate_uuid() {
+            uint8[] bytes = new uint8[16];
+            Sodium.Random.random_bytes(bytes);
+            bytes[6] = (bytes[6] & 0x0f) | 0x40;
+            bytes[8] = (bytes[8] & 0x3f) | 0x80;
+            return "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x".printf(
+                bytes[0], bytes[1], bytes[2], bytes[3],
+                bytes[4], bytes[5], bytes[6], bytes[7],
+                bytes[8], bytes[9], bytes[10], bytes[11],
+                bytes[12], bytes[13], bytes[14], bytes[15]
+            );
+        }
+    }
+}
+```
+
+### 4.2 SessionService Changes
+
+**Current Dependencies (to remove):**
+```vala
+using Implexus.Core;
+private Engine _engine = inject<Engine>();
+```
+
+**New Dependencies (to add):**
+```vala
+private SessionRepository _session_repository = inject<SessionRepository>();
+```
+
+**Methods to Modify:**
+
+| Method | Current Implementation | New Implementation |
+|--------|----------------------|-------------------|
+| [`create_session_async()`](../src/Authentication/SessionService.vala:209) | Creates document + updates user index | Call `_session_repository.create_async()` |
+| [`get_session_async()`](../src/Authentication/SessionService.vala:387) | Uses `get_entity_or_null_async()` | Call `_session_repository.get_by_id_async()` |
+| [`get_sessions_for_user_async()`](../src/Authentication/SessionService.vala:412) | Uses secondary index | Call `_session_repository.get_by_user_async()` |
+| [`delete_session_async()`](../src/Authentication/SessionService.vala:441) | Deletes document + updates index | Call `_session_repository.delete_async()` |
+| [`delete_all_sessions_for_user_async()`](../src/Authentication/SessionService.vala:468) | Iterates index + deletes | Call `_session_repository.delete_by_user_async()` |
+| [`cleanup_expired_sessions_async()`](../src/Authentication/SessionService.vala:562) | Iterates all sessions | Call `_session_repository.cleanup_expired_async()` |
+
+**Methods to Remove (Private Helpers):**
+- [`get_sessions_container_async()`](../src/Authentication/SessionService.vala:641) - No longer needed
+- [`get_sessions_by_user_container_async()`](../src/Authentication/SessionService.vala:665) - No longer needed
+- [`store_session_in_document_async()`](../src/Authentication/SessionService.vala:688) - No longer needed
+- [`load_session_from_document_async()`](../src/Authentication/SessionService.vala:698) - No longer needed
+- [`json_to_element()`](../src/Authentication/SessionService.vala:708) - No longer needed
+- [`properties_to_json()`](../src/Authentication/SessionService.vala:740) - No longer needed
+- [`element_to_json()`](../src/Authentication/SessionService.vala:750) - No longer needed
+- [`add_session_to_user_index_async()`](../src/Authentication/SessionService.vala:834) - No longer needed
+- [`remove_session_from_user_index_async()`](../src/Authentication/SessionService.vala:873) - No longer needed
+- [`clear_user_sessions_index_async()`](../src/Authentication/SessionService.vala:897) - No longer needed
+- [`get_session_ids_for_user_async()`](../src/Authentication/SessionService.vala:906) - No longer needed
+- [`load_session_ids_from_document_async()`](../src/Authentication/SessionService.vala:917) - No longer needed
+- [`store_session_ids_in_document_async()`](../src/Authentication/SessionService.vala:940) - No longer needed
+
+### 4.3 PermissionService Changes
+
+PermissionService already delegates to UserService, so minimal changes are needed. The `update_user_async()` call in PermissionService will work transparently with the repository-based UserService.
+
+---
+
+## 5. Build System Changes
+
+### 5.1 Root meson.build Changes
+
+**File:** [`meson.build`](../meson.build)
+
+**Add InvercargillSql dependency:**
+```meson
+# Add after existing dependencies (around line 17)
+invercargill_sql_dep = dependency('invercargill-sql-1')
+```
+
+**Note:** The `implexus_dep` can be removed from the root meson.build if no other modules use it. For this migration, keep it temporarily until full removal is confirmed.
+
+### 5.2 src/meson.build Changes
+
+**File:** [`src/meson.build`](../src/meson.build)
+
+No changes needed - the main Spry library doesn't directly use Implexus.
+
+### 5.3 src/Authentication/meson.build Changes
+
+**File:** [`src/Authentication/meson.build`](../src/Authentication/meson.build)
+
+**Current content:**
+```meson
+authentication_sources = files(
+    'User.vala',
+    'Session.vala',
+    'UserService.vala',
+    'SessionService.vala',
+    'PermissionService.vala',
+    'UserIdentityProvider.vala',
+    'Components/LoginFormComponent.vala',
+    'Components/UserManagementComponent.vala',
+    'Components/UserDetailsComponent.vala',
+    'Components/NewUserComponent.vala',
+    'AuthenticationMigration.vala'
+)
+
+libspry_authentication = static_library('spry-authentication',
+    authentication_sources,
+    dependencies: [spry_dep, spry_authorisation_dep, implexus_dep, sodium_deps, invercargill_dep, astralis_dep],
+    include_directories: include_directories('..')
+)
+
+spry_authentication_inc = include_directories('.')
+spry_authentication_dep = declare_dependency(
+    link_with: libspry_authentication,
+    include_directories: spry_authentication_inc,
+    dependencies: [spry_dep, spry_authorisation_dep, implexus_dep]
+)
+```
+
+**New content:**
+```meson
+authentication_sources = files(
+    'User.vala',
+    'Session.vala',
+    'UserService.vala',
+    'SessionService.vala',
+    'PermissionService.vala',
+    'UserIdentityProvider.vala',
+    'Components/LoginFormComponent.vala',
+    'Components/UserManagementComponent.vala',
+    'Components/UserDetailsComponent.vala',
+    'Components/NewUserComponent.vala',
+    'Repositories/UserRepository.vala',
+    'Repositories/SqlUserRepository.vala',
+    'Repositories/SessionRepository.vala',
+    'Repositories/SqlSessionRepository.vala',
+    'Migrations/CreateAuthTables.vala'
+)
+
+libspry_authentication = static_library('spry-authentication',
+    authentication_sources,
+    dependencies: [spry_dep, spry_authorisation_dep, invercargill_sql_dep, sodium_deps, invercargill_dep, astralis_dep],
+    include_directories: include_directories('..')
+)
+
+spry_authentication_inc = include_directories('.')
+spry_authentication_dep = declare_dependency(
+    link_with: libspry_authentication,
+    include_directories: spry_authentication_inc,
+    dependencies: [spry_dep, spry_authorisation_dep, invercargill_sql_dep]
+)
+```
+
+**Summary of changes:**
+1. Remove `'AuthenticationMigration.vala'` from sources
+2. Add new repository files to sources
+3. Add new migration file to sources
+4. Replace `implexus_dep` with `invercargill_sql_dep` in dependencies
+
+---
+
+## 6. Migration Path
+
+### 6.1 Implementation Order
+
+```mermaid
+flowchart TB
+    subgraph Phase 1 - Infrastructure
+        A1[Add invercargill_sql_dep to meson.build]
+        A2[Create Repositories directory]
+        A3[Create Migrations directory]
+    end
+    
+    subgraph Phase 2 - Repository Layer
+        B1[Create UserRepository interface]
+        B2[Create SessionRepository interface]
+        B3[Implement SqlUserRepository]
+        B4[Implement SqlSessionRepository]
+        B5[Create CreateAuthTables migration]
+    end
+    
+    subgraph Phase 3 - Service Migration
+        C1[Update UserService to use UserRepository]
+        C2[Update SessionService to use SessionRepository]
+        C3[Remove Implexus imports and helpers]
+    end
+    
+    subgraph Phase 4 - Build Updates
+        D1[Update src/Authentication/meson.build]
+        D2[Delete AuthenticationMigration.vala]
+        D3[Remove implexus_dep from root meson.build]
+    end
+    
+    subgraph Phase 5 - IoC Registration
+        E1[Register Connection in IoC container]
+        E2[Register UserRepository to SqlUserRepository]
+        E3[Register SessionRepository to SqlSessionRepository]
+        E4[Run migration at startup]
+    end
+    
+    A1 --> A2 --> A3 --> B1
+    B1 --> B2 --> B3 --> B4 --> B5
+    B5 --> C1 --> C2 --> C3
+    C3 --> D1 --> D2 --> D3
+    D3 --> E1 --> E2 --> E3 --> E4
+```
+
+### 6.2 Step-by-Step Implementation Checklist
+
+#### Phase 1: Infrastructure Setup
+- [ ] Add `invercargill_sql_dep = dependency('invercargill-sql-1')` to root [`meson.build`](../meson.build)
+- [ ] Create directory `src/Authentication/Repositories/`
+- [ ] Create directory `src/Authentication/Migrations/`
+
+#### Phase 2: Repository Interfaces
+- [ ] Create [`src/Authentication/Repositories/UserRepository.vala`](../src/Authentication/Repositories/UserRepository.vala) with interface definition
+- [ ] Create [`src/Authentication/Repositories/SessionRepository.vala`](../src/Authentication/Repositories/SessionRepository.vala) with interface definition
+
+#### Phase 3: Repository Implementations
+- [ ] Create [`src/Authentication/Repositories/SqlUserRepository.vala`](../src/Authentication/Repositories/SqlUserRepository.vala)
+  - Implement `get_by_id_async()`
+  - Implement `get_by_username_async()`
+  - Implement `get_by_email_async()`
+  - Implement `create_async()`
+  - Implement `update_async()`
+  - Implement `delete_async()`
+  - Implement `username_exists_async()`
+  - Implement `email_exists_async()`
+  - Implement `list_async()`
+  - Implement `count_async()`
+  - Implement permission methods
+  - Implement app data methods
+- [ ] Create [`src/Authentication/Repositories/SqlSessionRepository.vala`](../src/Authentication/Repositories/SqlSessionRepository.vala)
+  - Implement `get_by_id_async()`
+  - Implement `get_by_user_async()`
+  - Implement `create_async()`
+  - Implement `update_async()`
+  - Implement `delete_async()`
+  - Implement `delete_by_user_async()`
+  - Implement `cleanup_expired_async()`
+
+#### Phase 4: Schema Migration
+- [ ] Create [`src/Authentication/Migrations/CreateAuthTables.vala`](../src/Authentication/Migrations/CreateAuthTables.vala)
+
+#### Phase 5: Service Refactoring
+- [ ] Update [`src/Authentication/UserService.vala`](../src/Authentication/UserService.vala):
+  - Remove `using Implexus.Core;`
+  - Replace `Engine _engine` with `UserRepository _user_repository`
+  - Refactor all methods to use repository
+  - Remove private helper methods for Implexus
+- [ ] Update [`src/Authentication/SessionService.vala`](../src/Authentication/SessionService.vala):
+  - Remove `using Implexus.Core;`
+  - Replace `Engine _engine` with `SessionRepository _session_repository`
+  - Refactor all methods to use repository
+  - Remove private helper methods for Implexus
+  - Remove user index management methods
+
+#### Phase 6: Build System Updates
+- [ ] Update [`src/Authentication/meson.build`](../src/Authentication/meson.build):
+  - Add new repository files to sources
+  - Add new migration file to sources
+  - Remove `AuthenticationMigration.vala` from sources
+  - Replace `implexus_dep` with `invercargill_sql_dep`
+- [ ] Delete [`src/Authentication/AuthenticationMigration.vala`](../src/Authentication/AuthenticationMigration.vala)
+- [ ] Optionally remove `implexus_dep` from root [`meson.build`](../meson.build) if no other modules use it
+
+#### Phase 7: IoC Registration Updates
+- [ ] Update application startup code to:
+  - Create/open SQLite database connection
+  - Register `InvercargillSql.Connection` as singleton in IoC container
+  - Register `UserRepository` → `SqlUserRepository` in IoC container
+  - Register `SessionRepository` → `SqlSessionRepository` in IoC container
+  - Run `CreateAuthTables.migrate_async()` on first startup
+
+### 6.3 IoC Registration Example
+
+**In application startup code (e.g., main application entry point):**
+
+```vala
+using Inversion;
+using InvercargillSql;
+
+// Create and open database connection
+var db_path = Path.build_filename(Environment.get_user_data_dir(), "spry", "authentication.db");
+var connection = new Connection(db_path);
+yield connection.open_async();
+
+// Register in IoC container
+var container = new Container();
+container.register<Connection>(() => connection).as_singleton();
+container.register<UserRepository, SqlUserRepository>().as_singleton();
+container.register<SessionRepository, SqlSessionRepository>().as_singleton();
+container.register<UserService>().as_singleton();
+container.register<SessionService>().as_singleton();
+container.register<PermissionService>().as_singleton();
+
+// Run schema migration
+var migration = new Migrations.CreateAuthTables(connection);
+yield migration.migrate_async();
+```
+
+### 6.4 Data Migration Considerations
+
+If there is existing production data in Implexus:
+
+1. **Create a data migration script** that:
+   - Reads all users from Implexus
+   - Reads all sessions from Implexus
+   - Inserts them into the SQL tables
+   - Verifies data integrity
+
+2. **Migration script outline:**
+```vala
+public async void migrate_data_from_implexus_async(
+    Engine implexus_engine,
+    InvercargillSql.Connection sql_connection
+) throws Error {
+    // Migrate users
+    var users_path = new EntityPath("/spry/authentication/users");
+    var users_container = yield implexus_engine.get_entity_or_null_async(users_path);
+    
+    if (users_container != null) {
+        var children = yield users_container.get_children_async();
+        foreach (var child in children) {
+            if (child.entity_type == EntityType.DOCUMENT) {
+                var props = yield child.get_properties_async();
+                // Convert to User object and insert into SQL
+            }
+        }
+    }
+    
+    // Migrate sessions similarly
+}
+```
+
+### 6.5 Testing Strategy
+
+1. **Unit Tests for Repositories:**
+   - Test each repository method in isolation
+   - Use in-memory SQLite database for fast tests
+   - Test edge cases (null values, empty results, duplicates)
+
+2. **Integration Tests for Services:**
+   - Test UserService with SqlUserRepository
+   - Test SessionService with SqlSessionRepository
+   - Verify public API remains unchanged
+
+3. **Migration Tests:**
+   - Test schema creation on fresh database
+   - Test idempotency (running migration twice)
+   - Test data migration from Implexus (if applicable)
+
+4. **Performance Tests:**
+   - Benchmark user lookup by username
+   - Benchmark session listing by user
+   - Compare with Implexus performance
+
+---
+
+## 7. Files Summary
+
+### 7.1 New Files to Create
+
+| File | Purpose |
+|------|---------|
+| `src/Authentication/Repositories/UserRepository.vala` | User repository interface |
+| `src/Authentication/Repositories/SessionRepository.vala` | Session repository interface |
+| `src/Authentication/Repositories/SqlUserRepository.vala` | SQLite user repository implementation |
+| `src/Authentication/Repositories/SqlSessionRepository.vala` | SQLite session repository implementation |
+| `src/Authentication/Migrations/CreateAuthTables.vala` | SQL schema migration |
+
+### 7.2 Files to Modify
+
+| File | Changes |
+|------|---------|
+| [`meson.build`](../meson.build) | Add `invercargill_sql_dep` dependency |
+| [`src/Authentication/meson.build`](../src/Authentication/meson.build) | Update sources and dependencies |
+| [`src/Authentication/UserService.vala`](../src/Authentication/UserService.vala) | Replace Implexus with repository |
+| [`src/Authentication/SessionService.vala`](../src/Authentication/SessionService.vala) | Replace Implexus with repository |
+
+### 7.3 Files to Delete
+
+| File | Reason |
+|------|--------|
+| [`src/Authentication/AuthenticationMigration.vala`](../src/Authentication/AuthenticationMigration.vala) | Replaced by SQL migration |
+
+---
+
+## 8. Risk Mitigation
+
+| Risk | Mitigation |
+|------|------------|
+| Breaking existing API | Repository pattern isolates changes; public service APIs unchanged |
+| Data loss during migration | Create backup before migration; implement data migration script |
+| Performance regression | SQL indexes replicate catalogue performance; benchmark critical paths |
+| Async behavior differences | Both use async/await; InvercargillSql uses thread-based async (transparent) |
+| Missing features in InvercargillSql | Verify API coverage before starting; implement workarounds if needed |
+
+---
+
+## 9. Verification Checklist
+
+After implementation, verify:
+
+- [ ] All existing tests pass
+- [ ] User CRUD operations work correctly
+- [ ] Session CRUD operations work correctly
+- [ ] Username/email uniqueness is enforced
+- [ ] Permissions are correctly stored and retrieved
+- [ ] App data is correctly stored and retrieved
+- [ ] Expired sessions are cleaned up
+- [ ] Foreign key constraints work (deleting user deletes sessions)
+- [ ] Build completes without errors
+- [ ] No Implexus references remain in Authentication module

+ 804 - 0
plans/user-management-component-redesign.md

@@ -0,0 +1,804 @@
+# User Management Component Redesign
+
+## Executive Summary
+
+This document outlines the redesign of the Spry Authentication user management components. The redesign focuses on:
+1. Converting `UserManagementPage` to `UserManagementComponent` for better placement control
+2. Removing all CSS classes to avoid conflicts with application-defined styles
+3. Implementing an HTML5 `<details>`/`<summary>` pattern for user display
+4. Using separate view/edit components that swap via HTMX for cleaner separation
+5. Integrating permission editing directly into the edit component (removing standalone `PermissionEditorComponent`)
+
+---
+
+## Current Implementation Overview
+
+### Component Hierarchy
+
+```mermaid
+graph TD
+    A[UserManagementPage - PageComponent] --> B[UserListComponent]
+    A --> C[UserFormComponent]
+    B --> D[UserListItemComponent - per user]
+    C --> E[PermissionEditorComponent]
+```
+
+### Current Components
+
+| Component | Type | Purpose |
+|-----------|------|---------|
+| [`UserManagementPage`](src/Authentication/Components/UserManagementPage.vala) | PageComponent | Full HTML page with embedded CSS, permission check, orchestrates child components |
+| [`UserListComponent`](src/Authentication/Components/UserListComponent.vala) | Component | Table of users with search/pagination, creates UserListItemComponent per row |
+| [`UserListItemComponent`](src/Authentication/Components/UserListItemComponent.vala) | Component | Single table row with user info and action buttons |
+| [`UserFormComponent`](src/Authentication/Components/UserFormComponent.vala) | Component | Modal form for create/edit with validation |
+| [`PermissionEditorComponent`](src/Authentication/Components/PermissionEditorComponent.vala) | Component | Checkbox-based permission selection |
+
+### Current Issues
+
+1. **Tight Coupling to Page Structure**: `UserManagementPage` is a `PageComponent` that generates a full HTML document, limiting where it can be placed
+2. **CSS Class Pollution**: Extensive use of CSS classes like `.admin-container`, `.btn`, `.modal-overlay`, `.spry-user-list` may conflict with application styles
+3. **Modal-Based Editing**: Separate modal form disrupts the flow and requires additional clicks
+4. **Complex Component Tree**: Five separate components increase maintenance burden
+
+---
+
+## New Design
+
+### Component Hierarchy
+
+```mermaid
+graph TD
+    A[UserManagementComponent - Container] --> B[UserDetailsComponent - view mode per user]
+    A --> C[UserDetailEditComponent - edit mode swaps in]
+    A --> D[NewUserComponent - for creation]
+    C --> E[Integrated Permission Editing]
+    D --> E
+```
+
+### Simplified Component Structure
+
+| Component | Type | Purpose |
+|-----------|------|---------|
+| `UserManagementComponent` | Component | Container with header, new user button, user list outlet |
+| `UserDetailsComponent` | Component | View-only `<details>` element for displaying one user |
+| `UserDetailEditComponent` | Component | Edit mode `<details>` element with form fields and integrated permission editing |
+| `NewUserComponent` | Component | Create form using same pattern as edit, with integrated permission editing |
+
+### Key Changes
+
+1. **`UserManagementPage` → `UserManagementComponent`**:
+   - Changed from `PageComponent` to `Component`
+   - No longer generates full HTML document
+   - Application can place it anywhere
+
+2. **Separate View/Edit Components**:
+   - `UserDetailsComponent` handles read-only display
+   - `UserDetailEditComponent` handles editing (swapped in via HTMX)
+   - Clean separation of concerns - each component has a single responsibility
+
+3. **Remove `PermissionEditorComponent`**:
+   - Permission editing functionality integrated directly into `UserDetailEditComponent` and `NewUserComponent`
+   - Reduces component complexity and eliminates unnecessary abstraction
+
+4. **Remove `UserFormComponent`, `UserListComponent`, `UserListItemComponent`**:
+   - Functionality distributed among new focused components
+
+---
+
+## HTML Structure Design
+
+### UserManagementComponent
+
+```html
+<div sid="user-management" id="user-management" hx-swap="outerHTML">
+    <script spry-res="htmx.js"></script>
+    
+    <!-- Header with Create Button -->
+    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
+        <h3 style="margin: 0;">Users</h3>
+        <button sid="create-btn" 
+                spry-action=":ShowCreateUser"
+                spry-target="user-management"
+                style="padding: 0.5rem 1rem; cursor: pointer;">
+            + Create User
+        </button>
+    </div>
+    
+    <!-- Success/Error Messages -->
+    <div spry-if="this.success_message != null" 
+         style="padding: 0.75rem; margin-bottom: 1rem; background: #d4edda; color: #155724; border-radius: 4px;">
+        <span content-expr="this.success_message"></span>
+    </div>
+    
+    <div spry-if="this.error_message != null"
+         style="padding: 0.75rem; margin-bottom: 1rem; background: #f8d7da; color: #721c24; border-radius: 4px;">
+        <span content-expr="this.error_message"></span>
+    </div>
+    
+    <!-- New User Form (conditionally visible) -->
+    <div spry-if="this.show_create_form" sid="new-user-container">
+        <spry-component name="Spry.Authentication.Components.NewUserComponent" sid="new-user"/>
+    </div>
+    
+    <!-- User List -->
+    <div sid="user-list" style="display: flex; flex-direction: column; gap: 0.5rem;">
+        <spry-outlet sid="users"/>
+    </div>
+</div>
+```
+
+### UserDetailsComponent - View Mode Only
+
+This component handles read-only display of a single user. When the user clicks "Edit", the entire component is swapped out for `UserDetailEditComponent`.
+
+```html
+<details sid="user-details"
+         id-expr="'user-' + this.user_id"
+         hx-swap="outerHTML"
+         open>
+    
+    <!-- Summary: Always visible, shows key info -->
+    <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; cursor: pointer;">
+        <span content-expr="this.username" style="font-weight: 500; min-width: 120px;"></span>
+        <span content-expr="this.email" style="color: #6c757d;"></span>
+        <span spry-if="this.permissions.length > 0" style="margin-left: auto; font-size: 0.85rem; color: #495057;">
+            <span content-expr="this.permissions.length"></span> permission(s)
+        </span>
+    </summary>
+    
+    <!-- Details: Visible when expanded - VIEW ONLY -->
+    <div style="padding: 1rem; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 4px 4px;">
+        <table style="width: 100%; border-collapse: collapse;">
+            <tbody>
+                <tr>
+                    <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">User ID</td>
+                    <td style="padding: 0.5rem 0;"><code content-expr="this.user_id"></code></td>
+                </tr>
+                <tr>
+                    <td style="padding: 0.5rem 0; font-weight: 500;">Username</td>
+                    <td style="padding: 0.5rem 0;"><span content-expr="this.username"></span></td>
+                </tr>
+                <tr>
+                    <td style="padding: 0.5rem 0; font-weight: 500;">Email</td>
+                    <td style="padding: 0.5rem 0;"><span content-expr="this.email"></span></td>
+                </tr>
+                <tr>
+                    <td style="padding: 0.5rem 0; font-weight: 500;">Created</td>
+                    <td style="padding: 0.5rem 0;"><span content-expr="this.created_at"></span></td>
+                </tr>
+                <tr>
+                    <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
+                    <td style="padding: 0.5rem 0;">
+                        <div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
+                            <spry-outlet sid="permission-badges"/>
+                        </div>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        
+        <!-- View Mode Actions -->
+        <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
+            <button sid="edit-btn"
+                    spry-action=":StartEdit"
+                    spry-target="user-details"
+                    style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem;">
+                Edit
+            </button>
+            <button sid="delete-btn"
+                    spry-action=":DeleteUser"
+                    spry-target="user-management"
+                    style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem; background: #dc3545; color: white; border: none; border-radius: 4px;">
+                Delete
+            </button>
+        </div>
+    </div>
+</details>
+```
+
+### UserDetailEditComponent - Edit Mode with Integrated Permissions
+
+This component is swapped in when editing. It includes inline permission editing (no separate component).
+
+```html
+<details sid="user-details-edit"
+         id-expr="'user-' + this.user_id"
+         hx-swap="outerHTML"
+         open>
+    
+    <!-- Summary: Shows user being edited -->
+    <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #fff3cd; border-radius: 4px; cursor: pointer; border: 2px solid #ffc107;">
+        <span content-expr="this.username" style="font-weight: 500; min-width: 120px;"></span>
+        <span style="color: #856404; font-style: italic;">Editing...</span>
+    </summary>
+    
+    <!-- Edit Form -->
+    <div style="padding: 1rem; border: 2px solid #ffc107; border-top: none; border-radius: 0 0 4px 4px; background: #fffdf5;">
+        <form sid="edit-form"
+              spry-action=":SaveEdit"
+              spry-target="user-details-edit">
+            <input type="hidden" name="user_id" sid="user-id-input" value-expr="this.user_id"/>
+            
+            <table style="width: 100%; border-collapse: collapse;">
+                <tbody>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">User ID</td>
+                        <td style="padding: 0.5rem 0;"><code content-expr="this.user_id"></code></td>
+                    </tr>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500;">Username *</td>
+                        <td style="padding: 0.5rem 0;">
+                            <input type="text"
+                                   name="username"
+                                   sid="username-input"
+                                   required
+                                   minlength="3"
+                                   pattern="[a-zA-Z0-9_]+"
+                                   style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
+                                   value-expr="this.username"/>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
+                        <td style="padding: 0.5rem 0;">
+                            <input type="email"
+                                   name="email"
+                                   sid="email-input"
+                                   required
+                                   style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
+                                   value-expr="this.email"/>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500;">New Password</td>
+                        <td style="padding: 0.5rem 0;">
+                            <input type="password"
+                                   name="new_password"
+                                   sid="password-input"
+                                   minlength="8"
+                                   placeholder="Leave blank to keep current"
+                                   style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
+                            <small style="color: #6c757d;">Minimum 8 characters if changing</small>
+                        </td>
+                    </tr>
+                    
+                    <!-- Integrated Permission Editing -->
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
+                        <td style="padding: 0.5rem 0;">
+                            <!-- Common Permissions as Checkboxes -->
+                            <div style="margin-bottom: 0.75rem;">
+                                <div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Common:</div>
+                                <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-management" value="user-management"/>
+                                        User Management
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-create" value="user.create"/>
+                                        Create Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-read" value="user.read"/>
+                                        Read Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-update" value="user.update"/>
+                                        Update Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-delete" value="user.delete"/>
+                                        Delete Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_admin" value="admin"/>
+                                        Admin
+                                    </label>
+                                </div>
+                            </div>
+                            
+                            <!-- Custom Permissions -->
+                            <div>
+                                <div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Custom Permissions:</div>
+                                <div sid="custom-perms" style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem;">
+                                    <spry-outlet sid="custom-permission-tags"/>
+                                </div>
+                                <div style="display: flex; gap: 0.25rem;">
+                                    <input type="text"
+                                           name="new_permission"
+                                           sid="new-perm-input"
+                                           placeholder="e.g., reports.view"
+                                           style="flex: 1; padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem;"/>
+                                    <button type="button"
+                                            sid="add-perm-btn"
+                                            spry-action=":AddPermission"
+                                            spry-target="user-details-edit"
+                                            style="padding: 0.25rem 0.5rem; font-size: 0.875rem; cursor: pointer;">
+                                        Add
+                                    </button>
+                                </div>
+                            </div>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            
+            <!-- Error Message -->
+            <div spry-if="this.error_message != null"
+                 style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
+                <span content-expr="this.error_message"></span>
+            </div>
+            
+            <!-- Edit Actions -->
+            <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
+                <button type="submit" style="padding: 0.5rem 1rem; cursor: pointer;">Save Changes</button>
+                <button type="button"
+                        sid="cancel-btn"
+                        spry-action=":CancelEdit"
+                        spry-target="user-details-edit"
+                        style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
+                    Cancel
+                </button>
+            </div>
+        </form>
+    </div>
+</details>
+```
+
+### NewUserComponent - For Creating New Users
+
+Uses the same pattern as edit mode with integrated permission editing.
+
+```html
+<details sid="new-user"
+         id="new-user"
+         hx-swap="outerHTML"
+         open>
+    
+    <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #e7f3ff; border-radius: 4px; cursor: pointer; border: 2px dashed #007bff;">
+        <span style="font-weight: 500; color: #007bff;">+ New User</span>
+    </summary>
+    
+    <div style="padding: 1rem; border: 2px dashed #007bff; border-top: none; border-radius: 0 0 4px 4px; background: #f8faff;">
+        <form sid="create-form"
+              spry-action=":CreateUser"
+              spry-target="new-user">
+            
+            <table style="width: 100%; border-collapse: collapse;">
+                <tbody>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">Username *</td>
+                        <td style="padding: 0.5rem 0;">
+                            <input type="text"
+                                   name="username"
+                                   sid="username-input"
+                                   required
+                                   minlength="3"
+                                   pattern="[a-zA-Z0-9_]+"
+                                   placeholder="Enter username"
+                                   style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
+                            <small style="color: #6c757d;">Alphanumeric and underscores only, min 3 chars</small>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
+                        <td style="padding: 0.5rem 0;">
+                            <input type="email"
+                                   name="email"
+                                   sid="email-input"
+                                   required
+                                   placeholder="Enter email address"
+                                   style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500;">Password *</td>
+                        <td style="padding: 0.5rem 0;">
+                            <input type="password"
+                                   name="password"
+                                   sid="password-input"
+                                   required
+                                   minlength="8"
+                                   placeholder="Enter password"
+                                   style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
+                            <small style="color: #6c757d;">Minimum 8 characters</small>
+                        </td>
+                    </tr>
+                    
+                    <!-- Integrated Permission Editing (same as edit component) -->
+                    <tr>
+                        <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
+                        <td style="padding: 0.5rem 0;">
+                            <div style="margin-bottom: 0.75rem;">
+                                <div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Common:</div>
+                                <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-management" value="user-management"/>
+                                        User Management
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-create" value="user.create"/>
+                                        Create Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-read" value="user.read"/>
+                                        Read Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-update" value="user.update"/>
+                                        Update Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_user-delete" value="user.delete"/>
+                                        Delete Users
+                                    </label>
+                                    <label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
+                                        <input type="checkbox" name="perm_admin" value="admin"/>
+                                        Admin
+                                    </label>
+                                </div>
+                            </div>
+                            
+                            <div>
+                                <div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Custom Permissions:</div>
+                                <div sid="custom-perms" style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem;">
+                                    <spry-outlet sid="custom-permission-tags"/>
+                                </div>
+                                <div style="display: flex; gap: 0.25rem;">
+                                    <input type="text"
+                                           name="new_permission"
+                                           sid="new-perm-input"
+                                           placeholder="e.g., reports.view"
+                                           style="flex: 1; padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem;"/>
+                                    <button type="button"
+                                            sid="add-perm-btn"
+                                            spry-action=":AddPermission"
+                                            spry-target="new-user"
+                                            style="padding: 0.25rem 0.5rem; font-size: 0.875rem; cursor: pointer;">
+                                        Add
+                                    </button>
+                                </div>
+                            </div>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            
+            <!-- Error Message -->
+            <div spry-if="this.error_message != null"
+                 style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
+                <span content-expr="this.error_message"></span>
+            </div>
+            
+            <!-- Actions -->
+            <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
+                <button type="submit" style="padding: 0.5rem 1rem; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 4px;">
+                    Create User
+                </button>
+                <button type="button"
+                        sid="cancel-btn"
+                        spry-action=":CancelCreate"
+                        spry-target="user-management"
+                        style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
+                    Cancel
+                </button>
+            </div>
+        </form>
+    </div>
+</details>
+```
+
+---
+
+## HTMX Attributes and Targets
+
+### Target Strategy
+
+All actions must properly target the correct DOM elements for HTMX swapping. The key insight is that view and edit components swap each other out using the same element ID:
+
+| Action | Source Component | Target | Swap | Result |
+|--------|------------------|--------|------|--------|
+| `:StartEdit` | UserDetailsComponent | `user-{user_id}` | outerHTML | Swaps in UserDetailEditComponent |
+| `:SaveEdit` | UserDetailEditComponent | `user-{user_id}` | outerHTML | Swaps in UserDetailsComponent - view |
+| `:CancelEdit` | UserDetailEditComponent | `user-{user_id}` | outerHTML | Swaps in UserDetailsComponent - view |
+| `:AddPermission` | UserDetailEditComponent | `user-{user_id}` | outerHTML | Re-renders edit component with new permission |
+| `:RemovePermission` | UserDetailEditComponent | `user-{user_id}` | outerHTML | Re-renders edit component without permission |
+| `:DeleteUser` | UserDetailsComponent | `user-management` | outerHTML | Refreshes entire user list |
+| `:CreateUser` | NewUserComponent | `user-management` | outerHTML | Refreshes list with new user added |
+| `:CancelCreate` | NewUserComponent | `user-management` | outerHTML | Hides create form |
+| `:ShowCreateUser` | UserManagementComponent | `user-management` | outerHTML | Shows NewUserComponent |
+
+### Global ID Strategy
+
+- `UserManagementComponent`: `id="user-management"` (static, for cross-component targeting)
+- `UserDetailsComponent`: `id="user-{user_id}"` (dynamic per user)
+- `UserDetailEditComponent`: `id="user-{user_id}"` (same ID - they swap each other)
+- `NewUserComponent`: `id="new-user"` (static)
+
+### Component Swap Flow Diagram
+
+```mermaid
+sequenceDiagram
+    participant U as User
+    participant UV as UserDetailsComponent - View
+    participant UE as UserDetailEditComponent - Edit
+    participant UM as UserManagementComponent
+    participant S as Server/Service
+    
+    Note over U,S: Edit Flow - Component Swap
+    U->>UV: Click Edit button
+    UV->>S: POST with :StartEdit action
+    S->>S: Create UserDetailEditComponent with user data
+    S->>UE: Return UserDetailEditComponent - swaps in at same ID
+    U->>UE: Modify fields, click Save
+    UE->>S: POST with :SaveEdit action
+    S->>S: Validate and save to UserService
+    S->>S: Create UserDetailsComponent with updated data
+    S->>UV: Return UserDetailsComponent - swaps back at same ID
+    
+    Note over U,S: Delete Flow
+    U->>UV: Click Delete button
+    UV->>S: POST with :DeleteUser action targeting user-management
+    S->>S: Delete user via UserService
+    S->>UM: Return refreshed UserManagementComponent
+    
+    Note over U,S: Create Flow
+    U->>UM: Click Create User button
+    UM->>S: POST with :ShowCreateUser action
+    S->>UM: Return UserManagementComponent with NewUserComponent
+    U->>UM: Fill form, click Create
+    UM->>S: POST with :CreateUser action targeting user-management
+    S->>S: Create user via UserService
+    S->>UM: Return refreshed UserManagementComponent without form
+```
+
+---
+
+## Inline Editing Behavior
+
+### State Management
+
+Each `UserDetailsComponent` maintains its own editing state:
+
+```vala
+public class UserDetailsComponent : Component {
+    // State
+    private User _user;
+    public bool is_editing { get; private set; default = false; }
+    public string? error_message { get; private set; default = null; }
+    
+    // Exposed for template
+    public string user_id { get { return _user.id; } }
+    public string username { get { return _user.username; } }
+    public string email { get { return _user.email; } }
+    public string created_at { get { return _user.created_at.format("%Y-%m-%d"); } }
+    public string[] permissions { get { return _user.permissions; } }
+    
+    // Actions
+    public void start_edit() {
+        is_editing = true;
+        error_message = null;
+    }
+    
+    public void cancel_edit() {
+        is_editing = false;
+        error_message = null;
+    }
+}
+```
+
+### Edit Toggle Flow
+
+1. **View Mode** (default): Shows read-only table with Edit/Delete buttons
+2. **Click Edit**: Triggers `:StartEdit` action, sets `is_editing = true`, re-renders
+3. **Edit Mode**: Shows form inputs in table rows with Save/Cancel buttons
+4. **Save**: Validates input, calls UserService, on success sets `is_editing = false`
+5. **Cancel**: Sets `is_editing = false`, discards changes
+
+---
+
+## New User Creation Pattern
+
+### Visibility Control
+
+The `UserManagementComponent` controls whether the create form is visible:
+
+```vala
+public class UserManagementComponent : Component {
+    public bool show_create_form { get; private set; default = false; }
+    public string? success_message { get; private set; default = null; }
+    public string? error_message { get; private set; default = null; }
+    
+    public async override void handle_action(string action) throws Error {
+        switch (action) {
+            case "ShowCreateUser":
+                show_create_form = true;
+                break;
+            case "CancelCreate":
+                show_create_form = false;
+                break;
+            case "CreateUser":
+                // Handled by NewUserDetailsComponent, but we refresh here
+                show_create_form = false;
+                success_message = "User created successfully";
+                yield refresh_users_async();
+                break;
+            case "DeleteUser":
+                // Handle delete, refresh list
+                yield handle_delete_async();
+                break;
+        }
+    }
+}
+```
+
+### Creation Flow
+
+1. User clicks "Create User" button in header
+2. `UserManagementComponent` sets `show_create_form = true`
+3. `NewUserDetailsComponent` renders at top of list with empty fields
+4. User fills in fields and clicks "Create User"
+5. On success: form hides, success message shows, user list refreshes
+6. On error: error message shows in form, form stays visible
+
+---
+
+## CSS Removal Strategy
+
+### Before (CSS Classes)
+
+```html
+<div class="admin-container">
+    <div class="admin-header">
+        <h1>User Management</h1>
+        <button class="btn btn-primary">Create User</button>
+    </div>
+    <div class="alert alert-success">...</div>
+    <table class="user-table">...</table>
+</div>
+```
+
+### After (Inline Styles)
+
+```html
+<div style="max-width: 1200px; margin: 0 auto; padding: 1rem;">
+    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
+        <h3 style="margin: 0;">User Management</h3>
+        <button style="padding: 0.5rem 1rem; cursor: pointer;">Create User</button>
+    </div>
+    <div style="padding: 0.75rem; margin-bottom: 1rem; background: #d4edda; color: #155724; border-radius: 4px;">...</div>
+    <!-- Table uses minimal inline styles -->
+</div>
+```
+
+### Style Guidelines
+
+1. **Use minimal inline styles** only for essential layout
+2. **No class attributes** except for semantic purposes (not styling)
+3. **Prefer semantic HTML** (`<details>`, `<summary>`, `<table>`)
+4. **Use CSS custom properties** if the application wants to override defaults:
+   ```html
+   <div style="background: var(--spry-alert-success-bg, #d4edda);">
+   ```
+
+---
+
+## API/Endpoint Requirements
+
+### No New Endpoints Required
+
+The redesign uses the existing Spry component action pattern:
+- Actions are handled within components via `handle_action()`
+- No new HTTP endpoints need to be created
+- Existing `UserService` and `PermissionService` methods are sufficient
+
+### Service Dependencies
+
+Components continue to use:
+- `UserService`: create_user_async, get_user_async, update_user_async, delete_user_async, list_users_async
+- `PermissionService`: set_permission_async, has_permission_by_id_async
+- `SessionService`: authenticate_request_async (for permission checks)
+- `ComponentFactory`: create child components
+
+### Potential Future Enhancements
+
+1. **Bulk Operations**: Add `delete_users_async(string[] ids)` for multi-select delete
+2. **Search API**: Add `search_users_async(string query)` to UserService for efficient searching
+3. **Audit Logging**: Add methods to track who changed what and when
+
+---
+
+## Implementation Checklist
+
+### Files to Create
+
+- [ ] `src/Authentication/Components/UserManagementComponent.vala` - New container component
+- [ ] `src/Authentication/Components/UserDetailsComponent.vala` - View-only details/summary component
+- [ ] `src/Authentication/Components/UserDetailEditComponent.vala` - Edit mode with integrated permissions
+- [ ] `src/Authentication/Components/NewUserComponent.vala` - Create form with integrated permissions
+
+### Files to Modify
+
+- [ ] `src/Authentication/meson.build` - Add new component files, remove old ones
+- [ ] `examples/UsersExample.vala` - Update to use new component
+
+### Files to Deprecate/Remove
+
+- [ ] `src/Authentication/Components/UserManagementPage.vala` - Replace with UserManagementComponent
+- [ ] `src/Authentication/Components/UserListComponent.vala` - Merged into UserManagementComponent
+- [ ] `src/Authentication/Components/UserListItemComponent.vala` - Replaced by UserDetailsComponent
+- [ ] `src/Authentication/Components/UserFormComponent.vala` - Functionality moved to UserDetailEditComponent and NewUserComponent
+- [ ] `src/Authentication/Components/PermissionEditorComponent.vala` - Functionality integrated directly into edit/create components
+
+---
+
+## Migration Guide for Applications
+
+### Before (using UserManagementPage)
+
+```vala
+// Register as a page - generates full HTML document
+application.add_transient<UserManagementPage>();
+application.add_endpoint<UserManagementPage>(new EndpointRoute("/admin/users"));
+```
+
+### After (using UserManagementComponent)
+
+```vala
+// Option 1: Use in your own PageComponent
+public class AdminPage : PageComponent {
+    public override string markup {
+        return """
+        <!DOCTYPE html>
+        <html>
+        <head>...</head>
+        <body>
+            <nav>...</nav>
+            <main>
+                <!-- Place component anywhere in your layout -->
+                <spry-component name="Spry.Authentication.Components.UserManagementComponent" sid="user-mgmt"/>
+            </main>
+        </body>
+        </html>
+        """;
+    }
+}
+
+// Option 2: Create a simple wrapper page
+public class UserAdminPage : PageComponent {
+    private ComponentFactory _factory = inject<ComponentFactory>();
+    
+    public override string markup {
+        return """
+        <!DOCTYPE html>
+        <html>
+        <head>
+            <title>User Management</title>
+            <script spry-res="htmx.js"></script>
+        </head>
+        <body>
+            <spry-outlet sid="content"/>
+        </body>
+        </html>
+        """;
+    }
+    
+    public override async void prepare() throws Error {
+        var component = _factory.create<UserManagementComponent>();
+        add_outlet_child("content", component);
+        add_globals_from(component);
+    }
+}
+```
+
+---
+
+## Summary
+
+This redesign simplifies the user management component architecture from 5 components to 3, removes all CSS class dependencies, and implements a modern `<details>`/`<summary>` pattern with inline editing. The key benefits are:
+
+1. **Flexibility**: Applications can place `UserManagementComponent` anywhere in their layout
+2. **No Style Conflicts**: Inline styles avoid CSS class collisions
+3. **Better UX**: Inline editing is more intuitive than modal forms
+4. **Maintainability**: Fewer components with clearer responsibilities
+5. **Consistency**: Same editable table pattern for both editing and creating

+ 1878 - 0
src/Authentication/ARCHITECTURE.md

@@ -0,0 +1,1878 @@
+# 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
+
+```vala
+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
+
+```vala
+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
+
+```vala
+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
+
+```vala
+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
+
+```vala
+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
+
+```vala
+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
+
+```vala
+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
+
+```json
+{
+    "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
+
+```json
+{
+    "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
+
+```vala
+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:
+
+```vala
+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
+
+```vala
+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):
+
+```vala
+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:
+
+```vala
+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:
+
+```vala
+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:
+
+```vala
+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:
+
+```vala
+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:
+
+```vala
+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:
+
+```vala
+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:
+
+```vala
+// 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
+
+```vala
+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:
+
+```vala
+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
+
+```vala
+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
+
+```vala
+// 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
+
+```mermaid
+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
+
+```mermaid
+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
+
+```mermaid
+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
+
+```meson
+# 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`:
+
+```meson
+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

+ 267 - 0
src/Authentication/Components/LoginFormComponent.vala

@@ -0,0 +1,267 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication.Components {
+
+    /**
+     * LoginFormComponent - A reusable login form component with HTMX-based submission.
+     *
+     * This component provides a complete login flow:
+     * - Displays login form with username/email and password fields
+     * - Handles form submission via HTMX
+     * - Authenticates users via UserService
+     * - Creates sessions and sets cookies via SessionService
+     * - Displays error messages on failed authentication
+     * - Redirects to configurable URL on success
+     *
+     * Usage:
+     *   // In a PageComponent or another component's markup:
+     *   <spry-component name="LoginFormComponent" sid="login-form"/>
+     *
+     *   // Or create via factory and configure:
+     *   var login_form = factory.create<LoginFormComponent>();
+     *   login_form.redirect_url = "/dashboard";
+     *
+     * Customization:
+     *   - redirect_url: URL to redirect after successful login (default: "/")
+     *   - login_action_name: Custom action name (default: "login")
+     *   - Override markup property for custom styling
+     *
+     * This component uses the inject<> pattern for dependency injection.
+     */
+    public class LoginFormComponent : Component {
+
+        private UserService _user_service = inject<UserService>();
+        private SessionService _session_service = inject<SessionService>();
+        private PermissionService? _permission_service = inject<PermissionService>();
+        private HttpContext _http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * URL to redirect to after successful login.
+         * Default: "/"
+         */
+        public string redirect_url { get; set; default = "/"; }
+
+        /**
+         * Duration for "remember me" sessions in hours.
+         * When "remember_me" is checked, session duration is extended.
+         * Default: 168 (7 days)
+         */
+        public int remember_me_duration_hours { get; set; default = 168; }
+
+        // =========================================================================
+        // State Properties
+        // =========================================================================
+
+        /**
+         * Error message to display (set after failed authentication).
+         */
+        public string? error_message { get; private set; default = null; }
+
+        /**
+         * Username value to preserve after failed login.
+         */
+        public string preserved_username { get; private set; default = ""; }
+
+        /**
+         * Whether login was successful (triggers redirect).
+         */
+        public bool login_successful { get; private set; default = false; }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <spry-context property="redirect_url"/>
+            <script spry-res="htmx.js"></script>
+            <div class="spry-login-form" sid="login-form" hx-swap="outerHTML">
+                <form sid="form" spry-action=":login" spry-target="login-form" hx-disabled-elt="find button">
+                    <div class="form-group">
+                        <label for="username">Username or Email</label>
+                        <input type="text"
+                               name="username"
+                               sid="username-input"
+                               required
+                               autocomplete="username"
+                               autofocus
+                               placeholder="Enter your username or email"/>
+                    </div>
+
+                    <div class="form-group">
+                        <label for="password">Password</label>
+                        <input type="password"
+                               name="password"
+                               sid="password-input"
+                               required
+                               autocomplete="current-password"
+                               placeholder="Enter your password"/>
+                    </div>
+
+                    <div class="form-group form-group-checkbox">
+                        <label class="checkbox-label">
+                            <input type="checkbox" name="remember_me" sid="remember-me-input"/>
+                            <span>Remember me</span>
+                        </label>
+                    </div>
+
+                    <div spry-if="this.error_message != null" class="error-message" sid="error-container">
+                        <span content-expr="this.error_message"></span>
+                    </div>
+
+                    <button type="submit" sid="submit-btn" class="login-btn">Log In</button>
+                </form>
+            </div>
+            """;
+        }}
+
+        public override async void prepare() throws Error {
+            // Preserve username in the input field after failed login
+            if (preserved_username.length > 0) {
+                this["username-input"].set_attribute("value", preserved_username);
+            }
+        }
+
+        public async override void handle_action(string action) throws Error {
+            // Normalize action name comparison
+            if (action == "login") {
+                yield handle_login_async();
+            }
+        }
+
+        // =========================================================================
+        // Login Handler
+        // =========================================================================
+
+        private async void handle_login_async() throws Error {
+            stdout.printf("LOGIN DEBUG: handle_login_async() triggered\n");
+            
+            var query = _http_context.request.query_params;
+
+            // Get form values
+            var username_raw = query.get_any_or_default("username");
+            var password_raw = query.get_any_or_default("password");
+            var remember_me_raw = query.get_any_or_default("remember_me");
+
+            var username = username_raw != null ? ((!)username_raw).strip() : "";
+            var password = password_raw ?? "";
+            var remember_me = remember_me_raw == "on";
+
+            stdout.printf("LOGIN DEBUG: Username/email received: '%s'\n", username);
+            stdout.printf("LOGIN DEBUG: Password length: %d\n", password.length);
+            stdout.printf("LOGIN DEBUG: Remember me: %s\n", remember_me ? "true" : "false");
+
+            // Preserve username for re-display on error
+            preserved_username = username;
+
+            // Validate inputs
+            if (username.length == 0) {
+                stdout.printf("LOGIN DEBUG: Validation failed - empty username\n");
+                error_message = "Please enter your username or email";
+                return;
+            }
+
+            if (password.length == 0) {
+                stdout.printf("LOGIN DEBUG: Validation failed - empty password\n");
+                error_message = "Please enter your password";
+                return;
+            }
+
+            // Attempt authentication
+            stdout.printf("LOGIN DEBUG: Calling authenticate_async()...\n");
+            var user = yield _user_service.authenticate_async(username, password);
+            stdout.printf("LOGIN DEBUG: authenticate_async() returned user: %s\n", user != null ? user.id : "null");
+
+            if (user == null) {
+                // Authentication failed - show generic error message
+                // (Don't reveal whether username or password was wrong for security)
+                stdout.printf("LOGIN DEBUG: Authentication failed - invalid credentials\n");
+                error_message = "Invalid username or password";
+                return;
+            }
+
+            stdout.printf("LOGIN DEBUG: Authentication successful for user: %s (username: %s, email: %s)\n",
+                user.id, user.username, user.email);
+
+            // Get client info for session tracking
+            var ip_address = _http_context.request.remote_address;
+            var user_agent = _http_context.request.headers.get_any_or_default("User-Agent");
+            stdout.printf("LOGIN DEBUG: Client IP: %s, User-Agent: %s\n",
+                ip_address ?? "null", user_agent ?? "null");
+
+            // Create session
+            stdout.printf("LOGIN DEBUG: Creating session...\n");
+            Session? session;
+            if (remember_me) {
+                // Create session with extended duration for "remember me"
+                // Note: Current SessionService uses configured duration;
+                // for extended sessions, we'd need to modify SessionService
+                // For now, create a regular session
+                session = yield _session_service.create_session_async(user.id, ip_address, user_agent);
+            } else {
+                session = yield _session_service.create_session_async(user.id, ip_address, user_agent);
+            }
+            stdout.printf("LOGIN DEBUG: Session creation result: %s\n", session != null ? session.id : "null");
+
+            if (session == null) {
+                stdout.printf("LOGIN DEBUG: ERROR - Failed to create session\n");
+                error_message = "Failed to create session. Please try again.";
+                return;
+            }
+
+            // Generate session token
+            stdout.printf("LOGIN DEBUG: Generating session token...\n");
+            var token = _session_service.generate_session_token(session);
+            stdout.printf("LOGIN DEBUG: Token generated (length: %d)\n", token.length);
+
+            // Set session cookie using ResponseState
+            // This accumulates the cookie header to be applied when to_result() is called
+            stdout.printf("LOGIN DEBUG: Setting session cookie via ResponseState...\n");
+            response.set_cookie("spry_session", token, 86400, "/", true, true, "Strict");
+            stdout.printf("LOGIN DEBUG: Session cookie set\n");
+
+            // Set up HTMX redirect using ResponseState
+            // This sets the HX-Redirect header for client-side redirect
+            response.redirect(redirect_url);
+            
+            login_successful = true;
+            error_message = null;
+            stdout.printf("LOGIN DEBUG: Login successful! Redirect to: %s\n", redirect_url);
+
+            // Optional: Check permissions if permission_service is available
+            // This can be used for post-login permission checks
+            if (_permission_service != null) {
+                // Subclasses can override to add permission checks
+                // e.g., require certain permissions to access specific areas
+            }
+        }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Clears the error message and resets the form state.
+         */
+        public void clear_error() {
+            error_message = null;
+        }
+
+        /**
+         * Sets a custom error message.
+         * Useful for external validation or custom error handling.
+         *
+         * @param message The error message to display
+         */
+        public void set_error(string message) {
+            error_message = message;
+        }
+    }
+}

+ 347 - 0
src/Authentication/Components/NewUserComponent.vala

@@ -0,0 +1,347 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication.Components {
+
+    /**
+     * NewUserComponent - Create form for new users.
+     *
+     * This component provides:
+     * - HTML5 `<details>` element with `<summary>` for new user creation
+     * - Form fields for username, email, password
+     * - Integrated permission editing with checkboxes
+     * - Create/Cancel action buttons
+     *
+     * HTMX Target: `new-user` (the details element itself)
+     * On successful creation, an hx-refresh header is sent to reload the page.
+     *
+     * Usage:
+     *   var component = factory.create<NewUserComponent>();
+     *   component.available_permissions = {"user-management", "user.create", ...};
+     *
+     * This component uses the inject<> pattern for dependency injection.
+     */
+    public class NewUserComponent : Component {
+
+        private Vector<string> _selected_permissions;
+        private UserService _user_service = inject<UserService>();
+        private PermissionService _permission_service = inject<PermissionService>();
+        private ComponentFactory _factory = inject<ComponentFactory>();
+        private HttpContext _http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * List of permissions that the application supports.
+         * Must be set by the application before the component is rendered.
+         * The component does NOT hardcode any permissions.
+         */
+        public Vector<string> available_permissions { get; set; default = new Vector<string>(); }
+
+        // =========================================================================
+        // State Properties (must be public for template expression access)
+        // =========================================================================
+
+        public string? error_message { get; private set; default = null; }
+        public string preserved_username { get; private set; default = ""; }
+        public string preserved_email { get; private set; default = ""; }
+
+        // =========================================================================
+        // Construction
+        // =========================================================================
+
+        construct {
+            _selected_permissions = new Vector<string>();
+        }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <details sid="new-user"
+                     id="new-user"
+                     hx-swap="outerHTML"
+                     open="">
+                <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #e7f3ff; border-radius: 4px; cursor: pointer; border: 2px dashed #007bff; list-style: none;">
+                    <span style="font-weight: 500; color: #007bff;">+ New User</span>
+                </summary>
+
+                <div style="padding: 1rem; border: 2px dashed #007bff; border-top: none; border-radius: 0 0 4px 4px; background: #f8faff;">
+                    <form sid="create-form"
+                          spry-action=":CreateUser"
+                          spry-target="new-user">
+
+                        <table style="width: 100%; border-collapse: collapse;">
+                            <tbody>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">Username *</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <input type="text"
+                                               name="username"
+                                               sid="username-input"
+                                               required
+                                               minlength="3"
+                                               pattern="[a-zA-Z0-9_]+"
+                                               placeholder="Enter username"
+                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem; box-sizing: border-box;"/>
+                                        <small style="color: #6c757d; font-size: 0.75rem;">Alphanumeric and underscores only, min 3 chars</small>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <input type="email"
+                                               name="email"
+                                               sid="email-input"
+                                               required
+                                               placeholder="Enter email address"
+                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem; box-sizing: border-box;"/>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500;">Password *</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <input type="password"
+                                               name="password"
+                                               sid="password-input"
+                                               required
+                                               minlength="8"
+                                               placeholder="Enter password"
+                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem; box-sizing: border-box;"/>
+                                        <small style="color: #6c757d; font-size: 0.75rem;">Minimum 8 characters</small>
+                                    </td>
+                                </tr>
+
+                                <!-- Permission Checkboxes (dynamically generated from available_permissions) -->
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
+                                            <spry-outlet sid="permission-checkboxes"/>
+                                        </div>
+                                    </td>
+                                </tr>
+                            </tbody>
+                        </table>
+
+                        <!-- Error Message -->
+                        <div spry-if="this.error_message != null"
+                             style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
+                            <span content-expr="this.error_message"></span>
+                        </div>
+
+                        <!-- Actions -->
+                        <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
+                            <button type="submit" style="padding: 0.5rem 1rem; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 4px;">
+                                Create User
+                            </button>
+                            <button type="button"
+                                    sid="cancel-btn"
+                                    spry-action=":CancelCreate"
+                                    spry-target="new-user"
+                                    style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
+                                Cancel
+                            </button>
+                        </div>
+                    </form>
+                </div>
+            </details>
+            """;
+        }}
+
+        public async override void prepare() throws Error {
+            // Preserve form values after failed submission
+            if (preserved_username.length > 0) {
+                this["username-input"].set_attribute("value", preserved_username);
+            }
+            if (preserved_email.length > 0) {
+                this["email-input"].set_attribute("value", preserved_email);
+            }
+
+            // Generate permission checkboxes from available_permissions
+            var checkboxes = new Series<Renderable>();
+            foreach (var perm in available_permissions) {
+                bool is_checked = has_permission(perm);
+                var checkbox = create_permission_checkbox(perm, is_checked);
+                checkboxes.add(checkbox);
+            }
+
+            if (available_permissions.length == 0) {
+                var no_perms = create_text_renderable("No permissions available");
+                checkboxes.add(no_perms);
+            }
+
+            set_outlet_children("permission-checkboxes", checkboxes);
+        }
+
+        public async override void handle_action(string action) throws Error {
+            switch (action) {
+                case "CreateUser":
+                    yield handle_create_user_async();
+                    break;
+
+                case "CancelCreate":
+                    yield handle_cancel_create_async();
+                    break;
+            }
+        }
+
+        // =========================================================================
+        // Action Handlers
+        // =========================================================================
+
+        private async void handle_create_user_async() throws Error {
+            var request = _http_context.request;
+
+            // Get form values
+            var username = get_query_value(request, "username").strip();
+            var email = get_query_value(request, "email").strip();
+            var password = get_query_value(request, "password");
+
+            // Preserve values for re-display on error
+            preserved_username = username;
+            preserved_email = email;
+
+            // Validate username
+            if (username.length < 3) {
+                error_message = "Username must be at least 3 characters";
+                return;
+            }
+
+            if (!is_valid_username(username)) {
+                error_message = "Username can only contain letters, numbers, and underscores";
+                return;
+            }
+
+            // Validate email
+            if (email.length == 0) {
+                error_message = "Email is required";
+                return;
+            }
+
+            if (!is_valid_email(email)) {
+                error_message = "Please enter a valid email address";
+                return;
+            }
+
+            // Validate password
+            if (password.length < 8) {
+                error_message = "Password must be at least 8 characters";
+                return;
+            }
+
+            // Build permissions from checkboxes
+            _selected_permissions.clear();
+            foreach (var perm in available_permissions) {
+                string field_name = get_permission_field_name(perm);
+                if (request.query_params.get_any_or_default(field_name) != null) {
+                    _selected_permissions.add(perm);
+                }
+            }
+
+            try {
+                // Create the user
+                var user = yield _user_service.create_user_async(username, email, password);
+
+                // Set permissions if any were selected
+                if (_selected_permissions.length > 0) {
+                    foreach (var perm in _selected_permissions) {
+                        yield _permission_service.set_permission_async(user, perm);
+                    }
+                }
+
+                // Set hx-refresh header to cause page reload
+                // This ensures the user list is refreshed without needing parent references
+                set_refresh_response();
+
+            } catch (UserError.DUPLICATE_USERNAME e) {
+                error_message = "Username already exists";
+            } catch (UserError.DUPLICATE_EMAIL e) {
+                error_message = "Email already registered";
+            } catch (UserError e) {
+                error_message = e.message;
+            } catch (Error e) {
+                error_message = "Error: %s".printf(e.message);
+            }
+        }
+
+        private async void handle_cancel_create_async() throws Error {
+            // Set hx-refresh header to cause page reload
+            // This closes the form and refreshes the user list
+            set_refresh_response();
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private bool has_permission(string permission) {
+            foreach (var perm in _selected_permissions) {
+                if (perm == permission) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        private string get_permission_field_name(string permission) {
+            return @"perm_$(permission.replace(".", "-"))";
+        }
+
+        private string get_query_value(Astralis.HttpRequest request, string key) {
+            string? value = request.query_params.get_any_or_default(key);
+            return value != null ? ((!)value).strip() : "";
+        }
+
+        private bool is_valid_username(string username) {
+            if (username.length == 0) return false;
+            foreach (var c in username.data) {
+                if (!((c >= 'a' && c <= 'z') ||
+                      (c >= 'A' && c <= 'Z') ||
+                      (c >= '0' && c <= '9') ||
+                      c == '_')) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        private bool is_valid_email(string email) {
+            var at_index = email.index_of("@");
+            if (at_index < 1) return false;
+            var dot_index = email.index_of(".", at_index);
+            return dot_index > at_index + 1 && dot_index < email.length - 1;
+        }
+
+        private Renderable create_permission_checkbox(string permission, bool is_checked) {
+            var doc = new MarkupDocument();
+            var escaped = GLib.Markup.escape_text(permission);
+            var field_name = get_permission_field_name(permission);
+            string checked_attr = is_checked ? "checked" : "";
+            doc.body.inner_html = @"<label style=\"display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem; cursor: pointer;\"><input type=\"checkbox\" name=\"$field_name\" value=\"$permission\" $checked_attr/>$escaped</label>";
+            return new InlineRenderable(doc);
+        }
+
+        private Renderable create_text_renderable(string text) {
+            var doc = new MarkupDocument();
+            var escaped = GLib.Markup.escape_text(text);
+            doc.body.inner_html = @"<span style=\"color: #999; font-style: italic; font-size: 0.85rem;\">$escaped</span>";
+            return new InlineRenderable(doc);
+        }
+
+        /**
+         * Sets the HX-Refresh header on the response to trigger a full page refresh.
+         * This is used instead of parent component references to update the user list.
+         */
+        private void set_refresh_response() {
+            response.set_header("HX-Refresh", "true");
+        }
+    }
+}

+ 507 - 0
src/Authentication/Components/UserDetailsComponent.vala

@@ -0,0 +1,507 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication.Components {
+
+    /**
+     * UserDetailsComponent - Display and edit component for a single user.
+     *
+     * This component provides:
+     * - HTML5 `<details>` element with `<summary>` showing key user info
+     * - Two-column table displaying all user fields when expanded
+     * - View mode with Edit/Delete buttons
+     * - Edit mode with form fields and integrated permission editing
+     * - Permission badges display
+     *
+     * The component uses an `is_editing` state to toggle between view and edit modes.
+     *
+     * HTMX Target: `user-{user_id}` (the details element itself)
+     *
+     * Usage:
+     *   var component = factory.create<UserDetailsComponent>();
+     *   component.set_user(user);
+     *   component.available_permissions = {"user-management", "user.create", ...};
+     *
+     * This component uses the inject<> pattern for dependency injection.
+     */
+    public class UserDetailsComponent : Component {
+
+        private User _user;
+        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>();
+
+        // Editing state
+        private Vector<string> _editing_permissions;
+        private string _editing_username;
+        private string _editing_email;
+        private string _editing_password;
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * List of permissions that the application supports.
+         * Must be set by the application before the component is rendered.
+         * The component does NOT hardcode any permissions.
+         */
+        public Vector<string> available_permissions { get; set; default = new Vector<string>(); }
+
+        // =========================================================================
+        // State Properties (must be public for template expression access)
+        // =========================================================================
+
+        public string user_id { get; private set; default = ""; }
+        public string username { get; private set; default = ""; }
+        public string email { get; private set; default = ""; }
+        public string created_at { get; private set; default = ""; }
+        public string[] permissions { get; private set; default = {}; }
+        public int permission_count { get; private set; default = 0; }
+        public bool is_editing { get; private set; default = false; }
+        public string? error_message { get; private set; default = null; }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Sets the user to display.
+         *
+         * @param user The user to display
+         */
+        public void set_user(User user) {
+            _user = user;
+            user_id = user.id;
+            username = user.username;
+            email = user.email;
+            created_at = user.created_at.format("%Y-%m-%d %H:%M:%S UTC");
+            permissions = user.permissions;
+            permission_count = permissions.length;
+        }
+
+        /**
+         * Gets the user being displayed.
+         *
+         * @return The user being displayed
+         */
+        public User get_user() {
+            return _user;
+        }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <details sid="user-details"
+                     id-expr="'user-' + this.user_id"
+                     hx-swap="outerHTML">
+                <!-- Summary: Always visible, shows key info -->
+                <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; cursor: pointer; list-style: none;">
+                    <span content-expr="this.username" style="font-weight: 500; min-width: 120px;"></span>
+                    <span spry-if="this.is_editing" style="color: #856404; font-style: italic;">Editing...</span>
+                    <span spry-if="!this.is_editing" content-expr="this.email" style="color: #6c757d;"></span>
+                    <span spry-if="!this.is_editing && this.permission_count > 0" style="margin-left: auto; font-size: 0.85rem; color: #495057;">
+                        <span content-expr="this.permission_count"></span> permission(s)
+                    </span>
+                    <span spry-if="!this.is_editing && this.permission_count == 0" style="margin-left: auto; font-size: 0.85rem; color: #999;">
+                        No permissions
+                    </span>
+                </summary>
+
+                <!-- VIEW MODE -->
+                <div spry-if="!this.is_editing" style="padding: 1rem; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 4px 4px;">
+                    <table style="width: 100%; border-collapse: collapse;">
+                        <tbody>
+                            <tr>
+                                <td style="padding: 0.5rem 0; font-weight: 500; width: 150px; vertical-align: top;">User ID</td>
+                                <td style="padding: 0.5rem 0;"><code content-expr="this.user_id" style="font-size: 0.85rem; background: #f5f5f5; padding: 0.125rem 0.25rem; border-radius: 3px;"></code></td>
+                            </tr>
+                            <tr>
+                                <td style="padding: 0.5rem 0; font-weight: 500;">Username</td>
+                                <td style="padding: 0.5rem 0;"><span content-expr="this.username"></span></td>
+                            </tr>
+                            <tr>
+                                <td style="padding: 0.5rem 0; font-weight: 500;">Email</td>
+                                <td style="padding: 0.5rem 0;"><span content-expr="this.email"></span></td>
+                            </tr>
+                            <tr>
+                                <td style="padding: 0.5rem 0; font-weight: 500;">Created</td>
+                                <td style="padding: 0.5rem 0;"><span content-expr="this.created_at"></span></td>
+                            </tr>
+                            <tr>
+                                <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
+                                <td style="padding: 0.5rem 0;">
+                                    <div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
+                                        <spry-outlet sid="permission-badges"/>
+                                    </div>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+
+                    <!-- View Mode Actions -->
+                    <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
+                        <button sid="edit-btn"
+                                spry-action=":StartEdit"
+                                spry-target="user-details"
+                                style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem;">
+                            Edit
+                        </button>
+                        <button sid="delete-btn"
+                                spry-action=":DeleteUser"
+                                hx-confirm="Are you sure you want to delete this user?"
+                                style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem; background: #dc3545; color: white; border: none; border-radius: 4px;">
+                            Delete
+                        </button>
+                    </div>
+                </div>
+
+                <!-- EDIT MODE -->
+                <div spry-if="this.is_editing" style="padding: 1rem; border: 2px solid #ffc107; border-top: none; border-radius: 0 0 4px 4px; background: #fffdf5;">
+                    <form sid="edit-form"
+                          spry-action=":SaveEdit"
+                          spry-target="user-details">
+                        
+                        <table style="width: 100%; border-collapse: collapse;">
+                            <tbody>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">User ID</td>
+                                    <td style="padding: 0.5rem 0;"><code content-expr="this.user_id" style="font-size: 0.85rem; background: #f5f5f5; padding: 0.125rem 0.25rem; border-radius: 3px;"></code></td>
+                                </tr>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500;">Username *</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <input type="text"
+                                               name="username"
+                                               sid="username-input"
+                                               required
+                                               minlength="3"
+                                               pattern="[a-zA-Z0-9_]+"
+                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
+                                               value-expr="this.username"/>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <input type="email"
+                                               name="email"
+                                               sid="email-input"
+                                               required
+                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
+                                               value-expr="this.email"/>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500;">New Password</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <input type="password"
+                                               name="new_password"
+                                               sid="password-input"
+                                               minlength="8"
+                                               placeholder="Leave blank to keep current"
+                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"/>
+                                        <small style="color: #6c757d;">Minimum 8 characters if changing</small>
+                                    </td>
+                                </tr>
+                                
+                                <!-- Permission Checkboxes (dynamically generated from available_permissions) -->
+                                <tr>
+                                    <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
+                                    <td style="padding: 0.5rem 0;">
+                                        <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
+                                            <spry-outlet sid="permission-checkboxes"/>
+                                        </div>
+                                    </td>
+                                </tr>
+                            </tbody>
+                        </table>
+                        
+                        <!-- Error Message -->
+                        <div spry-if="this.error_message != null"
+                             style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
+                            <span content-expr="this.error_message"></span>
+                        </div>
+                        
+                        <!-- Edit Actions -->
+                        <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
+                            <button type="submit" style="padding: 0.5rem 1rem; cursor: pointer;">Save Changes</button>
+                            <button type="button"
+                                    sid="cancel-btn"
+                                    spry-action=":CancelEdit"
+                                    spry-target="user-details"
+                                    style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
+                                Cancel
+                            </button>
+                        </div>
+                    </form>
+                </div>
+            </details>
+            """;
+        }}
+
+        public async override void prepare() throws Error {
+            if (_user == null) {
+                return;
+            }
+
+            if (is_editing) {
+                yield prepare_edit_mode();
+            } else {
+                yield prepare_view_mode();
+            }
+        }
+
+        private async void prepare_view_mode() throws Error {
+            // Create permission badges
+            var badges = new Series<Renderable>();
+            foreach (var permission in permissions) {
+                var badge = create_permission_badge(permission);
+                badges.add(badge);
+            }
+
+            // Show "No permissions" message if none
+            if (permissions.length == 0) {
+                var no_perms = create_text_renderable("No permissions assigned");
+                badges.add(no_perms);
+            }
+
+            set_outlet_children("permission-badges", badges);
+        }
+
+        private async void prepare_edit_mode() throws Error {
+            // Initialize editing state if not already done
+            if (_editing_permissions == null) {
+                _editing_permissions = new Vector<string>();
+                foreach (var perm in permissions) {
+                    _editing_permissions.add(perm);
+                }
+                _editing_username = username;
+                _editing_email = email;
+                _editing_password = "";
+            }
+
+            // Generate permission checkboxes from available_permissions
+            var checkboxes = new Series<Renderable>();
+            foreach (var perm in available_permissions) {
+                bool is_checked = has_editing_permission(perm);
+                var checkbox = create_permission_checkbox(perm, is_checked);
+                checkboxes.add(checkbox);
+            }
+
+            if (available_permissions.length == 0) {
+                var no_perms = create_text_renderable("No permissions available");
+                checkboxes.add(no_perms);
+            }
+
+            set_outlet_children("permission-checkboxes", checkboxes);
+        }
+
+        public async override void handle_action(string action) throws Error {
+            switch (action) {
+                case "StartEdit":
+                    yield handle_start_edit_async();
+                    break;
+
+                case "SaveEdit":
+                    yield handle_save_edit_async();
+                    break;
+
+                case "CancelEdit":
+                    handle_cancel_edit();
+                    break;
+
+                case "DeleteUser":
+                    yield handle_delete_user_async();
+                    break;
+            }
+        }
+
+        // =========================================================================
+        // Action Handlers
+        // =========================================================================
+
+        private async void handle_start_edit_async() throws Error {
+            is_editing = true;
+            error_message = null;
+            _editing_permissions = null; // Reset to trigger re-initialization
+            yield prepare();
+        }
+
+        private async void handle_save_edit_async() throws Error {
+            var query = _http_context.request.query_params;
+
+            string new_username = query.get_any_or_default("username") ?? _editing_username;
+            string new_email = query.get_any_or_default("email") ?? _editing_email;
+            string new_password = query.get_any_or_default("new_password") ?? "";
+
+            // Validate
+            if (new_username.length < 3) {
+                error_message = "Username must be at least 3 characters";
+                yield prepare();
+                return;
+            }
+
+            if (!new_email.contains("@")) {
+                error_message = "Invalid email address";
+                yield prepare();
+                return;
+            }
+
+            if (new_password.length > 0 && new_password.length < 8) {
+                error_message = "Password must be at least 8 characters";
+                yield prepare();
+                return;
+            }
+
+            // Collect permissions from checkboxes
+            _editing_permissions = new Vector<string>();
+            foreach (var perm in available_permissions) {
+                string field_name = get_permission_field_name(perm);
+                var field_value = query.get_any_or_default(field_name);
+                if (field_value != null) {
+                    _editing_permissions.add(perm);
+                }
+            }
+
+            try {
+                // Update user object
+                _user.set_username(new_username);
+                _user.email = new_email;
+                
+                // Update password if provided
+                if (new_password.length > 0) {
+                    yield _user_service.set_password_async(_user, new_password);
+                }
+                
+                // Persist user changes
+                yield _user_service.update_user_async(_user);
+
+                // Update permissions - clear all and re-add
+                yield _permission_service.clear_all_permissions_async(_user);
+                foreach (var perm in _editing_permissions) {
+                    yield _permission_service.set_permission_async(_user, perm);
+                }
+
+                // Update local state
+                set_user(_user);
+                is_editing = false;
+                error_message = null;
+                _editing_permissions = null;
+
+                yield prepare();
+            } catch (Error e) {
+                error_message = @"Failed to save: $(e.message)";
+                yield prepare();
+            }
+        }
+
+        private void handle_cancel_edit() {
+            is_editing = false;
+            error_message = null;
+            _editing_permissions = null;
+            _editing_password = "";
+        }
+
+        private async void handle_delete_user_async() throws Error {
+            // Prevent self-deletion
+            var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
+            if (auth_result.is_authenticated && auth_result.user != null) {
+                if (auth_result.user.id == user_id) {
+                    // Can't delete self - show error
+                    error_message = "Cannot delete your own account";
+                    yield prepare();
+                    return;
+                }
+            }
+
+            // Delete the user
+            try {
+                yield _user_service.delete_user_async(user_id);
+
+                // Set hx-refresh header to cause page reload
+                // This ensures the user list is refreshed without needing parent references
+                set_refresh_response();
+
+            } catch (Error e) {
+                error_message = e.message;
+                yield prepare();
+            }
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private bool has_editing_permission(string permission) {
+            if (_editing_permissions == null) {
+                return false;
+            }
+            foreach (var perm in _editing_permissions) {
+                if (perm == permission) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        private string get_permission_field_name(string permission) {
+            return @"perm_$(permission.replace(".", "-"))";
+        }
+
+        private Renderable create_permission_badge(string permission) {
+            var doc = new MarkupDocument();
+            var escaped = GLib.Markup.escape_text(permission);
+            doc.body.inner_html = @"<span style=\"background: #e9ecef; color: #495057; padding: 0.125rem 0.5rem; border-radius: 12px; font-size: 0.8rem; white-space: nowrap;\">$escaped</span>";
+            return new InlineRenderable(doc);
+        }
+
+        private Renderable create_text_renderable(string text) {
+            var doc = new MarkupDocument();
+            var escaped = GLib.Markup.escape_text(text);
+            doc.body.inner_html = @"<span style=\"color: #999; font-style: italic; font-size: 0.85rem;\">$escaped</span>";
+            return new InlineRenderable(doc);
+        }
+
+        private Renderable create_permission_checkbox(string permission, bool is_checked) {
+            var doc = new MarkupDocument();
+            var escaped = GLib.Markup.escape_text(permission);
+            var field_name = get_permission_field_name(permission);
+            string checked_attr = is_checked ? "checked" : "";
+            doc.body.inner_html = @"<label style=\"display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem; cursor: pointer;\"><input type=\"checkbox\" name=\"$field_name\" value=\"$permission\" $checked_attr/>$escaped</label>";
+            return new InlineRenderable(doc);
+        }
+
+        /**
+         * Sets the HX-Refresh header on the response to trigger a full page refresh.
+         * This is used instead of parent component references to update the user list.
+         */
+        private void set_refresh_response() {
+            response.set_header("HX-Refresh", "true");
+        }
+    }
+
+    /**
+     * Helper class for inline HTML rendering.
+     * Used for dynamically generated permission badges and checkboxes.
+     */
+    internal class InlineRenderable : GLib.Object, Renderable {
+        private MarkupDocument _doc;
+
+        public InlineRenderable(MarkupDocument doc) {
+            _doc = doc;
+        }
+
+        public async MarkupDocument to_document() throws Error {
+            return _doc;
+        }
+    }
+}

+ 223 - 0
src/Authentication/Components/UserManagementComponent.vala

@@ -0,0 +1,223 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication.Components {
+
+    /**
+     * UserManagementComponent - Container component for user management.
+     *
+     * This component provides:
+     * - Header with "Create User" button
+     * - User list with expandable details for each user
+     * - Success/error message display
+     * - New user form (shown via HTMX)
+     *
+     * Unlike UserManagementPage (which was a PageComponent), this is a regular
+     * Component that can be placed anywhere in an application's layout.
+     *
+     * HTMX Target: `user-management`
+     *
+     * Usage:
+     *   // In your PageComponent:
+     *   var component = factory.create<UserManagementComponent>();
+     *   component.available_permissions = {"user-management", "user.create", ...};
+     *   add_outlet_child("user-management-outlet", component);
+     *
+     * This component uses the inject<> pattern for dependency injection.
+     */
+    public class UserManagementComponent : Component {
+
+        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>();
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * List of permissions that the application supports.
+         * Must be set by the application before the component is rendered.
+         * This is passed to child components (NewUserComponent, UserDetailsComponent).
+         */
+        public Vector<string> available_permissions { get; set; default = new Vector<string>(); }
+
+        // =========================================================================
+        // State Properties (must be public for template expression access)
+        // =========================================================================
+
+        public string? success_message { get; private set; default = null; }
+        public string? error_message { get; private set; default = null; }
+        public bool access_denied { get; private set; default = false; }
+        public bool show_create_form { get; private set; default = false; }
+        public Vector<User> users { get; private set; }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <div sid="user-management" id="user-management" hx-swap="outerHTML">
+                <script spry-res="htmx.js"></script>
+
+                <!-- Header with Create Button -->
+                <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
+                    <h3 style="margin: 0;">Users</h3>
+                    <button sid="create-btn"
+                            spry-if="!this.access_denied"
+                            spry-action=":ShowCreateUser"
+                            spry-target="user-management"
+                            style="padding: 0.5rem 1rem; cursor: pointer;">
+                        + Create User
+                    </button>
+                </div>
+
+                <!-- Access Denied Message -->
+                <div spry-if="this.access_denied"
+                     style="padding: 1rem; background: #f8d7da; color: #721c24; border-radius: 4px; text-align: center;">
+                    <h4 style="margin: 0 0 0.5rem 0; color: #dc3545;">Access Denied</h4>
+                    <p style="margin: 0;">You do not have permission to access this page.</p>
+                </div>
+
+                <!-- Success Message -->
+                <div spry-if="this.success_message != null && this.success_message.length > 0"
+                     style="padding: 0.75rem; margin-bottom: 1rem; background: #d4edda; color: #155724; border-radius: 4px;">
+                    <span content-expr="this.success_message"></span>
+                </div>
+
+                <!-- Error Message -->
+                <div spry-if="this.error_message != null && this.error_message.length > 0"
+                     style="padding: 0.75rem; margin-bottom: 1rem; background: #f8d7da; color: #721c24; border-radius: 4px;">
+                    <span content-expr="this.error_message"></span>
+                </div>
+
+                <!-- Main Content (hidden if access denied) -->
+                <div spry-if="!this.access_denied" sid="main-content">
+                    <!-- New User Form (conditionally visible) -->
+                    <div spry-if="this.show_create_form" sid="new-user-container" style="margin-bottom: 1rem;">
+                        <spry-outlet sid="new-user-outlet"/>
+                    </div>
+
+                    <!-- User List -->
+                    <div sid="user-list" style="display: flex; flex-direction: column; gap: 0.5rem;">
+                        <spry-outlet sid="users"/>
+                    </div>
+                </div>
+            </div>
+            """;
+        }}
+
+        public async override void prepare() throws Error {
+            // Initialize users vector
+            users = new Vector<User>();
+
+            // Check permission
+            var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
+
+            if (!auth_result.is_authenticated || auth_result.user == null) {
+                access_denied = true;
+                return;
+            }
+
+            var has_permission = yield _permission_service.has_permission_by_id_async(
+                auth_result.user.id,
+                PermissionService.USER_MANAGEMENT
+            );
+
+            if (!has_permission) {
+                access_denied = true;
+                return;
+            }
+
+            // Load users
+            yield load_users_async();
+
+            // Create user detail components
+            var items = new Series<Renderable>();
+            foreach (var user in users) {
+                var item = _factory.create<UserDetailsComponent>();
+                item.set_user(user);
+                item.available_permissions = available_permissions;
+                items.add(item);
+                add_globals_from(item);
+            }
+            set_outlet_children("users", items);
+
+            // Create new user component if form should be shown
+            if (show_create_form) {
+                var new_user_comp = _factory.create<NewUserComponent>();
+                new_user_comp.available_permissions = available_permissions;
+                set_outlet_child("new-user-outlet", new_user_comp);
+                add_globals_from(new_user_comp);
+            }
+        }
+
+        public async override void handle_action(string action) throws Error {
+            // Check permission for all actions
+            if (access_denied) {
+                return;
+            }
+
+            switch (action) {
+                case "ShowCreateUser":
+                    show_create_form = true;
+                    success_message = null;
+                    error_message = null;
+                    break;
+
+                case "CancelCreate":
+                    show_create_form = false;
+                    success_message = null;
+                    error_message = null;
+                    break;
+
+                case "ClearMessages":
+                    success_message = null;
+                    error_message = null;
+                    break;
+            }
+        }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Sets a success message to display.
+         */
+        public void set_success(string message) {
+            success_message = message;
+            error_message = null;
+        }
+
+        /**
+         * Sets an error message to display.
+         */
+        public void set_error(string message) {
+            error_message = message;
+            success_message = null;
+        }
+
+        /**
+         * Clears all messages.
+         */
+        public void clear_messages() {
+            success_message = null;
+            error_message = null;
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private async void load_users_async() throws Error {
+            users = yield _user_service.list_users_async(0, 100);
+        }
+    }
+}

+ 93 - 0
src/Authentication/CreateAuthTables.vala

@@ -0,0 +1,93 @@
+using InvercargillSql;
+
+namespace Spry.Authentication {
+
+    /**
+     * Creates the authentication database schema.
+     * Run once during application initialization.
+     */
+    public class CreateAuthTables : Object {
+
+        private Connection _connection;
+
+        // =========================================================================
+        // Constructor
+        // =========================================================================
+
+        public CreateAuthTables(Connection connection) {
+            _connection = connection;
+        }
+
+        // =========================================================================
+        // Migration
+        // =========================================================================
+
+        /**
+         * Creates all authentication tables if they don't exist.
+         */
+        public async void migrate() throws Error {
+            // Users table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS users (
+                    id TEXT PRIMARY KEY,
+                    username TEXT NOT NULL UNIQUE,
+                    email TEXT NOT NULL UNIQUE,
+                    password_hash TEXT NOT NULL,
+                    created_at TEXT NOT NULL,
+                    updated_at TEXT NOT NULL
+                )
+            """).execute_non_query_async();
+
+            // User permissions table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS user_permissions (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    user_id TEXT NOT NULL,
+                    permission TEXT NOT NULL,
+                    UNIQUE(user_id, permission),
+                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+                )
+            """).execute_non_query_async();
+
+            // User app data table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS user_app_data (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    user_id TEXT NOT NULL,
+                    key TEXT NOT NULL,
+                    value TEXT,
+                    UNIQUE(user_id, key),
+                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+                )
+            """).execute_non_query_async();
+
+            // Sessions table
+            yield _connection.create_command("""
+                CREATE TABLE IF NOT EXISTS sessions (
+                    id TEXT PRIMARY KEY,
+                    user_id TEXT NOT NULL,
+                    created_at TEXT NOT NULL,
+                    expires_at TEXT NOT NULL,
+                    last_accessed_at TEXT NOT NULL,
+                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+                )
+            """).execute_non_query_async();
+
+            // Create indexes
+            yield create_index("idx_users_username", "users(username)");
+            yield create_index("idx_users_email", "users(email)");
+            yield create_index("idx_user_permissions_user_id", "user_permissions(user_id)");
+            yield create_index("idx_user_app_data_user_id", "user_app_data(user_id)");
+            yield create_index("idx_sessions_user_id", "sessions(user_id)");
+            yield create_index("idx_sessions_expires_at", "sessions(expires_at)");
+        }
+
+        /**
+         * Creates an index if it doesn't exist.
+         */
+        private async void create_index(string name, string definition) throws Error {
+            var sql = "CREATE INDEX IF NOT EXISTS %s ON %s".printf(name, definition);
+            yield _connection.create_command(sql).execute_non_query_async();
+        }
+    }
+}

+ 258 - 0
src/Authentication/PermissionService.vala

@@ -0,0 +1,258 @@
+using Invercargill.DataStructures;
+using Inversion;
+
+namespace Spry.Authentication {
+
+    /**
+     * PermissionService handles granular permissions keyed by string, with wildcard support.
+     *
+     * Features:
+     * - Check if user has exact or wildcard permission
+     * - Set and clear permissions on users
+     * - Support for "admin" super-user permission
+     * - Wildcard matching delegated to Authorisation.PermissionMatcher
+     * 
+     * This service uses the inject<> pattern for dependency injection.
+     * All methods that need to load user data are async.
+     */
+    public class PermissionService : GLib.Object {
+
+        // =========================================================================
+        // Permission Constants
+        // =========================================================================
+
+        /** Permission for general user management access */
+        public const string USER_MANAGEMENT = "user-management";
+
+        /** Permission to create new users */
+        public const string USER_CREATE = "user-create";
+
+        /** Permission to read/view user details */
+        public const string USER_READ = "user-read";
+
+        /** Permission to update existing users */
+        public const string USER_UPDATE = "user-update";
+
+        /** Permission to delete users */
+        public const string USER_DELETE = "user-delete";
+
+        /** Super-user permission that grants all other permissions */
+        public const string ADMIN = "admin";
+
+        // =========================================================================
+        // Dependencies
+        // =========================================================================
+
+        private UserService _user_service = inject<UserService>();
+
+        /**
+         * Creates a new PermissionService instance.
+         * Dependencies are injected via inject<>() pattern.
+         */
+        public PermissionService() {
+            // Dependencies injected via field initializers
+        }
+
+        // =========================================================================
+        // Permission Checking
+        // =========================================================================
+
+        /**
+         * Checks if a user has a specific permission.
+         *
+         * This method:
+         * - Returns true if user has "admin" permission (super-user)
+         * - Checks for exact permission match
+         * - Uses Authorisation.PermissionMatcher for wildcard matching
+         *
+         * @param user The user to check
+         * @param permission The permission to check for
+         * @return true if the user has the permission, false otherwise
+         */
+        public bool has_permission(User user, string permission) {
+            // Get the user's permissions as an array
+            var user_permissions = user.permissions;
+
+            // Use PermissionMatcher.any_matches for efficient checking
+            return Authorisation.PermissionMatcher.any_matches(user_permissions, permission);
+        }
+
+        /**
+         * Checks if a user (by ID) has a specific permission.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to check for
+         * @return true if the user has the permission, false otherwise
+         * @throws Error on storage failure
+         */
+        public async bool has_permission_by_id_async(string user_id, string permission) throws Error {
+            var user = yield _user_service.get_user_async(user_id);
+            if (user == null) {
+                return false;
+            }
+            return has_permission(user, permission);
+        }
+
+        // =========================================================================
+        // Permission Setting
+        // =========================================================================
+
+        /**
+         * Sets (adds) a permission for a user.
+         *
+         * This method:
+         * - Adds the permission if not already present
+         * - Persists changes via UserService.update_user_async()
+         *
+         * @param user The user to update
+         * @param permission The permission to add
+         * @throws Error on failure
+         */
+        public async void set_permission_async(User user, string permission) throws Error {
+            // Check if permission already exists
+            if (user.has_permission(permission)) {
+                // Already has this permission
+                return;
+            }
+
+            // Add the permission
+            user.add_permission(permission);
+
+            // Persist changes
+            yield _user_service.update_user_async(user);
+        }
+
+        /**
+         * Sets (adds) a permission for a user (by ID).
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to add
+         * @throws Error on failure
+         */
+        public async void set_permission_by_id_async(string user_id, string permission) throws Error {
+            var user = yield _user_service.get_user_async(user_id);
+            if (user == null) {
+                throw new UserError.USER_NOT_FOUND("User not found");
+            }
+
+            yield set_permission_async(user, permission);
+        }
+
+        // =========================================================================
+        // Permission Clearing
+        // =========================================================================
+
+        /**
+         * Clears (removes) a permission from a user.
+         *
+         * This method:
+         * - Removes the permission if present
+         * - Persists changes via UserService.update_user_async()
+         *
+         * @param user The user to update
+         * @param permission The permission to remove
+         * @throws Error on failure
+         */
+        public async void clear_permission_async(User user, string permission) throws Error {
+            // Remove the permission
+            user.remove_permission(permission);
+
+            // Persist changes
+            yield _user_service.update_user_async(user);
+        }
+
+        /**
+         * Clears (removes) a permission from a user (by ID).
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to remove
+         * @throws Error on failure
+         */
+        public async void clear_permission_by_id_async(string user_id, string permission) throws Error {
+            var user = yield _user_service.get_user_async(user_id);
+            if (user == null) {
+                throw new UserError.USER_NOT_FOUND("User not found");
+            }
+
+            yield clear_permission_async(user, permission);
+        }
+
+        /**
+         * Clears all permissions from a user.
+         *
+         * @param user The user to update
+         * @throws Error on failure
+         */
+        public async void clear_all_permissions_async(User user) throws Error {
+            // Clear all permissions
+            user.clear_permissions();
+
+            // Persist changes
+            yield _user_service.update_user_async(user);
+        }
+
+        // =========================================================================
+        // Permission Retrieval
+        // =========================================================================
+
+        /**
+         * Gets all permissions for a user.
+         *
+         * @param user The user to get permissions for
+         * @return A Vector of permission strings
+         */
+        public Vector<string> get_permissions(User user) {
+            // Return a copy of the permissions vector
+            var result = new Vector<string>();
+            foreach (var perm in user.get_permissions_vector()) {
+                result.add(perm);
+            }
+            return result;
+        }
+
+        /**
+         * Gets all permissions for a user (by ID).
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of permission strings
+         * @throws Error on storage failure
+         */
+        public async Vector<string> get_permissions_by_id_async(string user_id) throws Error {
+            var user = yield _user_service.get_user_async(user_id);
+            if (user == null) {
+                return new Vector<string>();
+            }
+            return get_permissions(user);
+        }
+
+        // =========================================================================
+        // Wildcard Matching (delegated to PermissionMatcher)
+        // =========================================================================
+
+        /**
+         * Checks if a permission pattern matches a specific permission.
+         *
+         * This method delegates to Authorisation.PermissionMatcher.matches().
+         *
+         * Wildcard matching rules:
+         * - "*" matches everything
+         * - "prefix-*" matches any permission starting with "prefix-"
+         * - Without wildcard, requires exact match
+         * - "admin" matches everything (super-user)
+         *
+         * Examples:
+         * - permission_matches("*", "anything") → true
+         * - permission_matches("user-*", "user-create") → true
+         * - permission_matches("user-*", "user-delete") → true
+         * - permission_matches("user-*", "admin") → false
+         * - permission_matches("user-create", "user-create") → true
+         *
+         * @param pattern The pattern to match against (may contain wildcard)
+         * @param permission The permission to check
+         * @return true if the pattern matches the permission
+         */
+        public bool permission_matches(string pattern, string permission) {
+            return Authorisation.PermissionMatcher.matches(pattern, permission);
+        }
+    }
+}

+ 98 - 0
src/Authentication/Session.vala

@@ -0,0 +1,98 @@
+using Invercargill.DataStructures;
+using Json;
+
+namespace Spry.Authentication {
+
+    public class Session : GLib.Object {
+        // Identity
+        public string id { get; set; }
+        public string user_id { get; set; }
+
+        // Timing
+        public DateTime created_at { get; set; }
+        public DateTime expires_at { get; set; }
+
+        // Optional tracking
+        public string? ip_address { get; set; }
+        public string? user_agent { get; set; }
+
+        public Session() {
+            id = "";
+            user_id = "";
+            created_at = new DateTime.now_utc();
+            expires_at = new DateTime.now_utc();
+        }
+
+        public bool is_expired() {
+            return expires_at.compare(new DateTime.now_utc()) <= 0;
+        }
+
+        public static Session from_json(Json.Object obj) {
+            var session = new Session();
+
+            // Required string fields - use has_member and null coalescing for safety
+            session.id = obj.has_member("id") ? (obj.get_string_member("id") ?? "") : "";
+            session.user_id = obj.has_member("user_id") ? (obj.get_string_member("user_id") ?? "") : "";
+
+            // created_at - check member exists and value is not null/empty
+            if (obj.has_member("created_at")) {
+                var created_str = obj.get_string_member("created_at");
+                if (created_str != null && created_str.length > 0) {
+                    session.created_at = new DateTime.from_iso8601(created_str, new TimeZone.utc());
+                }
+            }
+
+            // expires_at - check member exists and value is not null/empty
+            if (obj.has_member("expires_at")) {
+                var expires_str = obj.get_string_member("expires_at");
+                if (expires_str != null && expires_str.length > 0) {
+                    session.expires_at = new DateTime.from_iso8601(expires_str, new TimeZone.utc());
+                }
+            }
+
+            // ip_address (optional) - check member exists and is not null
+            if (obj.has_member("ip_address")) {
+                var member = obj.get_member("ip_address");
+                if (member != null && member.get_node_type() == Json.NodeType.VALUE) {
+                    session.ip_address = obj.get_string_member("ip_address");
+                }
+            }
+
+            // user_agent (optional) - check member exists and is not null
+            if (obj.has_member("user_agent")) {
+                var member = obj.get_member("user_agent");
+                if (member != null && member.get_node_type() == Json.NodeType.VALUE) {
+                    session.user_agent = obj.get_string_member("user_agent");
+                }
+            }
+
+            return session;
+        }
+
+        public Json.Object to_json() {
+            var obj = new Json.Object();
+
+            // Use null coalescing to ensure we never pass null to set_string_member
+            obj.set_string_member("id", id ?? "");
+            obj.set_string_member("user_id", user_id ?? "");
+            obj.set_string_member("created_at", created_at != null ? created_at.format_iso8601() : new DateTime.now_utc().format_iso8601());
+            obj.set_string_member("expires_at", expires_at != null ? expires_at.format_iso8601() : new DateTime.now_utc().format_iso8601());
+
+            // ip_address (optional)
+            if (ip_address != null) {
+                obj.set_string_member("ip_address", (!)ip_address);
+            } else {
+                obj.set_null_member("ip_address");
+            }
+
+            // user_agent (optional)
+            if (user_agent != null) {
+                obj.set_string_member("user_agent", (!)user_agent);
+            } else {
+                obj.set_null_member("user_agent");
+            }
+
+            return obj;
+        }
+    }
+}

+ 83 - 0
src/Authentication/SessionRepository.vala

@@ -0,0 +1,83 @@
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication {
+
+    /**
+     * Repository interface for Session persistence operations.
+     * Abstracts the storage mechanism from the service layer.
+     */
+    public interface SessionRepository : Object {
+
+        // =========================================================================
+        // Retrieval Operations
+        // =========================================================================
+
+        /**
+         * Gets a session by its unique ID.
+         *
+         * @param id The session's unique identifier
+         * @return The Session, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async Session? get_by_id(string id) throws Error;
+
+        /**
+         * Gets all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of sessions
+         * @throws Error on storage failure
+         */
+        public abstract async Vector<Session> get_by_user_id(string user_id) throws Error;
+
+        // =========================================================================
+        // Mutation Operations
+        // =========================================================================
+
+        /**
+         * Creates a new session.
+         *
+         * @param id The session's unique identifier (pre-generated)
+         * @param user_id The user's unique identifier
+         * @param expires_at When the session expires
+         * @return The created Session
+         * @throws Error on storage failure
+         */
+        public abstract async Session create(string id, string user_id, DateTime expires_at) throws Error;
+
+        /**
+         * Updates an existing session.
+         *
+         * @param session The session to update
+         * @throws Error on storage failure
+         */
+        public abstract async void update(Session session) throws Error;
+
+        /**
+         * Deletes a session by its unique ID.
+         *
+         * @param id The session's unique identifier
+         * @throws Error on storage failure
+         */
+        public abstract async void delete(string id) throws Error;
+
+        /**
+         * Deletes all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @throws Error on storage failure
+         */
+        public abstract async void delete_by_user_id(string user_id) throws Error;
+
+        // =========================================================================
+        // Cleanup Operations
+        // =========================================================================
+
+        /**
+         * Removes all expired sessions from storage.
+         *
+         * @throws Error on storage failure
+         */
+        public abstract async void delete_expired() throws Error;
+    }
+}

+ 578 - 0
src/Authentication/SessionService.vala

@@ -0,0 +1,578 @@
+using Inversion;
+using InvercargillJson;
+using Invercargill.DataStructures;
+using Json;
+using Astralis;
+
+namespace Spry.Authentication {
+
+    /**
+     * Error domain for session-related operations.
+     */
+    public errordomain SessionError {
+        SESSION_NOT_FOUND,
+        SESSION_EXPIRED,
+        INVALID_SESSION_TOKEN,
+        COOKIE_NOT_FOUND,
+        STORAGE_ERROR
+    }
+
+    /**
+     * Result of session token validation containing session and user info.
+     */
+    public class SessionValidationResult : GLib.Object {
+        /**
+         * Whether the session token was successfully validated.
+         */
+        public bool is_valid { get; set; }
+
+        /**
+         * The session object if validation was successful.
+         */
+        public Session? session { get; set; }
+
+        /**
+         * The user object if validation was successful.
+         */
+        public User? user { get; set; }
+
+        /**
+         * Error message describing why validation failed.
+         */
+        public string? error_message { get; set; }
+
+        /**
+         * Creates a successful validation result.
+         */
+        public SessionValidationResult.success(Session session, User? user = null) {
+            GLib.Object(
+                is_valid: true,
+                session: session,
+                user: user,
+                error_message: null
+            );
+        }
+
+        /**
+         * Creates a failed validation result.
+         */
+        public SessionValidationResult.failure(string error_message) {
+            GLib.Object(
+                is_valid: false,
+                session: null,
+                user: null,
+                error_message: error_message
+            );
+        }
+    }
+
+    /**
+     * Result of authenticating a request.
+     */
+    public class AuthResult : GLib.Object {
+        /**
+         * Whether the request was successfully authenticated.
+         */
+        public bool is_authenticated { get; set; }
+
+        /**
+         * The authenticated user, or null if not authenticated.
+         */
+        public User? user { get; set; }
+
+        /**
+         * The session associated with the request, or null if not authenticated.
+         */
+        public Session? session { get; set; }
+
+        /**
+         * Error message describing why authentication failed.
+         */
+        public string? error_message { get; set; }
+
+        /**
+         * Creates a successful authentication result.
+         */
+        public AuthResult.success(User user, Session session) {
+            GLib.Object(
+                is_authenticated: true,
+                user: user,
+                session: session,
+                error_message: null
+            );
+        }
+
+        /**
+         * Creates a failed authentication result.
+         */
+        public AuthResult.failure(string error_message) {
+            GLib.Object(
+                is_authenticated: false,
+                user: null,
+                session: null,
+                error_message: error_message
+            );
+        }
+    }
+
+    /**
+     * SessionService handles session creation, validation, cookie management, and cleanup.
+     *
+     * Cookie Configuration:
+     * - Cookie name: spry_session (configurable)
+     * - HttpOnly: true
+     * - Secure: true (configurable for development)
+     * - SameSite: Strict
+     * - Path: /
+     * 
+     * Integration with Authorisation:
+     * - Uses AuthorisationTokenService for token generation/validation
+     * - Sessions can be converted to AuthorisationTokens for the Authorisation system
+     * 
+     * This service uses the inject<> pattern for dependency injection.
+     * All methods are async to work with the repository async API.
+     */
+    public class SessionService : GLib.Object {
+
+        private SessionRepository _repository = inject<SessionRepository>();
+        private CryptographyProvider _crypto = inject<CryptographyProvider>();
+        private Authorisation.AuthorisationTokenService? _token_service = inject<Authorisation.AuthorisationTokenService>();
+
+        // Cookie configuration
+        private string _cookie_name = "spry_session";
+        private bool _cookie_secure = true;
+        private TimeSpan _session_duration = TimeSpan.HOUR * 24;
+
+        /**
+         * The name of the session cookie.
+         */
+        public string cookie_name { 
+            get { return _cookie_name; } 
+            set { _cookie_name = value; }
+        }
+
+        /**
+         * Whether the session cookie should be Secure.
+         */
+        public bool cookie_secure { 
+            get { return _cookie_secure; } 
+            set { _cookie_secure = value; }
+        }
+
+        /**
+         * The session expiry duration.
+         */
+        public TimeSpan session_duration { 
+            get { return _session_duration; } 
+            set { _session_duration = value; }
+        }
+
+        /**
+         * Creates a new SessionService instance with default configuration.
+         * Use properties to customize cookie name, security, and duration.
+         */
+        public SessionService() {
+            // Default configuration - use properties to customize
+        }
+
+        // =========================================================================
+        // Session Creation
+        // =========================================================================
+
+        /**
+         * Creates a new session for a user.
+         *
+         * This method:
+         * - Generates a UUID for the session
+         * - Sets expiry based on configured duration
+         * - Stores optional IP and user agent
+         *
+         * @param user_id The user's unique identifier
+         * @param ip_address Optional IP address for tracking
+         * @param user_agent Optional user agent for tracking
+         * @return The created Session
+         * @throws Error on failure
+         */
+        public async Session create_session_async(string user_id, string? ip_address = null, string? user_agent = null) throws Error {
+            stdout.printf("SESSION DEBUG: create_session_async() called for user: %s\n", user_id);
+            stdout.printf("SESSION DEBUG: IP address: %s, User-Agent: %s\n",
+                ip_address ?? "null", user_agent ?? "null");
+            
+            // Generate UUID for session
+            var session_id = generate_uuid();
+            stdout.printf("SESSION DEBUG: Generated session ID: %s\n", session_id);
+
+            // Calculate expiry
+            var expires_at = new DateTime.now_utc().add(_session_duration);
+            stdout.printf("SESSION DEBUG: Session expires at: %s\n", expires_at.format_iso8601());
+
+            // Create session via repository
+            stdout.printf("SESSION DEBUG: Creating session via repository...\n");
+            var session = yield _repository.create(session_id, user_id, expires_at);
+            stdout.printf("SESSION DEBUG: Session created via repository\n");
+
+            // Set optional fields
+            if (ip_address != null) {
+                session.ip_address = ip_address;
+            }
+            if (user_agent != null) {
+                session.user_agent = user_agent;
+            }
+
+            // Update session with optional fields if any were set
+            if (ip_address != null || user_agent != null) {
+                stdout.printf("SESSION DEBUG: Updating session with IP/User-Agent...\n");
+                yield _repository.update(session);
+            }
+
+            stdout.printf("SESSION DEBUG: Session created successfully: %s\n", session_id);
+            return session;
+        }
+
+        // =========================================================================
+        // Session Token Generation
+        // =========================================================================
+
+        /**
+         * Generates a signed and encrypted session token.
+         *
+         * Creates a JSON payload with session_id, user_id, expires_at
+         * and uses CryptographyProvider.sign_then_seal_token().
+         *
+         * Note: For integration with the Authorisation system, use
+         * generate_authorisation_token() which creates an AuthorisationToken.
+         *
+         * @param session The session to generate a token for
+         * @return The encrypted token string
+         */
+        public string generate_session_token(Session session) {
+            stdout.printf("SESSION DEBUG: generate_session_token() called for session: %s\n", session.id);
+            stdout.printf("SESSION DEBUG: Session user_id: %s, expires_at: %s\n",
+                session.user_id, session.expires_at.format_iso8601());
+            
+            // Create JSON payload
+            var payload_obj = new Json.Object();
+            payload_obj.set_string_member("session_id", session.id);
+            payload_obj.set_string_member("user_id", session.user_id);
+            payload_obj.set_string_member("expires_at", session.expires_at.format_iso8601());
+
+            // Wrap object in a node for serialization
+            var node = new Json.Node(Json.NodeType.OBJECT);
+            node.set_object(payload_obj);
+            var payload = Json.to_string(node, false);
+            stdout.printf("SESSION DEBUG: Token payload JSON: %s\n", payload);
+
+            // Sign and seal the token with expiry
+            stdout.printf("SESSION DEBUG: Calling sign_then_seal_token()...\n");
+            var token = _crypto.sign_then_seal_token(payload, session.expires_at);
+            stdout.printf("SESSION DEBUG: Token generated successfully (length: %d)\n", token.length);
+            return token;
+        }
+
+        /**
+         * Generates an AuthorisationToken for a user session.
+         * 
+         * This method creates a token compatible with the Authorisation system,
+         * using the AuthorisationTokenService if available.
+         *
+         * @param user The authenticated user
+         * @param session The session for the user
+         * @return The encrypted authorisation token string
+         */
+        public string generate_authorisation_token(User user, Session session) {
+            // If AuthorisationTokenService is available, use it
+            if (_token_service != null) {
+                return _token_service.generate_token(user, session.expires_at);
+            }
+            
+            // Fall back to session token generation
+            return generate_session_token(session);
+        }
+
+        // =========================================================================
+        // Session Validation
+        // =========================================================================
+
+        /**
+         * Validates a session token and returns the result.
+         *
+         * This method:
+         * - Uses CryptographyProvider.unseal_then_verify_token()
+         * - Checks expiry
+         * - Loads session from storage
+         * - Verifies session exists and matches token data
+         *
+         * @param token The encrypted token string
+         * @return A SessionValidationResult with session and user info
+         */
+        public async SessionValidationResult validate_session_token_async(string token) throws Error {
+            // Decrypt and verify the token
+            var token_result = _crypto.unseal_then_verify_token(token);
+
+            if (!token_result.is_valid) {
+                return new SessionValidationResult.failure(
+                    token_result.error_message ?? "Invalid token"
+                );
+            }
+
+            // Check if token is expired (crypto provider handles this but double-check)
+            if (token_result.is_expired) {
+                return new SessionValidationResult.failure("Session has expired");
+            }
+
+            // Parse the payload
+            var payload = token_result.payload;
+            if (payload == null) {
+                return new SessionValidationResult.failure("Empty token payload");
+            }
+
+            var json = new JsonElement.from_string((!)payload);
+            var obj = json.as<JsonObject>();
+
+            var session_id = obj.get("session_id").as<string>();
+            var user_id = obj.get("user_id").as<string>();
+            var expires_at_str = obj.get("expires_at").as<string>();
+            var expires_at = new DateTime.from_iso8601(expires_at_str, new TimeZone.utc());
+
+            // Check expiry again
+            if (expires_at.compare(new DateTime.now_utc()) <= 0) {
+                return new SessionValidationResult.failure("Session has expired");
+            }
+
+            // Load session from storage
+            var session = yield get_session_async(session_id);
+            if (session == null) {
+                return new SessionValidationResult.failure("Session not found");
+            }
+
+            // Verify session matches token data
+            if (session.user_id != user_id) {
+                return new SessionValidationResult.failure("Session user mismatch");
+            }
+
+            return new SessionValidationResult.success(session);
+        }
+
+        // =========================================================================
+        // Session Retrieval
+        // =========================================================================
+
+        /**
+         * Gets a session by its unique ID.
+         *
+         * @param session_id The session's unique identifier
+         * @return The Session, or null if not found or expired
+         * @throws Error on storage failure
+         */
+        public async Session? get_session_async(string session_id) throws Error {
+            var session = yield _repository.get_by_id(session_id);
+
+            // Don't return expired sessions
+            if (session != null && session.is_expired()) {
+                return null;
+            }
+
+            return session;
+        }
+
+        /**
+         * Gets all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of active (non-expired) sessions
+         * @throws Error on storage failure
+         */
+        public async Vector<Session> get_sessions_for_user_async(string user_id) throws Error {
+            var all_sessions = yield _repository.get_by_user_id(user_id);
+            
+            // Filter out expired sessions
+            var active_sessions = new Vector<Session>();
+            foreach (var session in all_sessions) {
+                if (!session.is_expired()) {
+                    active_sessions.add(session);
+                }
+            }
+
+            return active_sessions;
+        }
+
+        // =========================================================================
+        // Session Deletion
+        // =========================================================================
+
+        /**
+         * Deletes a session by its unique ID.
+         *
+         * @param session_id The session's unique identifier
+         * @throws Error on failure
+         */
+        public async void delete_session_async(string session_id) throws Error {
+            // Get session first to verify it exists
+            var session = yield get_session_async(session_id);
+            if (session == null) {
+                throw new SessionError.SESSION_NOT_FOUND("Session not found");
+            }
+
+            // Delete session via repository
+            yield _repository.delete(session_id);
+        }
+
+        /**
+         * Deletes all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @throws Error on storage failure
+         */
+        public async void delete_all_sessions_for_user_async(string user_id) throws Error {
+            yield _repository.delete_by_user_id(user_id);
+        }
+
+        // =========================================================================
+        // Cookie Handling
+        // =========================================================================
+
+        /**
+         * Sets the session cookie on an HTTP response.
+         *
+         * Cookie configuration:
+         * - HttpOnly: true
+         * - Secure: configurable (default true)
+         * - SameSite: Strict
+         * - Path: /
+         *
+         * @param result The HttpResult to set the cookie header on
+         * @param token The session token to set
+         */
+        public void set_session_cookie(HttpResult result, string token) {
+            stdout.printf("SESSION DEBUG: set_session_cookie() called\n");
+            stdout.printf("SESSION DEBUG: Cookie name: %s, Token length: %d\n", _cookie_name, token.length);
+            
+            // Build the Set-Cookie header value manually
+            var max_age = (int)(_session_duration / TimeSpan.SECOND);
+            stdout.printf("SESSION DEBUG: Max-Age: %d seconds\n", max_age);
+            
+            var cookie_value = @"$_cookie_name=$token; Path=/; Max-Age=$max_age; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            stdout.printf("SESSION DEBUG: Cookie header value (length: %d): %s\n",
+                cookie_value.length, cookie_value.substring(0, int.min(100, cookie_value.length)) + "...");
+            stdout.printf("SESSION DEBUG: Calling result.set_header('Set-Cookie', ...)\n");
+            result.set_header("Set-Cookie", cookie_value);
+            stdout.printf("SESSION DEBUG: Cookie header set successfully\n");
+        }
+
+        /**
+         * Clears the session cookie on an HTTP response.
+         *
+         * @param result The HttpResult to clear the cookie on
+         */
+        public void clear_session_cookie(HttpResult result) {
+            var cookie_value = @"$_cookie_name=; Path=/; Max-Age=0; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        /**
+         * Gets the session cookie value from an HTTP request.
+         *
+         * @param http_context The HttpContext containing the request
+         * @return The session cookie value, or null if not present
+         */
+        public string? get_session_cookie(HttpContext http_context) {
+            return http_context.request.get_cookie(_cookie_name);
+        }
+
+        // =========================================================================
+        // Session Cleanup
+        // =========================================================================
+
+        /**
+         * Removes all expired sessions from storage.
+         *
+         * @throws Error on storage failure
+         */
+        public async void cleanup_expired_sessions_async() throws Error {
+            yield _repository.delete_expired();
+        }
+
+        // =========================================================================
+        // Authentication Helper
+        // =========================================================================
+
+        /**
+         * Authenticates an HTTP request using the session cookie.
+         *
+         * This method:
+         * - Gets session cookie from request
+         * - Validates token
+         * - Loads user via UserService
+         * - Returns result with user and session
+         *
+         * @param http_context The HttpContext containing the request to authenticate
+         * @param user_service The UserService to load users from
+         * @return An AuthResult with authentication status and user/session info
+         * @throws Error on storage failure
+         */
+        public async AuthResult authenticate_request_async(HttpContext http_context, UserService user_service) throws Error {
+            // Get session cookie
+            var token = get_session_cookie(http_context);
+
+            if (token == null) {
+                return new AuthResult.failure("No session cookie found");
+            }
+
+            // Validate token
+            var validation = yield validate_session_token_async((!)token);
+
+            if (!validation.is_valid) {
+                return new AuthResult.failure(validation.error_message ?? "Invalid session");
+            }
+
+            var session = validation.session;
+            if (session == null) {
+                return new AuthResult.failure("Session not found");
+            }
+
+            // Load user
+            var user = yield user_service.get_user_async(session.user_id);
+            if (user == null) {
+                return new AuthResult.failure("User not found");
+            }
+
+            return new AuthResult.success(user, session);
+        }
+
+        // =========================================================================
+        // Private Helper Methods
+        // =========================================================================
+
+        private string generate_uuid() {
+            // Generate UUID v4 using libsodium random bytes
+            uint8[] bytes = new uint8[16];
+            Sodium.Random.random_bytes(bytes);
+
+            // Set version (4) and variant bits
+            bytes[6] = (bytes[6] & 0x0f) | 0x40;  // Version 4
+            bytes[8] = (bytes[8] & 0x3f) | 0x80;  // Variant 1
+
+            // Format as UUID string
+            return "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x".printf(
+                bytes[0], bytes[1], bytes[2], bytes[3],
+                bytes[4], bytes[5], bytes[6], bytes[7],
+                bytes[8], bytes[9], bytes[10], bytes[11],
+                bytes[12], bytes[13], bytes[14], bytes[15]
+            );
+        }
+    }
+}

+ 189 - 0
src/Authentication/SqlSessionRepository.vala

@@ -0,0 +1,189 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillSql;
+
+namespace Spry.Authentication {
+
+    /**
+     * SQL implementation of SessionRepository using InvercargillSql.
+     */
+    public class SqlSessionRepository : Object, SessionRepository {
+
+        private Connection _connection;
+
+        // =========================================================================
+        // Constructor
+        // =========================================================================
+
+        public SqlSessionRepository(Connection connection) {
+            _connection = connection;
+        }
+
+        // =========================================================================
+        // Retrieval Operations
+        // =========================================================================
+
+        public async Session? get_by_id(string id) throws Error {
+            var sql = "SELECT * FROM sessions WHERE id = :id";
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("id", id)
+                .execute_query_async();
+
+            var row = results.first_or_default();
+            if (row == null) {
+                return null;
+            }
+
+            return session_from_properties(row);
+        }
+
+        public async Vector<Session> get_by_user_id(string user_id) throws Error {
+            var sql = """
+                SELECT * FROM sessions
+                WHERE user_id = :user_id
+                ORDER BY created_at DESC
+            """;
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .execute_query_async();
+
+            var sessions = new Vector<Session>();
+            foreach (var row in results) {
+                sessions.add(session_from_properties(row));
+            }
+
+            return sessions;
+        }
+
+        // =========================================================================
+        // Mutation Operations
+        // =========================================================================
+
+        public async Session create(string id, string user_id, DateTime expires_at) throws Error {
+            var now = new DateTime.now_utc();
+
+            var sql = """
+                INSERT INTO sessions (id, user_id, created_at, expires_at, last_accessed_at)
+                VALUES (:id, :user_id, :created_at, :expires_at, :last_accessed_at)
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("id", id)
+                .with_parameter("user_id", user_id)
+                .with_parameter("created_at", now.format_iso8601())
+                .with_parameter("expires_at", expires_at.format_iso8601())
+                .with_parameter("last_accessed_at", now.format_iso8601())
+                .execute_non_query_async();
+
+            var session = new Session();
+            session.id = id;
+            session.user_id = user_id;
+            session.created_at = now;
+            session.expires_at = expires_at;
+
+            return session;
+        }
+
+        public async void update(Session session) throws Error {
+            var sql = """
+                UPDATE sessions SET
+                    expires_at = :expires_at
+                WHERE id = :id
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("id", session.id)
+                .with_parameter("expires_at", session.expires_at.format_iso8601())
+                .execute_non_query_async();
+        }
+
+        public async void delete(string id) throws Error {
+            var sql = "DELETE FROM sessions WHERE id = :id";
+
+            yield _connection.create_command(sql)
+                .with_parameter("id", id)
+                .execute_non_query_async();
+        }
+
+        public async void delete_by_user_id(string user_id) throws Error {
+            var sql = "DELETE FROM sessions WHERE user_id = :user_id";
+
+            yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .execute_non_query_async();
+        }
+
+        // =========================================================================
+        // Cleanup Operations
+        // =========================================================================
+
+        public async void delete_expired() throws Error {
+            var now = new DateTime.now_utc();
+
+            var sql = "DELETE FROM sessions WHERE expires_at < :now";
+
+            yield _connection.create_command(sql)
+                .with_parameter("now", now.format_iso8601())
+                .execute_non_query_async();
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private Session session_from_properties(Properties props) {
+            var session = new Session();
+
+            // Required fields
+            session.id = get_string_or_empty(props, "id");
+            session.user_id = get_string_or_empty(props, "user_id");
+
+            // created_at
+            var created_str = get_string_or_empty(props, "created_at");
+            if (created_str.length > 0) {
+                session.created_at = new DateTime.from_iso8601(created_str, new TimeZone.utc());
+            }
+
+            // expires_at
+            var expires_str = get_string_or_empty(props, "expires_at");
+            if (expires_str.length > 0) {
+                session.expires_at = new DateTime.from_iso8601(expires_str, new TimeZone.utc());
+            }
+
+            // ip_address (nullable)
+            var ip_address = get_string_or_null(props, "ip_address");
+            session.ip_address = ip_address;
+
+            // user_agent (nullable)
+            var user_agent = get_string_or_null(props, "user_agent");
+            session.user_agent = user_agent;
+
+            return session;
+        }
+
+        private string get_string_or_empty(Properties props, string key) {
+            if (!props.has(key)) {
+                return "";
+            }
+            var elem = props.get(key);
+            if (elem == null) {
+                return "";
+            }
+            var str = elem.as<string>();
+            return str ?? "";
+        }
+
+        private string? get_string_or_null(Properties props, string key) {
+            if (!props.has(key)) {
+                return null;
+            }
+            var elem = props.get(key);
+            if (elem == null) {
+                return null;
+            }
+            return elem.as<string>();
+        }
+    }
+}

+ 401 - 0
src/Authentication/SqlUserRepository.vala

@@ -0,0 +1,401 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillSql;
+
+namespace Spry.Authentication {
+
+    /**
+     * SQL implementation of UserRepository using InvercargillSql.
+     */
+    public class SqlUserRepository : Object, UserRepository {
+
+        private Connection _connection;
+
+        // =========================================================================
+        // Constructor
+        // =========================================================================
+
+        public SqlUserRepository(Connection connection) {
+            _connection = connection;
+        }
+
+        // =========================================================================
+        // Retrieval Operations
+        // =========================================================================
+
+        public async User? get_by_id(string id) throws Error {
+            var sql = "SELECT * FROM users WHERE id = :id";
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("id", id)
+                .execute_query_async();
+
+            var row = results.first_or_default();
+            if (row == null) {
+                return null;
+            }
+
+            var user = user_from_properties(row);
+
+            // Load permissions
+            var permissions = yield get_permissions(id);
+            foreach (var perm in permissions) {
+                user.add_permission(perm);
+            }
+
+            // Load app data
+            var app_data = yield get_all_app_data(id);
+            foreach (var key in app_data.keys) {
+                user.app_data.set(key, app_data.get(key));
+            }
+
+            return user;
+        }
+
+        public async User? get_by_username(string username) throws Error {
+            var sql = "SELECT * FROM users WHERE username = :username";
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("username", username)
+                .execute_query_async();
+
+            var row = results.first_or_default();
+            if (row == null) {
+                return null;
+            }
+
+            var user = user_from_properties(row);
+
+            // Load permissions
+            var permissions = yield get_permissions(user.id);
+            foreach (var perm in permissions) {
+                user.add_permission(perm);
+            }
+
+            // Load app data
+            var app_data = yield get_all_app_data(user.id);
+            foreach (var key in app_data.keys) {
+                user.app_data.set(key, app_data.get(key));
+            }
+
+            return user;
+        }
+
+        public async User? get_by_email(string email) throws Error {
+            var sql = "SELECT * FROM users WHERE email = :email";
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("email", email)
+                .execute_query_async();
+
+            var row = results.first_or_default();
+            if (row == null) {
+                return null;
+            }
+
+            var user = user_from_properties(row);
+
+            // Load permissions
+            var permissions = yield get_permissions(user.id);
+            foreach (var perm in permissions) {
+                user.add_permission(perm);
+            }
+
+            // Load app data
+            var app_data = yield get_all_app_data(user.id);
+            foreach (var key in app_data.keys) {
+                user.app_data.set(key, app_data.get(key));
+            }
+
+            return user;
+        }
+
+        // =========================================================================
+        // Mutation Operations
+        // =========================================================================
+
+        public async User create(string username, string email, string password_hash) throws Error {
+            var id = generate_uuid();
+            var now = new DateTime.now_utc();
+
+            var sql = """
+                INSERT INTO users (id, username, email, password_hash, created_at, updated_at)
+                VALUES (:id, :username, :email, :password_hash, :created_at, :updated_at)
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("id", id)
+                .with_parameter("username", username)
+                .with_parameter("email", email)
+                .with_parameter("password_hash", password_hash)
+                .with_parameter("created_at", now.format_iso8601())
+                .with_parameter("updated_at", now.format_iso8601())
+                .execute_non_query_async();
+
+            var user = new User();
+            user.set_id(id);
+            user.set_username(username);
+            user.email = email;
+            user.password_hash = password_hash;
+            user.created_at = now;
+            user.updated_at = now;
+
+            return user;
+        }
+
+        public async void update(User user) throws Error {
+            var now = new DateTime.now_utc();
+
+            var sql = """
+                UPDATE users SET
+                    username = :username,
+                    email = :email,
+                    password_hash = :password_hash,
+                    updated_at = :updated_at
+                WHERE id = :id
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("id", user.id)
+                .with_parameter("username", user.username)
+                .with_parameter("email", user.email)
+                .with_parameter("password_hash", user.password_hash)
+                .with_parameter("updated_at", now.format_iso8601())
+                .execute_non_query_async();
+
+            user.updated_at = now;
+        }
+
+        public async void delete(string id) throws Error {
+            var sql = "DELETE FROM users WHERE id = :id";
+
+            yield _connection.create_command(sql)
+                .with_parameter("id", id)
+                .execute_non_query_async();
+        }
+
+        // =========================================================================
+        // Query Operations
+        // =========================================================================
+
+        public async bool exists_by_username(string username) throws Error {
+            var sql = "SELECT COUNT(*) FROM users WHERE username = :username";
+
+            var scalar = yield _connection.create_command(sql)
+                .with_parameter("username", username)
+                .execute_scalar_async();
+
+            if (scalar == null) {
+                return false;
+            }
+
+            return scalar.as<int>() > 0;
+        }
+
+        public async bool exists_by_email(string email) throws Error {
+            var sql = "SELECT COUNT(*) FROM users WHERE email = :email";
+
+            var scalar = yield _connection.create_command(sql)
+                .with_parameter("email", email)
+                .execute_scalar_async();
+
+            if (scalar == null) {
+                return false;
+            }
+
+            return scalar.as<int>() > 0;
+        }
+
+        // =========================================================================
+        // Permission Operations
+        // =========================================================================
+
+        public async void add_permission(string user_id, string permission) throws Error {
+            var sql = """
+                INSERT OR IGNORE INTO user_permissions (user_id, permission)
+                VALUES (:user_id, :permission)
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .with_parameter("permission", permission)
+                .execute_non_query_async();
+        }
+
+        public async void remove_permission(string user_id, string permission) throws Error {
+            var sql = """
+                DELETE FROM user_permissions
+                WHERE user_id = :user_id AND permission = :permission
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .with_parameter("permission", permission)
+                .execute_non_query_async();
+        }
+
+        public async bool has_permission(string user_id, string permission) throws Error {
+            var sql = """
+                SELECT COUNT(*) FROM user_permissions
+                WHERE user_id = :user_id AND permission = :permission
+            """;
+
+            var scalar = yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .with_parameter("permission", permission)
+                .execute_scalar_async();
+
+            if (scalar == null) {
+                return false;
+            }
+
+            return scalar.as<int>() > 0;
+        }
+
+        public async Vector<string> get_permissions(string user_id) throws Error {
+            var sql = "SELECT permission FROM user_permissions WHERE user_id = :user_id";
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .execute_query_async();
+
+            var permissions = new Vector<string>();
+            foreach (var row in results) {
+                var perm_elem = row.get("permission");
+                if (perm_elem != null) {
+                    var perm = perm_elem.as<string>();
+                    if (perm != null && perm.length > 0) {
+                        permissions.add(perm);
+                    }
+                }
+            }
+
+            return permissions;
+        }
+
+        // =========================================================================
+        // App Data Operations
+        // =========================================================================
+
+        public async void set_app_data(string user_id, string key, string value) throws Error {
+            var sql = """
+                INSERT OR REPLACE INTO user_app_data (user_id, key, value)
+                VALUES (:user_id, :key, :value)
+            """;
+
+            yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .with_parameter("key", key)
+                .with_parameter("value", value)
+                .execute_non_query_async();
+        }
+
+        public async string? get_app_data(string user_id, string key) throws Error {
+            var sql = """
+                SELECT value FROM user_app_data
+                WHERE user_id = :user_id AND key = :key
+            """;
+
+            var scalar = yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .with_parameter("key", key)
+                .execute_scalar_async();
+
+            if (scalar == null) {
+                return null;
+            }
+
+            return scalar.as<string>();
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private User user_from_properties(Properties props) {
+            var user = new User();
+
+            // Required fields
+            user.set_id(get_string_or_empty(props, "id"));
+            user.set_username(get_string_or_empty(props, "username"));
+            user.email = get_string_or_empty(props, "email");
+            user.password_hash = get_string_or_empty(props, "password_hash");
+
+            // created_at
+            var created_str = get_string_or_empty(props, "created_at");
+            if (created_str.length > 0) {
+                user.created_at = new DateTime.from_iso8601(created_str, new TimeZone.utc());
+            }
+
+            // updated_at (nullable)
+            var updated_str = get_string_or_null(props, "updated_at");
+            if (updated_str != null && updated_str.length > 0) {
+                user.updated_at = new DateTime.from_iso8601(updated_str, new TimeZone.utc());
+            }
+
+            return user;
+        }
+
+        private string get_string_or_empty(Properties props, string key) {
+            if (!props.has(key)) {
+                return "";
+            }
+            var elem = props.get(key);
+            if (elem == null) {
+                return "";
+            }
+            var str = elem.as<string>();
+            return str ?? "";
+        }
+
+        private string? get_string_or_null(Properties props, string key) {
+            if (!props.has(key)) {
+                return null;
+            }
+            var elem = props.get(key);
+            if (elem == null) {
+                return null;
+            }
+            return elem.as<string>();
+        }
+
+        private async Dictionary<string, string> get_all_app_data(string user_id) throws Error {
+            var sql = "SELECT key, value FROM user_app_data WHERE user_id = :user_id";
+
+            var results = yield _connection.create_command(sql)
+                .with_parameter("user_id", user_id)
+                .execute_query_async();
+
+            var app_data = new Dictionary<string, string>();
+            foreach (var row in results) {
+                var key_elem = row.get("key");
+                var value_elem = row.get("value");
+
+                if (key_elem != null) {
+                    var key = key_elem.as<string>();
+                    var value = value_elem != null ? value_elem.as<string>() ?? "" : "";
+                    if (key != null && key.length > 0) {
+                        app_data.set(key, value);
+                    }
+                }
+            }
+
+            return app_data;
+        }
+
+        private string generate_uuid() {
+            uint8[] bytes = new uint8[16];
+            Sodium.Random.random_bytes(bytes);
+            // Set version 4 (random UUID)
+            bytes[6] = (bytes[6] & 0x0f) | 0x40;
+            // Set variant RFC 4122
+            bytes[8] = (bytes[8] & 0x3f) | 0x80;
+            return "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x".printf(
+                bytes[0], bytes[1], bytes[2], bytes[3],
+                bytes[4], bytes[5], bytes[6], bytes[7],
+                bytes[8], bytes[9], bytes[10], bytes[11],
+                bytes[12], bytes[13], bytes[14], bytes[15]
+            );
+        }
+    }
+}

+ 291 - 0
src/Authentication/User.vala

@@ -0,0 +1,291 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Json;
+
+namespace Spry.Authentication {
+
+    /**
+     * User represents an authenticated user and implements the Identity interface
+     * for integration with the Authorisation system.
+     */
+    public class User : GLib.Object, Authorisation.Identity {
+
+        // =========================================================================
+        // Identity Properties (required by Authorisation.Identity interface)
+        // =========================================================================
+
+        // Backing fields for identity properties
+        private string _id = "";
+        private string _username = "";
+
+        /**
+         * Unique identifier for this user.
+         * Read-only for Identity interface compliance.
+         * Use set_id() to modify.
+         */
+        public string id {
+            get { return _id; }
+        }
+
+        /**
+         * Human-readable name for this user.
+         * Read-only for Identity interface compliance.
+         * Use set_username() to modify.
+         */
+        public string username {
+            get { return _username; }
+        }
+
+        /**
+         * Returns permissions as an array for the Identity interface.
+         * Converts from internal Vector<string> to string[] for serialization.
+         */
+        public string[] permissions {
+            owned get {
+                var result = new string[0];
+                foreach (var perm in _permissions) {
+                    result += perm;
+                }
+                return result;
+            }
+        }
+
+        /**
+         * Returns user data as a Variant for embedding in authorisation tokens.
+         * Includes email, app_data, and timestamps.
+         */
+        public Variant data {
+            owned get {
+                return get_data_variant();
+            }
+        }
+
+        // =========================================================================
+        // Additional Authentication Data
+        // =========================================================================
+
+        public string email { get; set; }
+        public string password_hash { get; set; }
+
+        // Metadata
+        public DateTime created_at { get; set; }
+        public DateTime? updated_at { get; set; }
+
+        // Application-specific data - stored as JSON object
+        public Dictionary<string, string> app_data { get; set; default = new Dictionary<string, string>(); }
+
+        // =========================================================================
+        // Internal Storage
+        // =========================================================================
+
+        // Internal vector storage for permissions (used for mutability)
+        private Vector<string> _permissions = new Vector<string>();
+
+        // =========================================================================
+        // Setters for Identity Properties
+        // =========================================================================
+
+        /**
+         * Sets the user ID.
+         */
+        public void set_id(string value) {
+            _id = value;
+        }
+
+        /**
+         * Sets the username.
+         */
+        public void set_username(string value) {
+            _username = value;
+        }
+
+        // =========================================================================
+        // Permission Management (for internal use)
+        // =========================================================================
+
+        /**
+         * Gets the internal permissions vector for mutation.
+         * Used by PermissionService to add/remove permissions.
+         */
+        public Vector<string> get_permissions_vector() {
+            return _permissions;
+        }
+
+        /**
+         * Adds a permission to the user.
+         */
+        public void add_permission(string permission) {
+            _permissions.add(permission);
+        }
+
+        /**
+         * Removes a permission from the user.
+         */
+        public void remove_permission(string permission) {
+            _permissions.remove(permission);
+        }
+
+        /**
+         * Clears all permissions from the user.
+         */
+        public void clear_permissions() {
+            _permissions.clear();
+        }
+
+        /**
+         * Checks if the user has a specific permission.
+         */
+        public bool has_permission(string permission) {
+            foreach (var perm in _permissions) {
+                if (perm == permission) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        // =========================================================================
+        // Helper Methods
+        // =========================================================================
+
+        /**
+         * Helper to convert user data to Variant for token embedding.
+         */
+        private Variant get_data_variant() {
+            var builder = new VariantBuilder(VariantType.VARDICT);
+            
+            builder.add("{sv}", "email", new Variant.string(email ?? ""));
+            builder.add("{sv}", "created_at", new Variant.string(created_at != null ? created_at.format_iso8601() : ""));
+            
+            if (updated_at != null) {
+                builder.add("{sv}", "updated_at", new Variant.string(((!)updated_at).format_iso8601()));
+            }
+            
+            // Add app_data as a dictionary
+            var app_data_builder = new VariantBuilder(VariantType.VARDICT);
+            var iter = app_data.iterator();
+            while (iter.next()) {
+                var pair = iter.get();
+                app_data_builder.add("{sv}", pair.key, new Variant.string(pair.value ?? ""));
+            }
+            builder.add("{sv}", "app_data", app_data_builder.end());
+            
+            return builder.end();
+        }
+
+        // =========================================================================
+        // Constructors and JSON Serialization
+        // =========================================================================
+
+        public User() {
+            _id = "";
+            _username = "";
+            email = "";
+            password_hash = "";
+            created_at = new DateTime.now_utc();
+        }
+
+        public static User from_json(Json.Object obj) {
+            var user = new User();
+
+            // Required string fields - use has_member and null coalescing for safety
+            user.set_id(obj.has_member("id") ? (obj.get_string_member("id") ?? "") : "");
+            user.set_username(obj.has_member("username") ? (obj.get_string_member("username") ?? "") : "");
+            user.email = obj.has_member("email") ? (obj.get_string_member("email") ?? "") : "";
+            user.password_hash = obj.has_member("password_hash") ? (obj.get_string_member("password_hash") ?? "") : "";
+
+            // created_at - check member exists and value is not null/empty
+            if (obj.has_member("created_at")) {
+                var created_str = obj.get_string_member("created_at");
+                if (created_str != null && created_str.length > 0) {
+                    user.created_at = new DateTime.from_iso8601(created_str, new TimeZone.utc());
+                }
+            }
+
+            // updated_at (optional) - check member exists, is not null, and has value
+            if (obj.has_member("updated_at")) {
+                var member = obj.get_member("updated_at");
+                if (member != null && member.get_node_type() == Json.NodeType.VALUE) {
+                    var updated_str = obj.get_string_member("updated_at");
+                    if (updated_str != null && updated_str.length > 0) {
+                        user.updated_at = new DateTime.from_iso8601(updated_str, new TimeZone.utc());
+                    }
+                }
+            }
+
+            // permissions (array of strings) - check member exists and is array
+            if (obj.has_member("permissions")) {
+                var member = obj.get_member("permissions");
+                if (member != null && member.get_node_type() == Json.NodeType.ARRAY) {
+                    var perms_array = obj.get_array_member("permissions");
+                    if (perms_array != null) {
+                        foreach (var perm in perms_array.get_elements()) {
+                            var perm_str = perm.get_string();
+                            if (perm_str != null) {
+                                user._permissions.add(perm_str);
+                            }
+                        }
+                    }
+                }
+            }
+
+            // app_data (object with string values) - check member exists and is object
+            if (obj.has_member("app_data")) {
+                var member = obj.get_member("app_data");
+                if (member != null && member.get_node_type() == Json.NodeType.OBJECT) {
+                    var app_data_obj = obj.get_object_member("app_data");
+                    if (app_data_obj != null) {
+                        foreach (var key in app_data_obj.get_members()) {
+                            var value = app_data_obj.get_string_member(key);
+                            user.app_data.set(key, value ?? "");
+                        }
+                    }
+                }
+            }
+
+            return user;
+        }
+
+        public Json.Object to_json() {
+            var obj = new Json.Object();
+
+            // Use null coalescing to ensure we never pass null to set_string_member
+            obj.set_string_member("id", id ?? "");
+            obj.set_string_member("username", username ?? "");
+            obj.set_string_member("email", email ?? "");
+            obj.set_string_member("password_hash", password_hash ?? "");
+            obj.set_string_member("created_at", created_at != null ? created_at.format_iso8601() : new DateTime.now_utc().format_iso8601());
+
+            // updated_at (optional)
+            if (updated_at != null) {
+                obj.set_string_member("updated_at", ((!)updated_at).format_iso8601());
+            } else {
+                obj.set_null_member("updated_at");
+            }
+
+            // permissions array - always create array even if empty
+            var perms_array = new Json.Array();
+            if (_permissions != null) {
+                foreach (var perm in _permissions) {
+                    if (perm != null) {
+                        perms_array.add_string_element(perm);
+                    }
+                }
+            }
+            obj.set_array_member("permissions", perms_array);
+
+            // app_data object - always create object even if empty
+            var app_data_obj = new Json.Object();
+            if (app_data != null) {
+                var iter = app_data.iterator();
+                while (iter.next()) {
+                    var pair = iter.get();
+                    app_data_obj.set_string_member(pair.key ?? "", pair.value ?? "");
+                }
+            }
+            obj.set_object_member("app_data", app_data_obj);
+
+            return obj;
+        }
+    }
+}

+ 62 - 0
src/Authentication/UserIdentityProvider.vala

@@ -0,0 +1,62 @@
+using Inversion;
+
+namespace Spry.Authentication {
+
+    /**
+     * UserIdentityProvider implements the IdentityProvider interface from the
+     * Authorisation system, bridging Authentication.User to Authorisation.Identity.
+     * 
+     * This allows the Authorisation system to retrieve Identity objects using
+     * the UserService for storage operations.
+     * 
+     * Usage:
+     * Register this as the IdentityProvider implementation in your IoC container:
+     *   container.register<Authorisation.IdentityProvider, UserIdentityProvider>();
+     * 
+     * Or use directly:
+     *   var provider = new UserIdentityProvider();
+     *   var identity = yield provider.get_identity_by_id("user-123");
+     */
+    public class UserIdentityProvider : GLib.Object, Authorisation.IdentityProvider {
+
+        private UserService _user_service = inject<UserService>();
+
+        /**
+         * Creates a new UserIdentityProvider.
+         * Dependencies are injected via inject<>() pattern.
+         */
+        public UserIdentityProvider() {
+            // Dependencies injected via field initializers
+        }
+
+        /**
+         * Retrieves an Identity by its unique ID.
+         * 
+         * This method looks up a User by ID and returns it as an Identity.
+         * Since User implements Identity, no conversion is needed.
+         * 
+         * @param id The user's unique identifier
+         * @return The User as Identity, or null if not found
+         * @throws Error on retrieval failure
+         */
+        public async Authorisation.Identity? get_identity_by_id(string id) throws Error {
+            var user = yield _user_service.get_user_async(id);
+            return user; // User implements Identity
+        }
+
+        /**
+         * Retrieves an Identity by its username.
+         * 
+         * This method looks up a User by username and returns it as an Identity.
+         * Since User implements Identity, no conversion is needed.
+         * 
+         * @param username The username to look up
+         * @return The User as Identity, or null if not found
+         * @throws Error on retrieval failure
+         */
+        public async Authorisation.Identity? get_identity_by_username(string username) throws Error {
+            var user = yield _user_service.get_user_by_username_async(username);
+            return user; // User implements Identity
+        }
+    }
+}

+ 160 - 0
src/Authentication/UserRepository.vala

@@ -0,0 +1,160 @@
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication {
+
+    /**
+     * Repository interface for User persistence operations.
+     * Abstracts the storage mechanism from the service layer.
+     */
+    public interface UserRepository : Object {
+
+        // =========================================================================
+        // Retrieval Operations
+        // =========================================================================
+
+        /**
+         * Gets a user by their unique ID.
+         *
+         * @param id The user's unique identifier
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async User? get_by_id(string id) throws Error;
+
+        /**
+         * Gets a user by their username.
+         *
+         * @param username The username to look up
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async User? get_by_username(string username) throws Error;
+
+        /**
+         * Gets a user by their email address.
+         *
+         * @param email The email address to look up
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async User? get_by_email(string email) throws Error;
+
+        // =========================================================================
+        // Mutation Operations
+        // =========================================================================
+
+        /**
+         * Creates a new user.
+         *
+         * @param username The username for the new user
+         * @param email The email for the new user
+         * @param password_hash The hashed password
+         * @return The created User
+         * @throws Error on storage failure
+         */
+        public abstract async User create(string username, string email, string password_hash) throws Error;
+
+        /**
+         * Updates an existing user.
+         *
+         * @param user The user to update
+         * @throws Error on storage failure
+         */
+        public abstract async void update(User user) throws Error;
+
+        /**
+         * Deletes a user by their unique ID.
+         *
+         * @param id The user's unique identifier
+         * @throws Error on storage failure
+         */
+        public abstract async void delete(string id) throws Error;
+
+        // =========================================================================
+        // Query Operations
+        // =========================================================================
+
+        /**
+         * Checks if a username already exists.
+         *
+         * @param username The username to check
+         * @return true if the username exists
+         * @throws Error on storage failure
+         */
+        public abstract async bool exists_by_username(string username) throws Error;
+
+        /**
+         * Checks if an email already exists.
+         *
+         * @param email The email to check
+         * @return true if the email exists
+         * @throws Error on storage failure
+         */
+        public abstract async bool exists_by_email(string email) throws Error;
+
+        // =========================================================================
+        // Permission Operations
+        // =========================================================================
+
+        /**
+         * Adds a permission to a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to add
+         * @throws Error on storage failure
+         */
+        public abstract async void add_permission(string user_id, string permission) throws Error;
+
+        /**
+         * Removes a permission from a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to remove
+         * @throws Error on storage failure
+         */
+        public abstract async void remove_permission(string user_id, string permission) throws Error;
+
+        /**
+         * Checks if a user has a specific permission.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to check
+         * @return true if the user has the permission
+         * @throws Error on storage failure
+         */
+        public abstract async bool has_permission(string user_id, string permission) throws Error;
+
+        /**
+         * Gets all permissions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of permission strings
+         * @throws Error on storage failure
+         */
+        public abstract async Vector<string> get_permissions(string user_id) throws Error;
+
+        // =========================================================================
+        // App Data Operations
+        // =========================================================================
+
+        /**
+         * Sets an app data value for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param key The app data key
+         * @param value The app data value
+         * @throws Error on storage failure
+         */
+        public abstract async void set_app_data(string user_id, string key, string value) throws Error;
+
+        /**
+         * Gets an app data value for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @param key The app data key
+         * @return The app data value, or null if not found
+         * @throws Error on storage failure
+         */
+        public abstract async string? get_app_data(string user_id, string key) throws Error;
+    }
+}

+ 401 - 0
src/Authentication/UserService.vala

@@ -0,0 +1,401 @@
+using Inversion;
+using Invercargill.DataStructures;
+
+namespace Spry.Authentication {
+
+    /**
+     * Error domain for user-related operations.
+     */
+    public errordomain UserError {
+        USER_NOT_FOUND,
+        DUPLICATE_USERNAME,
+        DUPLICATE_EMAIL,
+        INVALID_PASSWORD,
+        INVALID_CREDENTIALS,
+        USER_INACTIVE,
+        PERMISSION_DENIED,
+        STORAGE_ERROR
+    }
+
+    /**
+     * UserService provides user management operations including CRUD,
+     * password hashing, and authentication.
+     * 
+     * This service uses the inject<> pattern for dependency injection.
+     * All methods are async to work with the repository async API.
+     */
+    public class UserService : GLib.Object {
+
+        private UserRepository _repository = inject<UserRepository>();
+        private CryptographyProvider _crypto = inject<CryptographyProvider>();
+
+        // =========================================================================
+        // User Creation
+        // =========================================================================
+
+        /**
+         * Creates a new user with the specified credentials.
+         *
+         * This method:
+         * - Validates username uniqueness
+         * - Validates email uniqueness
+         * - Hashes password with Argon2id via libsodium
+         * - Creates User with UUID and timestamps
+         *
+         * @param username The unique username
+         * @param email The unique email address
+         * @param password The plaintext password to hash
+         * @return The created User
+         * @throws UserError on validation or storage failure
+         */
+        public async User create_user_async(string username, string email, string password) throws Error {
+            // Validate username uniqueness
+            if (yield username_exists_async(username)) {
+                throw new UserError.DUPLICATE_USERNAME("Username already exists");
+            }
+
+            // Validate email uniqueness
+            if (yield email_exists_async(email)) {
+                throw new UserError.DUPLICATE_EMAIL("Email already exists");
+            }
+
+            // Hash password with Argon2id
+            var password_hash = hash_password(password);
+            if (password_hash == null) {
+                throw new UserError.STORAGE_ERROR("Failed to hash password");
+            }
+
+            // Create user via repository
+            var user = yield _repository.create(username, email, (!)password_hash);
+
+            return user;
+        }
+
+        // =========================================================================
+        // User Retrieval
+        // =========================================================================
+
+        /**
+         * Gets a user by their unique ID.
+         *
+         * @param user_id The user's unique identifier
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public async User? get_user_async(string user_id) throws Error {
+            return yield _repository.get_by_id(user_id);
+        }
+
+        /**
+         * Gets a user by their username.
+         *
+         * @param username The username to look up
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public async User? get_user_by_username_async(string username) throws Error {
+            return yield _repository.get_by_username(username);
+        }
+
+        /**
+         * Gets a user by their email address.
+         *
+         * @param email The email address to look up
+         * @return The User, or null if not found
+         * @throws Error on storage failure
+         */
+        public async User? get_user_by_email_async(string email) throws Error {
+            return yield _repository.get_by_email(email);
+        }
+
+        // =========================================================================
+        // User Update
+        // =========================================================================
+
+        /**
+         * Updates an existing user.
+         *
+         * This method:
+         * - Updates the updated_at timestamp
+         * - Handles username/email changes with uniqueness validation
+         *
+         * @param user The user to update
+         * @throws Error on validation or storage failure
+         */
+        public async void update_user_async(User user) throws Error {
+            // Get existing user to check for changes
+            var existing = yield get_user_async(user.id);
+            if (existing == null) {
+                throw new UserError.USER_NOT_FOUND("User not found");
+            }
+
+            // Check if username changed
+            if (existing.username != user.username) {
+                // Check new username uniqueness
+                var existing_with_username = yield get_user_by_username_async(user.username);
+                if (existing_with_username != null && existing_with_username.id != user.id) {
+                    throw new UserError.DUPLICATE_USERNAME("Username already exists");
+                }
+            }
+
+            // Check if email changed
+            if (existing.email != user.email) {
+                // Check new email uniqueness
+                var existing_with_email = yield get_user_by_email_async(user.email);
+                if (existing_with_email != null && existing_with_email.id != user.id) {
+                    throw new UserError.DUPLICATE_EMAIL("Email already exists");
+                }
+            }
+
+            // Update timestamp
+            user.updated_at = new DateTime.now_utc();
+
+            // Store updated user via repository
+            yield _repository.update(user);
+        }
+
+        // =========================================================================
+        // User Deletion
+        // =========================================================================
+
+        /**
+         * Deletes a user by their unique ID.
+         *
+         * @param user_id The user's unique identifier
+         * @throws Error on storage failure
+         */
+        public async void delete_user_async(string user_id) throws Error {
+            // Get user first (optional, for logging/cleanup)
+            var user = yield get_user_async(user_id);
+            if (user == null) {
+                throw new UserError.USER_NOT_FOUND("User not found");
+            }
+
+            // Delete user via repository
+            yield _repository.delete(user_id);
+        }
+
+        // =========================================================================
+        // User Listing
+        // =========================================================================
+
+        /**
+         * Lists users with pagination support.
+         *
+         * Note: This method is not supported by the basic UserRepository interface.
+         * Subclasses or extensions should implement this as needed.
+         *
+         * @param offset The number of users to skip
+         * @param limit The maximum number of users to return
+         * @return A Vector of users
+         * @throws Error on storage failure
+         */
+        public async Vector<User> list_users_async(int offset = 0, int limit = 100) throws Error {
+            // The basic UserRepository interface doesn't include list operations
+            // This would need to be added to the interface or handled differently
+            // For now, return an empty list as a placeholder
+            return new Vector<User>();
+        }
+
+        // =========================================================================
+        // Password Management
+        // =========================================================================
+
+        /**
+         * Hashes a password using Argon2id via libsodium.
+         *
+         * @param password The plaintext password to hash
+         * @return The hashed password string, or null on failure
+         */
+        public string? hash_password(string password) {
+            return Sodium.PasswordHashing.hash(password);
+        }
+
+        /**
+         * Verifies a password against a stored hash.
+         *
+         * @param user The user to verify against
+         * @param password The plaintext password to verify
+         * @return true if the password matches, false otherwise
+         */
+        public bool verify_password(User user, string password) {
+            return Sodium.PasswordHashing.check(user.password_hash, password);
+        }
+
+        /**
+         * Sets a new password for a user.
+         *
+         * @param user The user to update
+         * @param new_password The new plaintext password
+         * @throws Error on failure
+         */
+        public async void set_password_async(User user, string new_password) throws Error {
+            var password_hash = hash_password(new_password);
+            if (password_hash == null) {
+                throw new UserError.STORAGE_ERROR("Failed to hash password");
+            }
+
+            user.password_hash = (!)password_hash;
+            user.updated_at = new DateTime.now_utc();
+
+            yield update_user_async(user);
+        }
+
+        // =========================================================================
+        // Authentication
+        // =========================================================================
+
+        /**
+         * Authenticates a user by username/email and password.
+         *
+         * This method:
+         * - Looks up user by username or email
+         * - Verifies the password
+         * - Returns the user if valid
+         *
+         * @param username_or_email The username or email address
+         * @param password The plaintext password
+         * @return The authenticated User, or null if authentication failed
+         * @throws Error on storage failure
+         */
+        public async User? authenticate_async(string username_or_email, string password) throws Error {
+            // Try to find user by username first, then by email
+            User? user = yield get_user_by_username_async(username_or_email);
+
+            if (user == null) {
+                user = yield get_user_by_email_async(username_or_email);
+            }
+
+            if (user == null) {
+                return null;
+            }
+
+            // Verify password
+            bool password_valid = verify_password(user, password);
+            
+            if (!password_valid) {
+                return null;
+            }
+
+            return user;
+        }
+
+        // =========================================================================
+        // Utility Methods
+        // =========================================================================
+
+        /**
+         * Checks if a username already exists.
+         *
+         * @param username The username to check
+         * @return true if the username exists
+         * @throws Error on storage failure
+         */
+        public async bool username_exists_async(string username) throws Error {
+            return yield _repository.exists_by_username(username);
+        }
+
+        /**
+         * Checks if an email already exists.
+         *
+         * @param email The email to check
+         * @return true if the email exists
+         * @throws Error on storage failure
+         */
+        public async bool email_exists_async(string email) throws Error {
+            return yield _repository.exists_by_email(email);
+        }
+
+        /**
+         * Gets the total count of users.
+         *
+         * Note: This method is not supported by the basic UserRepository interface.
+         * Subclasses or extensions should implement this as needed.
+         *
+         * @return The number of users
+         * @throws Error on storage failure
+         */
+        public async int user_count_async() throws Error {
+            // The basic UserRepository interface doesn't include count operations
+            // This would need to be added to the interface or handled differently
+            return 0;
+        }
+
+        // =========================================================================
+        // Permission Operations
+        // =========================================================================
+
+        /**
+         * Adds a permission to a user.
+         *
+         * @param user The user to add the permission to
+         * @param permission The permission to add
+         * @throws Error on storage failure
+         */
+        public async void add_permission_async(User user, string permission) throws Error {
+            yield _repository.add_permission(user.id, permission);
+        }
+
+        /**
+         * Removes a permission from a user.
+         *
+         * @param user The user to remove the permission from
+         * @param permission The permission to remove
+         * @throws Error on storage failure
+         */
+        public async void remove_permission_async(User user, string permission) throws Error {
+            yield _repository.remove_permission(user.id, permission);
+        }
+
+        /**
+         * Checks if a user has a specific permission.
+         *
+         * @param user The user to check
+         * @param permission The permission to check
+         * @return true if the user has the permission
+         * @throws Error on storage failure
+         */
+        public async bool has_permission_async(User user, string permission) throws Error {
+            return yield _repository.has_permission(user.id, permission);
+        }
+
+        /**
+         * Gets all permissions for a user.
+         *
+         * @param user The user to get permissions for
+         * @return A Vector of permission strings
+         * @throws Error on storage failure
+         */
+        public async Vector<string> get_permissions_async(User user) throws Error {
+            return yield _repository.get_permissions(user.id);
+        }
+
+        // =========================================================================
+        // App Data Operations
+        // =========================================================================
+
+        /**
+         * Sets an app data value for a user.
+         *
+         * @param user The user to set the app data for
+         * @param key The app data key
+         * @param value The app data value
+         * @throws Error on storage failure
+         */
+        public async void set_app_data_async(User user, string key, string value) throws Error {
+            yield _repository.set_app_data(user.id, key, value);
+        }
+
+        /**
+         * Gets an app data value for a user.
+         *
+         * @param user The user to get the app data for
+         * @param key The app data key
+         * @return The app data value, or null if not found
+         * @throws Error on storage failure
+         */
+        public async string? get_app_data_async(User user, string key) throws Error {
+            return yield _repository.get_app_data(user.id, key);
+        }
+    }
+}

+ 30 - 0
src/Authentication/meson.build

@@ -0,0 +1,30 @@
+authentication_sources = files(
+    'User.vala',
+    'Session.vala',
+    'UserService.vala',
+    'SessionService.vala',
+    'PermissionService.vala',
+    'UserIdentityProvider.vala',
+    'UserRepository.vala',
+    'SessionRepository.vala',
+    'SqlUserRepository.vala',
+    'SqlSessionRepository.vala',
+    'CreateAuthTables.vala',
+    'Components/LoginFormComponent.vala',
+    'Components/UserManagementComponent.vala',
+    'Components/UserDetailsComponent.vala',
+    'Components/NewUserComponent.vala'
+)
+
+libspry_authentication = static_library('spry-authentication',
+    authentication_sources,
+    dependencies: [spry_dep, spry_authorisation_dep, invercargill_sql_dep, sqlite_dep, sodium_deps, invercargill_dep, astralis_dep],
+    include_directories: include_directories('..')
+)
+
+spry_authentication_inc = include_directories('.')
+spry_authentication_dep = declare_dependency(
+    link_with: libspry_authentication,
+    include_directories: spry_authentication_inc,
+    dependencies: [spry_dep, spry_authorisation_dep, invercargill_sql_dep]
+)

+ 272 - 0
src/Authorisation/AuthorisationContext.vala

@@ -0,0 +1,272 @@
+using Inversion;
+
+namespace Spry.Authorisation {
+
+    /**
+     * Request-scoped authorisation context.
+     * 
+     * Provides access to the current identity's authorisation state.
+     * Automatically populated from cookie or Bearer token on each request.
+     * 
+     * Usage:
+     * ```vala
+     * var auth = inject<AuthorisationContext>();
+     * if (!auth.is_authorised) {
+     *     // Redirect to login
+     * }
+     * if (auth.has_permission("admin")) {
+     *     // Show admin content
+     * }
+     * ```
+     */
+    public class AuthorisationContext : GLib.Object {
+
+        private AuthorisationToken? _token = null;
+        private IdentityProvider? _identity_provider = null;
+        private Identity? _cached_identity = null;
+
+        /**
+         * Whether the request has a valid authorisation token.
+         */
+        public bool is_authorised { get { return _token != null; } }
+
+        /**
+         * The identity ID from the token.
+         * Returns null if not authorised.
+         */
+        public string? user_id { 
+            get { return _token?.user_id; } 
+        }
+
+        /**
+         * The username from the token.
+         * Returns null if not authorised.
+         */
+        public string? username { 
+            get { return _token?.username; } 
+        }
+
+        /**
+         * The permissions from the token.
+         * Returns empty array if not authorised.
+         */
+        public string[] permissions {
+            owned get {
+                if (_token == null) {
+                    return new string[0];
+                }
+                return _token.permissions;
+            }
+        }
+
+        /**
+         * Additional data from the token.
+         * Returns empty variant if not authorised.
+         */
+        public Variant data {
+            owned get {
+                if (_token == null) {
+                    return new Variant.array(VariantType.VARIANT, {});
+                }
+                return _token.data;
+            }
+        }
+
+        /**
+         * The token expiry time, or null if no expiry set.
+         */
+        public DateTime? expires_at {
+            get { return _token?.expires_at; }
+        }
+
+        /**
+         * The token issuance time.
+         */
+        public DateTime issued_at {
+            owned get { return _token?.issued_at ?? new DateTime.now_utc(); }
+        }
+
+        /**
+         * The underlying token (for advanced use).
+         */
+        public AuthorisationToken? token { get { return _token; } }
+
+        /**
+         * Creates a new AuthorisationContext.
+         */
+        public AuthorisationContext() {
+        }
+
+        /**
+         * Creates an AuthorisationContext with a token.
+         */
+        public AuthorisationContext.with_token(AuthorisationToken token) {
+            _token = token;
+        }
+
+        /**
+         * Sets the token (called by AuthorisationService).
+         * 
+         * @param token The token to set, or null to clear
+         */
+        internal void set_token(AuthorisationToken? token) {
+            _token = token;
+            _cached_identity = null;
+        }
+
+        /**
+         * Sets the identity provider (called during initialization).
+         * 
+         * @param provider The identity provider to use
+         */
+        internal void set_identity_provider(IdentityProvider? provider) {
+            _identity_provider = provider;
+        }
+
+        // =========================================================================
+        // Permission Checking
+        // =========================================================================
+
+        /**
+         * Checks if the current identity has a specific permission.
+         * 
+         * Supports wildcard matching:
+         * - "admin" matches everything (super-user)
+         * - "users.*" matches "users.read", "users.write", etc.
+         * - "*" matches everything
+         * 
+         * @param permission The permission to check
+         * @return true if the identity has the permission
+         */
+        public bool has_permission(string permission) {
+            if (_token == null) {
+                return false;
+            }
+
+            foreach (var user_perm in _token.permissions) {
+                if (PermissionMatcher.matches(user_perm, permission)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Requires a specific permission, throws if not present.
+         * 
+         * @param permission The required permission
+         * @throws AuthorisationError.NOT_AUTHORISED if not authorised
+         * @throws AuthorisationError.PERMISSION_DENIED if permission missing
+         */
+        public void require_permission(string permission) throws Error {
+            if (!is_authorised) {
+                throw new AuthorisationError.NOT_AUTHORISED(
+                    "Authentication required"
+                );
+            }
+            if (!has_permission(permission)) {
+                throw new AuthorisationError.PERMISSION_DENIED(
+                    @"Permission '$permission' required"
+                );
+            }
+        }
+
+        /**
+         * Checks if the identity has ANY of the specified permissions.
+         * 
+         * @param permissions Array of permissions to check
+         * @return true if the identity has at least one of the permissions
+         */
+        public bool has_any_permission(string[] permissions) {
+            foreach (var perm in permissions) {
+                if (has_permission(perm)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Checks if the identity has ALL of the specified permissions.
+         * 
+         * @param permissions Array of permissions to check
+         * @return true if the identity has all of the permissions
+         */
+        public bool has_all_permissions(string[] permissions) {
+            foreach (var perm in permissions) {
+                if (!has_permission(perm)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        // =========================================================================
+        // Identity Retrieval
+        // =========================================================================
+
+        /**
+         * Retrieves the full Identity object from the provider.
+         * 
+         * This calls the registered IdentityProvider to get the complete
+         * identity object (e.g., User from Spry.Authentication).
+         * 
+         * @return The Identity, or null if not found/not authorised
+         * @throws Error on retrieval failure
+         */
+        public async Identity? get_current_identity_async() throws Error {
+            if (_token == null) {
+                return null;
+            }
+            if (_cached_identity != null) {
+                return _cached_identity;
+            }
+            if (_identity_provider == null) {
+                return null;
+            }
+
+            _cached_identity = yield _identity_provider.get_identity_by_id(_token.user_id);
+            return _cached_identity;
+        }
+
+        /**
+         * Synchronous version for cases where async is not available.
+         * 
+         * Note: This will only return a cached identity if one has been
+         * fetched previously via get_current_identity_async().
+         * 
+         * @return The cached Identity, or null if not available
+         */
+        public Identity? get_current_identity() {
+            if (_token == null) {
+                return null;
+            }
+            if (_cached_identity != null) {
+                return _cached_identity;
+            }
+            // Cannot call async synchronously - return null
+            return null;
+        }
+
+        // =========================================================================
+        // Utility Methods
+        // =========================================================================
+
+        /**
+         * Checks if the token has expired.
+         * 
+         * @return true if the token has expired
+         */
+        public bool is_expired() {
+            return _token?.is_expired() ?? true;
+        }
+
+        /**
+         * Clears the authorisation context.
+         */
+        public void clear() {
+            _token = null;
+            _cached_identity = null;
+        }
+    }
+}

+ 35 - 0
src/Authorisation/AuthorisationError.vala

@@ -0,0 +1,35 @@
+namespace Spry.Authorisation {
+
+    /**
+     * Error domain for authorisation-related errors.
+     * 
+     * These errors are thrown by the Authorisation system when
+     * authorisation checks fail or tokens are invalid.
+     */
+    public errordomain AuthorisationError {
+        /**
+         * The request is not authorised (no valid token present).
+         */
+        NOT_AUTHORISED,
+
+        /**
+         * The authorised identity does not have the required permission.
+         */
+        PERMISSION_DENIED,
+
+        /**
+         * The token is invalid (malformed, bad signature, etc.).
+         */
+        INVALID_TOKEN,
+
+        /**
+         * The token has expired.
+         */
+        TOKEN_EXPIRED,
+
+        /**
+         * The identity referenced by the token was not found.
+         */
+        IDENTITY_NOT_FOUND
+    }
+}

+ 340 - 0
src/Authorisation/AuthorisationService.vala

@@ -0,0 +1,340 @@
+using Inversion;
+using Astralis;
+
+namespace Spry.Authorisation {
+
+    /**
+     * Main service for authorisation request processing.
+     * 
+     * Token Sources (in order of precedence):
+     * 1. Authorization: Bearer <token> header
+     * 2. Cookie named in cookie_name property (default: spry_auth)
+     * 
+     * This service:
+     * - Extracts tokens from requests (cookie or bearer header)
+     * - Validates tokens via AuthorisationTokenService
+     * - Populates AuthorisationContext
+     * - Manages auth cookies on responses
+     */
+    public class AuthorisationService : GLib.Object {
+
+        private AuthorisationTokenService _token_service = inject<AuthorisationTokenService>();
+        private AuthorisationContext _context = inject<AuthorisationContext>();
+        private IdentityProvider? _identity_provider = inject<IdentityProvider>();
+        private HttpContext _http_context = inject<HttpContext>();
+
+        // Configuration
+        private string _cookie_name = "spry_auth";
+        private bool _cookie_secure = true;
+        private TimeSpan _token_duration = TimeSpan.HOUR * 24;
+
+        /**
+         * Cookie name for authorisation tokens.
+         */
+        public string cookie_name { 
+            get { return _cookie_name; } 
+            set { _cookie_name = value; }
+        }
+
+        /**
+         * Whether cookies should be Secure (HTTPS only).
+         */
+        public bool cookie_secure { 
+            get { return _cookie_secure; } 
+            set { _cookie_secure = value; }
+        }
+
+        /**
+         * Default token validity duration.
+         */
+        public TimeSpan token_duration { 
+            get { return _token_duration; } 
+            set { _token_duration = value; }
+        }
+
+        /**
+         * The underlying token service.
+         */
+        public AuthorisationTokenService token_service { 
+            get { return _token_service; }
+        }
+
+        /**
+         * The current authorisation context.
+         */
+        public AuthorisationContext context {
+            get { return _context; }
+        }
+
+        /**
+         * Creates a new AuthorisationService.
+         */
+        public AuthorisationService() {
+            // Set up identity provider in context if available
+            if (_identity_provider != null) {
+                _context.set_identity_provider(_identity_provider);
+            }
+        }
+
+        // =========================================================================
+        // Token Generation
+        // =========================================================================
+
+        /**
+         * Generates an authorisation token for an identity.
+         * 
+         * @param identity The identity to create a token for
+         * @param expires_at Optional custom expiry (defaults to token_duration from now)
+         * @return The encrypted token string
+         */
+        public string generate_token(Identity identity, DateTime? expires_at = null) {
+            return _token_service.generate_token(identity, expires_at);
+        }
+
+        /**
+         * Generates a token with a specific duration.
+         * 
+         * @param identity The identity to create a token for
+         * @param duration The token validity duration
+         * @return The encrypted token string
+         */
+        public string generate_token_with_duration(Identity identity, TimeSpan duration) {
+            var expires_at = new DateTime.now_utc().add(duration);
+            return _token_service.generate_token(identity, expires_at);
+        }
+
+        // =========================================================================
+        // Token Validation
+        // =========================================================================
+
+        /**
+         * Validates a token string.
+         * 
+         * @param token The encrypted token string
+         * @return The parsed AuthorisationToken, or null if invalid
+         */
+        public AuthorisationToken? validate_token(string token) {
+            return _token_service.parse_token(token);
+        }
+
+        // =========================================================================
+        // Request Processing
+        // =========================================================================
+
+        /**
+         * Extracts and validates token from an HTTP context's request.
+         * 
+         * Checks Authorization header first, then cookie.
+         * Populates the AuthorisationContext if valid token found.
+         * 
+         * @param http_context The HttpContext containing the request
+         * @return An AuthorisationContext populated with the token data
+         * @throws Error on processing failure
+         */
+        public async AuthorisationContext get_context_from_request(HttpContext http_context) throws Error {
+            string? token = null;
+
+            // 1. Check Authorization: Bearer header
+            var auth_header = http_context.request.headers.get_any_or_default("Authorization");
+            if (auth_header != null && ((!)auth_header).has_prefix("Bearer ")) {
+                token = ((!)auth_header).substring(7).strip();
+            }
+
+            // 2. Check cookie
+            if (token == null) {
+                token = http_context.request.get_cookie(_cookie_name);
+            }
+
+            // 3. Validate and populate context
+            if (token != null) {
+                var parsed_token = _token_service.parse_token((!)token);
+                if (parsed_token != null) {
+                    _context.set_token(parsed_token);
+                }
+            }
+
+            // Set identity provider if available
+            if (_identity_provider != null) {
+                _context.set_identity_provider(_identity_provider);
+            }
+
+            return _context;
+        }
+
+        /**
+         * Processes the current HTTP request (uses injected HttpContext).
+         *
+         * Checks Authorization header first, then cookie.
+         * Populates the AuthorisationContext if valid token found.
+         *
+         * @throws Error on processing failure
+         */
+        public async void process_request_async() throws Error {
+            string? token = null;
+
+            // 1. Check Authorization: Bearer header
+            var auth_header = _http_context.request.headers.get_any_or_default("Authorization");
+            if (auth_header != null && ((!)auth_header).has_prefix("Bearer ")) {
+                token = ((!)auth_header).substring(7).strip();
+            }
+
+            // 2. Check cookie
+            if (token == null) {
+                token = _http_context.request.get_cookie(_cookie_name);
+            }
+
+            // 3. Validate and populate context
+            if (token != null) {
+                var parsed_token = _token_service.parse_token((!)token);
+                if (parsed_token != null) {
+                    _context.set_token(parsed_token);
+                }
+            }
+
+            // Set identity provider if available
+            if (_identity_provider != null) {
+                _context.set_identity_provider(_identity_provider);
+            }
+        }
+
+        // =========================================================================
+        // Cookie Management
+        // =========================================================================
+
+        /**
+         * Sets the authorisation cookie on an HTTP result.
+         * 
+         * Cookie configuration:
+         * - HttpOnly: true
+         * - Secure: configurable (default true)
+         * - SameSite: Strict
+         * - Path: /
+         * 
+         * @param result The HttpResult to set the cookie on
+         * @param token The token to set
+         */
+        public void set_auth_cookie(HttpResult result, string token) {
+            // Build the Set-Cookie header value
+            var max_age = (int)(_token_duration / TimeSpan.SECOND);
+            var cookie_value = @"$_cookie_name=$token; Path=/; Max-Age=$max_age; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        /**
+         * Sets the authorisation cookie with custom max-age.
+         * 
+         * @param result The HttpResult to set the cookie on
+         * @param token The token to set
+         * @param max_age_seconds The cookie max-age in seconds
+         */
+        public void set_auth_cookie_with_max_age(HttpResult result, string token, int max_age_seconds) {
+            var cookie_value = @"$_cookie_name=$token; Path=/; Max-Age=$max_age_seconds; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        /**
+         * Clears the authorisation cookie.
+         * 
+         * @param result The HttpResult to clear the cookie on
+         */
+        public void clear_auth_cookie(HttpResult result) {
+            var cookie_value = @"$_cookie_name=; Path=/; Max-Age=0; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        // =========================================================================
+        // Convenience Methods
+        // =========================================================================
+
+        /**
+         * Checks if the current request is authorised.
+         * 
+         * Call get_context_from_request() first to populate the context.
+         * 
+         * @return true if the request has a valid authorisation token
+         */
+        public bool is_authorised() {
+            return _context.is_authorised;
+        }
+
+        /**
+         * Checks if the current identity has a specific permission.
+         * 
+         * Call get_context_from_request() first to populate the context.
+         * 
+         * @param permission The permission to check
+         * @return true if the identity has the permission
+         */
+        public bool has_permission(string permission) {
+            return _context.has_permission(permission);
+        }
+
+        /**
+         * Requires a specific permission, throws if not present.
+         * 
+         * Call get_context_from_request() first to populate the context.
+         * 
+         * @param permission The required permission
+         * @throws AuthorisationError.NOT_AUTHORISED if not authorised
+         * @throws AuthorisationError.PERMISSION_DENIED if permission missing
+         */
+        public void require_permission(string permission) throws Error {
+            _context.require_permission(permission);
+        }
+
+        /**
+         * Gets the current user ID from the context.
+         * 
+         * Call get_context_from_request() first to populate the context.
+         * 
+         * @return The user ID, or null if not authorised
+         */
+        public string? get_user_id() {
+            return _context.user_id;
+        }
+
+        /**
+         * Gets the current username from the context.
+         * 
+         * Call get_context_from_request() first to populate the context.
+         * 
+         * @return The username, or null if not authorised
+         */
+        public string? get_username() {
+            return _context.username;
+        }
+
+        /**
+         * Gets the current identity from the context.
+         * 
+         * Call get_context_from_request() first to populate the context.
+         * 
+         * @return The Identity, or null if not available
+         * @throws Error on retrieval failure
+         */
+        public async Identity? get_current_identity_async() throws Error {
+            return yield _context.get_current_identity_async();
+        }
+    }
+}

+ 281 - 0
src/Authorisation/AuthorisationToken.vala

@@ -0,0 +1,281 @@
+using Json;
+
+namespace Spry.Authorisation {
+
+    /**
+     * Data structure embedded in authorisation tokens.
+     * 
+     * Token contents (JSON, encrypted and signed):
+     * - id: Identity unique identifier
+     * - username: Human-readable name
+     * - permissions: Array of permission strings
+     * - data: Additional variant data
+     * - issued_at: Token issuance timestamp
+     * - expires_at: Token expiry timestamp
+     */
+    public class AuthorisationToken : GLib.Object {
+
+        // Identity fields
+        public string user_id { get; set; default = ""; }
+        public string username { get; set; default = ""; }
+        public string[] permissions { get; set; default = {}; }
+        public Variant data { get; set; }
+
+        // Token metadata
+        public DateTime issued_at { get; set; }
+        public DateTime? expires_at { get; set; default = null; }
+
+        /**
+         * Creates a token from an Identity.
+         * 
+         * @param identity The identity to create a token for
+         * @param duration Optional token duration (defaults to 24 hours)
+         */
+        public AuthorisationToken.from_identity(Identity identity, TimeSpan? duration = null) {
+            GLib.Object(
+                user_id: identity.id,
+                username: identity.username,
+                permissions: identity.permissions,
+                data: identity.data,
+                issued_at: new DateTime.now_utc(),
+                expires_at: new DateTime.now_utc().add(duration ?? TimeSpan.HOUR * 24)
+            );
+        }
+
+        /**
+         * Default constructor for deserialization.
+         */
+        public AuthorisationToken() {
+            issued_at = new DateTime.now_utc();
+            data = new Variant.array(VariantType.VARIANT, {});
+        }
+
+        /**
+         * Checks if the token has expired.
+         * 
+         * @return true if the token has expired, false otherwise
+         */
+        public bool is_expired() {
+            if (expires_at == null) {
+                return false;
+            }
+            return ((!)expires_at).compare(new DateTime.now_utc()) <= 0;
+        }
+
+        /**
+         * Serializes the token to JSON for encryption.
+         * 
+         * @return A Json.Object containing all token data
+         */
+        public Json.Object to_json() {
+            var obj = new Json.Object();
+            
+            // Identity fields
+            obj.set_string_member("user_id", user_id ?? "");
+            obj.set_string_member("username", username ?? "");
+            
+            // Permissions array
+            var perms_array = new Json.Array();
+            foreach (var perm in permissions) {
+                perms_array.add_string_element(perm);
+            }
+            obj.set_array_member("permissions", perms_array);
+            
+            // Data - serialize Variant to JSON
+            var data_obj = variant_to_json_object(data);
+            obj.set_object_member("data", data_obj);
+            
+            // Metadata
+            obj.set_string_member("issued_at", issued_at.format_iso8601());
+            if (expires_at != null) {
+                obj.set_string_member("expires_at", ((!)expires_at).format_iso8601());
+            }
+            
+            return obj;
+        }
+
+        /**
+         * Deserializes a token from JSON after decryption.
+         * 
+         * @param obj The Json.Object to deserialize from
+         * @return A new AuthorisationToken instance
+         */
+        public static AuthorisationToken from_json(Json.Object obj) {
+            var token = new AuthorisationToken();
+            
+            // Identity fields
+            token.user_id = obj.get_string_member("user_id") ?? "";
+            token.username = obj.get_string_member("username") ?? "";
+            
+            // Permissions
+            if (obj.has_member("permissions")) {
+                var arr = obj.get_array_member("permissions");
+                var perms = new string[arr.get_length()];
+                uint i = 0;
+                foreach (var element in arr.get_elements()) {
+                    perms[i++] = element.get_string() ?? "";
+                }
+                token.permissions = perms;
+            }
+            
+            // Data
+            if (obj.has_member("data")) {
+                token.data = json_object_to_variant(obj.get_object_member("data"));
+            }
+            
+            // Metadata
+            if (obj.has_member("issued_at")) {
+                token.issued_at = new DateTime.from_iso8601(
+                    obj.get_string_member("issued_at"),
+                    new TimeZone.utc()
+                );
+            }
+            if (obj.has_member("expires_at")) {
+                token.expires_at = new DateTime.from_iso8601(
+                    obj.get_string_member("expires_at"),
+                    new TimeZone.utc()
+                );
+            }
+            
+            return token;
+        }
+
+        /**
+         * Converts the token to a JSON string.
+         * 
+         * @return A JSON string representation
+         */
+        public string to_json_string() {
+            var node = new Json.Node(Json.NodeType.OBJECT);
+            node.set_object(to_json());
+            return Json.to_string(node, false);
+        }
+
+        /**
+         * Parses a token from a JSON string.
+         * 
+         * @param json_str The JSON string to parse
+         * @return A new AuthorisationToken instance, or null on parse error
+         */
+        public static AuthorisationToken? from_json_string(string json_str) {
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data(json_str);
+                var root = parser.get_root();
+                
+                if (root.get_node_type() != Json.NodeType.OBJECT) {
+                    return null;
+                }
+                
+                return from_json(root.get_object());
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        // Private helper to convert Variant to Json.Object
+        private static Json.Object variant_to_json_object(Variant v) {
+            var obj = new Json.Object();
+            
+            if (!v.is_of_type(VariantType.DICTIONARY)) {
+                // If not a dictionary, wrap in a single value
+                obj.set_string_member("value", v.print(false));
+                return obj;
+            }
+            
+            var iter = new VariantIter(v);
+            string key;
+            Variant value;
+            while (iter.next("{sv}", out key, out value)) {
+                add_variant_to_json_object(obj, key, value);
+            }
+            
+            return obj;
+        }
+
+        // Private helper to add a Variant value to a Json.Object
+        private static void add_variant_to_json_object(Json.Object obj, string key, Variant value) {
+            if (value.is_of_type(VariantType.STRING)) {
+                obj.set_string_member(key, value.get_string());
+            } else if (value.is_of_type(VariantType.BOOLEAN)) {
+                obj.set_boolean_member(key, value.get_boolean());
+            } else if (value.is_of_type(VariantType.INT64)) {
+                obj.set_int_member(key, value.get_int64());
+            } else if (value.is_of_type(VariantType.DOUBLE)) {
+                obj.set_double_member(key, value.get_double());
+            } else if (value.is_of_type(VariantType.ARRAY)) {
+                var arr = new Json.Array();
+                var iter = new VariantIter(value);
+                Variant item;
+                while ((item = iter.next_value()) != null) {
+                    arr.add_element(variant_to_json_node(item));
+                }
+                obj.set_array_member(key, arr);
+            } else if (value.is_of_type(VariantType.DICTIONARY)) {
+                obj.set_object_member(key, variant_to_json_object(value));
+            } else {
+                // Fallback: convert to string
+                obj.set_string_member(key, value.print(false));
+            }
+        }
+
+        // Private helper to convert a Variant to a Json.Node
+        private static Json.Node variant_to_json_node(Variant v) {
+            var node = new Json.Node(Json.NodeType.VALUE);
+            
+            if (v.is_of_type(VariantType.STRING)) {
+                node.set_string(v.get_string());
+            } else if (v.is_of_type(VariantType.BOOLEAN)) {
+                node.set_boolean(v.get_boolean());
+            } else if (v.is_of_type(VariantType.INT64)) {
+                node.set_int(v.get_int64());
+            } else if (v.is_of_type(VariantType.DOUBLE)) {
+                node.set_double(v.get_double());
+            } else {
+                node.set_string(v.print(false));
+            }
+            
+            return node;
+        }
+
+        // Private helper to convert Json.Object to Variant
+        private static Variant json_object_to_variant(Json.Object obj) {
+            var builder = new VariantBuilder(VariantType.VARDICT);
+            
+            foreach (var member in obj.get_members()) {
+                var node = obj.get_member(member);
+                builder.add("{sv}", member, json_node_to_variant(node));
+            }
+            
+            return builder.end();
+        }
+
+        // Private helper to convert Json.Node to Variant
+        private static Variant json_node_to_variant(Json.Node node) {
+            switch (node.get_node_type()) {
+                case Json.NodeType.VALUE:
+                    if (node.get_value_type() == typeof(string)) {
+                        return new Variant.string(node.get_string() ?? "");
+                    } else if (node.get_value_type() == typeof(bool)) {
+                        return new Variant.boolean(node.get_boolean());
+                    } else if (node.get_value_type() == typeof(int64)) {
+                        return new Variant.int64(node.get_int());
+                    } else if (node.get_value_type() == typeof(double)) {
+                        return new Variant.double(node.get_double());
+                    }
+                    return new Variant.string(node.get_string() ?? "");
+                case Json.NodeType.ARRAY:
+                    var arr = node.get_array();
+                    var builder = new VariantBuilder(VariantType.ARRAY);
+                    foreach (var element in arr.get_elements()) {
+                        builder.add_value(json_node_to_variant(element));
+                    }
+                    return builder.end();
+                case Json.NodeType.OBJECT:
+                    return json_object_to_variant(node.get_object());
+                default:
+                    return new Variant.string("");
+            }
+        }
+    }
+}

+ 223 - 0
src/Authorisation/AuthorisationTokenService.vala

@@ -0,0 +1,223 @@
+using Inversion;
+using Json;
+
+namespace Spry.Authorisation {
+
+    /**
+     * Service for generating and validating authorisation tokens.
+     * 
+     * Token Format:
+     * - JSON payload containing identity data and metadata
+     * - Signed with Ed25519 (server signing key)
+     * - Encrypted with X25519 (server sealing key)
+     * - Base64url encoded
+     * 
+     * This service uses the same encryption approach as SessionService.
+     */
+    public class AuthorisationTokenService : GLib.Object {
+
+        private CryptographyProvider _crypto = inject<CryptographyProvider>();
+
+        // Configuration
+        private TimeSpan _token_duration = TimeSpan.HOUR * 24;
+
+        /**
+         * Default token validity duration.
+         */
+        public TimeSpan token_duration { 
+            get { return _token_duration; } 
+            set { _token_duration = value; }
+        }
+
+        /**
+         * Creates a new AuthorisationTokenService with default configuration.
+         */
+        public AuthorisationTokenService() {
+            // Default configuration
+        }
+
+        // =========================================================================
+        // Token Generation
+        // =========================================================================
+
+        /**
+         * Generates a signed and encrypted authorisation token for an identity.
+         * 
+         * @param identity The identity to create a token for
+         * @param expires_at Optional custom expiry (defaults to token_duration from now)
+         * @return The encrypted token string
+         */
+        public string generate_token(Identity identity, DateTime? expires_at = null) {
+            // Calculate expiry
+            DateTime token_expiry;
+            if (expires_at != null) {
+                token_expiry = (!)expires_at;
+            } else {
+                token_expiry = new DateTime.now_utc().add(_token_duration);
+            }
+
+            // Create token from identity
+            var duration = token_expiry.difference(new DateTime.now_utc());
+            var token = new AuthorisationToken.from_identity(identity, duration);
+
+            // Serialize to JSON
+            var json_obj = token.to_json();
+            var node = new Json.Node(Json.NodeType.OBJECT);
+            node.set_object(json_obj);
+            var json_str = Json.to_string(node, false);
+
+            // Sign and seal using CryptographyProvider
+            return _crypto.sign_then_seal_token(json_str, token_expiry);
+        }
+
+        /**
+         * Generates a token from an existing AuthorisationToken.
+         * 
+         * @param token The token to serialize and encrypt
+         * @return The encrypted token string
+         */
+        public string generate_token_from_token(AuthorisationToken token) {
+            var json_str = token.to_json_string();
+            return _crypto.sign_then_seal_token(json_str, token.expires_at);
+        }
+
+        // =========================================================================
+        // Token Validation
+        // =========================================================================
+
+        /**
+         * Validates a token string and returns the parsed token.
+         * 
+         * This method:
+         * - Uses CryptographyProvider.unseal_then_verify_token()
+         * - Checks expiry
+         * - Parses the JSON payload
+         * 
+         * @param token_string The encrypted token string
+         * @return The AuthorisationToken, or null if invalid/expired
+         */
+        public AuthorisationToken? parse_token(string token_string) {
+            try {
+                // Decrypt and verify the token
+                var result = _crypto.unseal_then_verify_token(token_string);
+
+                if (!result.is_valid) {
+                    return null;
+                }
+
+                // Check if token is expired
+                if (result.is_expired) {
+                    return null;
+                }
+
+                // Get the payload
+                var payload = result.payload;
+                if (payload == null) {
+                    return null;
+                }
+
+                // Parse the JSON
+                var token = AuthorisationToken.from_json_string((!)payload);
+                if (token == null) {
+                    return null;
+                }
+
+                // Double-check expiry from the token itself
+                if (token.is_expired()) {
+                    return null;
+                }
+
+                return token;
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        /**
+         * Validates a token and returns detailed validation result.
+         * 
+         * @param token_string The encrypted token string
+         * @return A TokenValidationResult with status and token data
+         */
+        public TokenValidationResult validate_token(string token_string) {
+            // Decrypt and verify the token
+            var crypto_result = _crypto.unseal_then_verify_token(token_string);
+
+            if (!crypto_result.is_valid) {
+                return new TokenValidationResult.failure(
+                    crypto_result.error_message ?? "Invalid token"
+                );
+            }
+
+            if (crypto_result.is_expired) {
+                return new TokenValidationResult.failure("Token has expired", true);
+            }
+
+            var payload = crypto_result.payload;
+            if (payload == null) {
+                return new TokenValidationResult.failure("Empty token payload");
+            }
+
+            var token = AuthorisationToken.from_json_string((!)payload);
+            if (token == null) {
+                return new TokenValidationResult.failure("Failed to parse token payload");
+            }
+
+            // Double-check expiry from the token itself
+            if (token.is_expired()) {
+                return new TokenValidationResult.failure("Token has expired", true);
+            }
+
+            return new TokenValidationResult.success(token);
+        }
+    }
+
+    /**
+     * Result of token validation containing the token and status information.
+     */
+    public class TokenValidationResult : GLib.Object {
+        /**
+         * Whether the token was successfully validated.
+         */
+        public bool is_valid { get; set; }
+
+        /**
+         * The parsed token, or null if validation failed.
+         */
+        public AuthorisationToken? token { get; set; }
+
+        /**
+         * Error message describing why validation failed.
+         */
+        public string? error_message { get; set; }
+
+        /**
+         * Whether the token has expired.
+         */
+        public bool is_expired { get; set; }
+
+        /**
+         * Creates a successful validation result.
+         */
+        public TokenValidationResult.success(AuthorisationToken token) {
+            GLib.Object(
+                is_valid: true,
+                token: token,
+                error_message: null,
+                is_expired: false
+            );
+        }
+
+        /**
+         * Creates a failed validation result.
+         */
+        public TokenValidationResult.failure(string error_message, bool expired = false) {
+            GLib.Object(
+                is_valid: false,
+                token: null,
+                error_message: error_message,
+                is_expired: expired
+            );
+        }
+    }
+}

+ 41 - 0
src/Authorisation/Identity.vala

@@ -0,0 +1,41 @@
+using Invercargill.DataStructures;
+
+namespace Spry.Authorisation {
+
+    /**
+     * Interface representing an authenticated identity.
+     * 
+     * Implementations provide identity data that gets embedded in tokens
+     * and can be retrieved on subsequent requests.
+     * 
+     * Built-in implementation: Spry.Authentication.User (via UserIdentityProvider)
+     * Custom implementations: OAuth providers, certificate auth, etc.
+     */
+    public interface Identity : GLib.Object {
+
+        /**
+         * Unique identifier for this identity.
+         * Used to look up the full identity object.
+         */
+        public abstract string id { get; }
+
+        /**
+         * Human-readable name for this identity.
+         * Typically username or email.
+         */
+        public abstract string username { get; }
+
+        /**
+         * Permissions granted to this identity.
+         * Returns an array of permission strings for serialization.
+         */
+        public abstract string[] permissions { owned get; }
+
+        /**
+         * Additional data to embed in the token.
+         * Implementation-specific data (e.g., roles, preferences).
+         * Returns a Variant that can be serialized to JSON.
+         */
+        public abstract Variant data { owned get; }
+    }
+}

+ 31 - 0
src/Authorisation/IdentityProvider.vala

@@ -0,0 +1,31 @@
+namespace Spry.Authorisation {
+
+    /**
+     * Interface for retrieving Identity objects by ID or username.
+     * 
+     * The AuthorisationContext uses this to get_current_identity_async().
+     * Applications register their implementation during startup.
+     * 
+     * Built-in implementation: Spry.Authentication.UserIdentityProvider
+     */
+    public interface IdentityProvider : GLib.Object {
+
+        /**
+         * Retrieves an Identity by its unique ID.
+         * 
+         * @param id The identity ID from the token
+         * @return The Identity, or null if not found/inactive
+         * @throws Error on retrieval failure
+         */
+        public abstract async Identity? get_identity_by_id(string id) throws Error;
+
+        /**
+         * Retrieves an Identity by its username.
+         * 
+         * @param username The username to look up
+         * @return The Identity, or null if not found/inactive
+         * @throws Error on retrieval failure
+         */
+        public abstract async Identity? get_identity_by_username(string username) throws Error;
+    }
+}

+ 143 - 0
src/Authorisation/PermissionMatcher.vala

@@ -0,0 +1,143 @@
+namespace Spry.Authorisation {
+
+    /**
+     * Permission wildcard matching utility.
+     * 
+     * Supports wildcard patterns where `*` matches any suffix:
+     * - `users.*` matches `users.read`, `users.write`, `users.delete`
+     * - `admin.*` matches `admin.users.read`, `admin.settings.edit`
+     * - `*` matches everything
+     * - `admin` is a super-user permission that matches everything
+     * 
+     * This is a static utility class with no state.
+     */
+    public class PermissionMatcher : GLib.Object {
+
+        /**
+         * Checks if a permission pattern matches a specific permission.
+         * 
+         * Wildcard matching rules:
+         * - "admin" matches everything (super-user)
+         * - "*" matches everything
+         * - "prefix*" matches any permission starting with "prefix"
+         * - Without wildcard, requires exact match
+         * 
+         * Examples:
+         * - matches("admin", "anything") → true
+         * - matches("*", "anything") → true
+         * - matches("users.*", "users.read") → true
+         * - matches("users.*", "users.write") → true
+         * - matches("users.*", "admin") → false
+         * - matches("users.read", "users.read") → true
+         * - matches("users.read", "users.write") → false
+         * 
+         * @param pattern The pattern to match against (may contain wildcard)
+         * @param permission The permission to check
+         * @return true if the pattern matches the permission
+         */
+        public static bool matches(string pattern, string permission) {
+            // "admin" is the super-user permission - matches everything
+            if (pattern == "admin") {
+                return true;
+            }
+
+            // "*" matches everything
+            if (pattern == "*") {
+                return true;
+            }
+
+            // Check for trailing wildcard
+            if (pattern.has_suffix("*")) {
+                // Get the prefix before the wildcard
+                string prefix = pattern.substring(0, pattern.length - 1);
+
+                // Check if permission starts with the prefix
+                return permission.has_prefix(prefix);
+            }
+
+            // No wildcard - exact match required
+            return pattern == permission;
+        }
+
+        /**
+         * Checks if any of the patterns match the permission.
+         * 
+         * @param patterns Array of patterns to check
+         * @param permission The permission to check
+         * @return true if any pattern matches
+         */
+        public static bool any_matches(string[] patterns, string permission) {
+            foreach (var pattern in patterns) {
+                if (matches(pattern, permission)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Checks if all permissions are matched by at least one pattern.
+         * 
+         * @param patterns Array of patterns to check
+         * @param permissions Array of permissions to verify
+         * @return true if all permissions are covered by at least one pattern
+         */
+        public static bool all_matched(string[] patterns, string[] permissions) {
+            foreach (var permission in permissions) {
+                if (!any_matches(patterns, permission)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        /**
+         * Finds all permissions that match a pattern.
+         * 
+         * @param pattern The pattern to match against
+         * @param permissions Array of permissions to filter
+         * @return Array of matching permissions
+         */
+        public static string[] filter_matching(string pattern, string[] permissions) {
+            var matching = new string[0];
+            foreach (var permission in permissions) {
+                if (matches(pattern, permission)) {
+                    matching += permission;
+                }
+            }
+            return matching;
+        }
+
+        /**
+         * Checks if a pattern contains a wildcard.
+         * 
+         * @param pattern The pattern to check
+         * @return true if the pattern contains a wildcard
+         */
+        public static bool has_wildcard(string pattern) {
+            return pattern.contains("*");
+        }
+
+        /**
+         * Checks if a pattern is the super-user "admin" pattern.
+         * 
+         * @param pattern The pattern to check
+         * @return true if the pattern is "admin"
+         */
+        public static bool is_admin(string pattern) {
+            return pattern == "admin";
+        }
+
+        /**
+         * Checks if a pattern matches everything.
+         * 
+         * This is true for "admin" and "*" patterns.
+         * 
+         * @param pattern The pattern to check
+         * @return true if the pattern matches everything
+         */
+        public static bool matches_everything(string pattern) {
+            return pattern == "admin" || pattern == "*";
+        }
+    }
+}

+ 23 - 0
src/Authorisation/meson.build

@@ -0,0 +1,23 @@
+authorisation_sources = files(
+    'Identity.vala',
+    'IdentityProvider.vala',
+    'AuthorisationToken.vala',
+    'AuthorisationTokenService.vala',
+    'AuthorisationContext.vala',
+    'AuthorisationService.vala',
+    'PermissionMatcher.vala',
+    'AuthorisationError.vala'
+)
+
+libspry_authorisation = static_library('spry-authorisation',
+    authorisation_sources,
+    dependencies: [spry_dep, inversion_dep, astralis_dep, json_glib_dep, sodium_deps],
+    include_directories: include_directories('..')
+)
+
+spry_authorisation_inc = include_directories('.')
+spry_authorisation_dep = declare_dependency(
+    link_with: libspry_authorisation,
+    include_directories: spry_authorisation_inc,
+    dependencies: [spry_dep, inversion_dep, astralis_dep]
+)

+ 2 - 2
src/Component.vala

@@ -477,8 +477,8 @@ namespace Spry {
             var result = expression.evaluate(context);
 
             bool boolean_result;
-            if(result.is<bool?>()) {
-                boolean_result = result.as<bool?>();
+            if(result.is<bool>()) {
+                boolean_result = result.as<bool>();
             }
             else if(result.is<int>()) {
                 boolean_result = result.as<int>() != 0;

+ 2 - 1
src/ComponentEndpoint.vala

@@ -31,7 +31,8 @@ namespace Spry {
                 try {
                     context = cryptograpy_provider.read_component_context_blob (context_blob);
                 }
-                catch {
+                catch(Error e) {
+                    warning(@"Invalid component context: $(e.message)");
                     return new HttpStringResult ("Invalid component context", StatusCode.BAD_REQUEST);
                 }
                 if(context.type_name != component.get_type().name()) {

+ 1 - 1
src/CryptographyProvider.vala

@@ -77,7 +77,7 @@ namespace Spry {
             var mapper = ComponentContext.get_mapper();
             var properties = mapper.map_from(context);
             var json = new JsonElement.from_properties(properties);
-            var blob = json.stringify(false);
+            var blob = json.stringify();
             var signed = Sodium.Asymmetric.Signing.sign(blob.data, signing_secret_key);
             var @sealed = Sodium.Asymmetric.Sealing.seal(signed, sealing_public_key);
             return Base64.encode(@sealed).replace("+", "-").replace("/", "_");

+ 3 - 0
src/meson.build

@@ -50,3 +50,6 @@ spry_dep = declare_dependency(
     include_directories: include_directories('.'),
     dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, inversion_dep, libxml_dep]
 )
+
+# Authorisation submodule
+subdir('Authorisation')