Explorar el Código

feat: add expression evaluation and declarative components to template system

Add comprehensive expression attribute support (*-expr) for dynamic content,
conditional rendering (spry-if/else-if/else), and loop iteration (spry-per-*).
Introduce ContinuationContext for improved SSE handling, ResponseState for
HTTP response control, and declarative child components via <spry-component>.

Key additions:
- Expression attributes: content-expr, class-expr, style-*-expr, etc.
- Conditional rendering with spry-if, spry-else-if, spry-else
- Loop rendering with spry-per-{varname}="expression"
- spry-unique for stable element IDs in dynamic content
- spry-dynamic for SSE-updatable template sections
- get_component_child<T>() for accessing declarative child components
- PageComponent base class combining Component and Endpoint
- SpryConfigurator for structured component registration
- prepare_once() for one-time initialization before prepare()
- CryptographyProvider for secure component context serialization
- Authentication and Authorisation submodules

BREAKING CHANGE: continuation() method signature changed from
SseStream to ContinuationContext parameter. Update implementations to use
continuation_context.send_dynamic(name) instead of stream.send_event().
Billy Barrow hace 1 mes
padre
commit
13c9f429a0
Se han modificado 86 ficheros con 20475 adiciones y 816 borrados
  1. 0 3
      .dockerignore
  2. 1 0
      .gitignore
  3. 17 8
      Dockerfile
  4. 33 0
      demo/Components/CodeBlockComponent.vala
  5. 128 0
      demo/Components/DemoHostComponent.vala
  6. 86 0
      demo/Components/NavSidebarComponent.vala
  7. 106 0
      demo/DemoComponents/ProgressDemo.vala
  8. 53 0
      demo/DemoComponents/SimpleCounterDemo.vala
  9. 178 0
      demo/DemoComponents/TodoListDemo.vala
  10. 83 0
      demo/Main.vala
  11. 49 0
      demo/MainTemplate.vala
  12. 316 0
      demo/Pages/ComponentsActionsPage.vala
  13. 266 0
      demo/Pages/ComponentsContinuationsPage.vala
  14. 268 0
      demo/Pages/ComponentsOutletsPage.vala
  15. 228 0
      demo/Pages/ComponentsOverviewPage.vala
  16. 329 0
      demo/Pages/ComponentsTemplateSyntaxPage.vala
  17. 118 0
      demo/Pages/HomePage.vala
  18. 267 0
      demo/Pages/PageComponentsOverviewPage.vala
  19. 419 0
      demo/Pages/PageTemplatesPage.vala
  20. 414 0
      demo/Pages/StaticResourcesMkssrPage.vala
  21. 259 0
      demo/Pages/StaticResourcesOverviewPage.vala
  22. 1190 0
      demo/Static/docs.css
  23. 40 0
      demo/meson.build
  24. 23 21
      examples/ProgressExample.vala
  25. 46 73
      examples/TodoComponent.vala
  26. 1043 0
      examples/UsersExample.vala
  27. 8 0
      examples/meson.build
  28. 315 1
      important-details.md
  29. 399 0
      important-notes-on-writing-documentation-pages.md
  30. 453 0
      invercargill-format-migration-details.md
  31. 10 1
      meson.build
  32. 1324 0
      plans/auth-refactor-architecture.md
  33. 658 0
      plans/authentication-implexus-analysis.md
  34. 1258 0
      plans/invercargill-sql-migration-plan.md
  35. 406 0
      plans/response-state-design.md
  36. 804 0
      plans/user-management-component-redesign.md
  37. BIN
      spry_auth.db
  38. 1878 0
      src/Authentication/ARCHITECTURE.md
  39. 267 0
      src/Authentication/Components/LoginFormComponent.vala
  40. 347 0
      src/Authentication/Components/NewUserComponent.vala
  41. 507 0
      src/Authentication/Components/UserDetailsComponent.vala
  42. 223 0
      src/Authentication/Components/UserManagementComponent.vala
  43. 93 0
      src/Authentication/CreateAuthTables.vala
  44. 258 0
      src/Authentication/PermissionService.vala
  45. 98 0
      src/Authentication/Session.vala
  46. 83 0
      src/Authentication/SessionRepository.vala
  47. 578 0
      src/Authentication/SessionService.vala
  48. 189 0
      src/Authentication/SqlSessionRepository.vala
  49. 401 0
      src/Authentication/SqlUserRepository.vala
  50. 291 0
      src/Authentication/User.vala
  51. 62 0
      src/Authentication/UserIdentityProvider.vala
  52. 160 0
      src/Authentication/UserRepository.vala
  53. 401 0
      src/Authentication/UserService.vala
  54. 30 0
      src/Authentication/meson.build
  55. 272 0
      src/Authorisation/AuthorisationContext.vala
  56. 35 0
      src/Authorisation/AuthorisationError.vala
  57. 340 0
      src/Authorisation/AuthorisationService.vala
  58. 281 0
      src/Authorisation/AuthorisationToken.vala
  59. 223 0
      src/Authorisation/AuthorisationTokenService.vala
  60. 41 0
      src/Authorisation/Identity.vala
  61. 31 0
      src/Authorisation/IdentityProvider.vala
  62. 143 0
      src/Authorisation/PermissionMatcher.vala
  63. 23 0
      src/Authorisation/meson.build
  64. 445 39
      src/Component.vala
  65. 26 0
      src/ComponentEndpoint.vala
  66. 14 1
      src/ComponentFactory.vala
  67. 49 0
      src/ContinuationContext.vala
  68. 2 1
      src/ContinuationProvider.vala
  69. 205 0
      src/CryptographyProvider.vala
  70. 0 2
      src/PageComponent.vala
  71. 6 0
      src/PathProvider.vala
  72. 209 0
      src/ResponseState.vala
  73. 14 1
      src/Spry.vala
  74. 7 1
      src/meson.build
  75. 1 1
      tools/spry-mkssr/meson.build
  76. 0 135
      vapi/libbrotlienc.vapi
  77. 0 159
      vapi/libmicrohttpd.vapi
  78. 284 0
      vapi/libsodium.vapi
  79. 0 286
      vapi/libzstd.vapi
  80. 85 0
      website/Endpoints/AuroraActionEndpoint.vala
  81. 114 0
      website/Endpoints/AuroraSseEndpoint.vala
  82. 81 0
      website/Endpoints/CounterSseEndpoint.vala
  83. 35 7
      website/Main.vala
  84. 43 73
      website/Pages/DemoPage.vala
  85. 2 1
      website/Templates/SiteLayoutTemplate.vala
  86. 3 2
      website/meson.build

+ 0 - 3
.dockerignore

@@ -16,9 +16,6 @@ builddir/
 *.md
 !README.md
 
-# VAPI files (not needed at runtime, only build)
-vapi/
-
 # Any compiled objects
 *.o
 *.a

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+data/

+ 17 - 8
Dockerfile

@@ -1,4 +1,4 @@
-# Multi-stage Dockerfile for Spry Framework Website
+# Multi-stage Dockerfile for Spry Framework Demo Website
 # Build stage: Compile the Vala application
 FROM fedora:43 AS builder
 
@@ -17,17 +17,18 @@ RUN dnf install -y \
     libgee-devel \
     brotli-devel \
     libmicrohttpd-devel \
+    libsodium-devel \
     git \
     pkg-config \
     && dnf clean all
 
 # Clone and build invercargill library
 WORKDIR /build
-RUN git clone https://fabrica.unitatem.net/Tilo15/Invercargill.git invercargill && \
+RUN git clone -b expressions https://fabrica.unitatem.net/Tilo15/Invercargill.git invercargill && \
     cd invercargill && \
     meson setup src builddir --prefix=/usr && \
     ninja -C builddir && \
-    ninja -C builddir install
+    ninja -C builddir install && echo hi
 
 # Clone and build invercargill-json library
 RUN git clone https://git.sr.ht/~tilo15/Invercargill-Json invercargill-json && \
@@ -48,12 +49,12 @@ RUN git clone https://fabrica.unitatem.net/Tilo15/astralis.git astralis && \
     cd astralis && \
     meson setup builddir --prefix=/usr && \
     ninja -C builddir && \
-    ninja -C builddir install && \
+    ninja -C builddir install
 
 # Copy the Spry project source
 COPY . /build/spry
 
-# Build the Spry library and website
+# Build the Spry library and demo
 WORKDIR /build/spry
 RUN meson setup builddir --prefix=/usr && \
     ninja -C builddir && \
@@ -72,6 +73,7 @@ RUN dnf install -y \
     brotli \
     libmicrohttpd \
     libgee \
+    libsodium \
     && dnf clean all
 
 # Copy built libraries and executable from builder
@@ -81,7 +83,14 @@ COPY --from=builder /usr/lib64/libinversion*.so* /usr/lib64/
 COPY --from=builder /usr/lib64/libastralis*.so* /usr/lib64/
 COPY --from=builder /usr/lib64/libspry*.so* /usr/lib64/
 COPY --from=builder /usr/lib64/girepository-1.0/*.typelib /usr/lib64/girepository-1.0/
-COPY --from=builder /usr/bin/spry-website /usr/bin/
+COPY --from=builder /usr/bin/spry-demo /usr/bin/
+
+# Copy demo source files for DemoHostComponent to display in the demo viewer
+# These paths must match what DemoHostComponent.source_file expects
+COPY --from=builder /build/spry/demo/DemoComponents /app/demo/DemoComponents
+
+# Set working directory so relative paths in DemoHostComponent resolve correctly
+WORKDIR /app
 
 # Update library cache
 RUN ldconfig
@@ -92,5 +101,5 @@ EXPOSE 8080
 # Set environment variables
 ENV PORT=8080
 
-# Run the website
-CMD ["spry-website", "8080"]
+# Run the demo website
+CMD ["spry-demo", "8080"]

+ 33 - 0
demo/Components/CodeBlockComponent.vala

@@ -0,0 +1,33 @@
+using Spry;
+
+/**
+ * CodeBlockComponent - A styled code block with syntax highlighting
+ * 
+ * Displays code with a header showing language and window dots
+ */
+public class CodeBlockComponent : Component {
+    
+    public string language { set; get; default = "Vala"; }
+    public string code { set; get; default = ""; }
+    
+    public override string markup { get {
+        return """
+        <div class="code-block">
+            <div class="code-header">
+                <div class="code-dots">
+                    <div class="code-dot red"></div>
+                    <div class="code-dot yellow"></div>
+                    <div class="code-dot green"></div>
+                </div>
+                <span class="code-lang" sid="language"></span>
+            </div>
+            <pre><code sid="code"></code></pre>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        this["language"].text_content = language;
+        this["code"].text_content = code;
+    }
+}

+ 128 - 0
demo/Components/DemoHostComponent.vala

@@ -0,0 +1,128 @@
+using Spry;
+using Inversion;
+using Invercargill.DataStructures;
+
+/**
+ * DemoHostComponent - A component that hosts demo implementations with source/demo toggle
+ * 
+ * This component provides a frame for demo content with the ability to toggle
+ * between viewing the demo and viewing the source code.
+ * 
+ * Usage:
+ *   var host = factory.create<DemoHostComponent>();
+ *   host.demo_component_name = "SimpleCounterDemo";
+ *   host.source_file = "demo/DemoComponents/SimpleCounterDemo.vala";
+ */
+public class DemoHostComponent : Component {
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    /// Name of the demo component to create and display
+    public string demo_component_name { get; set; }
+    
+    /// Path to the source file to display (relative to project root)
+    public string source_file { get; set; default = ""; }
+    
+    /// Toggle state - true when showing source code
+    public bool showing_source { get; set; default = false; }
+    
+    /// The loaded source code content (cached after first load)
+    private string? _source_content = null;
+    
+    public override string markup { get {
+        return """
+        <spry-context property="demo_component_name"/>
+        <spry-context property="source_file"/>
+        <div class="demo-host" sid="host" hx-swap="outerHTML">
+            <div class="demo-header">
+                <span class="demo-title" sid="title">Demo</span>
+                <div class="demo-toggle-group">
+                    <button class="demo-toggle-btn" spry-action=":ShowDemo" spry-target="host" 
+                            class-active-expr="!this.showing_source">Demo</button>
+                    <button class="demo-toggle-btn" spry-action=":ShowSource" spry-target="host"
+                            class-active-expr="this.showing_source">Source</button>
+                </div>
+            </div>
+            <div class="demo-content" sid="content">
+                <div spry-if="this.showing_source">
+                    <pre class="demo-source"><code sid="source-code"></code></pre>
+                </div>
+                <div spry-else>
+                    <spry-outlet sid="demo-outlet"/>
+                </div>
+            </div>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Update the title with the demo component name
+        if (demo_component_name != null) {
+            this["title"].text_content = demo_component_name;
+        }
+        
+        // Create the demo component and add it to the outlet when showing demo
+        if (!showing_source && source_file != null) {
+            var demo = factory.create_by_name(demo_component_name);
+            var series = new Series<Renderable>();
+            series.add(demo);
+            set_outlet_children("demo-outlet", series);
+        }
+        
+        // Update the source code display if we're showing source
+        if (showing_source && _source_content != null) {
+            this["source-code"].text_content = _source_content;
+        }
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        switch (action) {
+            case "ShowDemo":
+                showing_source = false;
+                break;
+            case "ShowSource":
+                if (_source_content == null) {
+                    _source_content = yield load_source_file();
+                }
+                showing_source = true;
+                break;
+        }
+    }
+    
+    /**
+     * Load the source file from disk and escape HTML entities
+     */
+    private async string load_source_file() {
+        try {
+            var path = source_file;
+            // Try to read from the project root (relative path)
+            var file = File.new_for_path(path);
+            
+            if (!file.query_exists()) {
+                return @"Error: Source file not found: $path";
+            }
+            
+            uint8[] contents;
+            string etag_out;
+            yield file.load_contents_async(null, out contents, out etag_out);
+            
+            var source = (string)contents;
+            return escape_html(source);
+        } catch (Error e) {
+            return @"Error loading source file: $(e.message)";
+        }
+    }
+    
+    /**
+     * Escape HTML entities for safe display in <pre><code> blocks
+     */
+    private string escape_html(string input) {
+        var result = input
+            .replace("&", "&amp;")
+            .replace("<", "&lt;")
+            .replace(">", "&gt;")
+            .replace("\"", "&quot;")
+            .replace("'", "&#39;");
+        return result;
+    }
+}

+ 86 - 0
demo/Components/NavSidebarComponent.vala

@@ -0,0 +1,86 @@
+using Spry;
+
+/**
+ * NavSidebarComponent - A simple navigation sidebar for the documentation site
+ * 
+ * Features:
+ * - All items always visible (no collapsing)
+ * - Current page highlighting
+ */
+public class NavSidebarComponent : Component {
+    
+    public string current_path { get; set; default = "/"; }
+    
+    public override string markup { get {
+        return """
+        <aside class="nav-sidebar" sid="sidebar" hx-disinherit="*">
+            <!-- Home - Single link -->
+            <div class="nav-section">
+                <a href="/" class="nav-item" sid="home-link">Home</a>
+            </div>
+            
+            <!-- Components Section -->
+            <div class="nav-section">
+                <h3 class="nav-section-title">Components</h3>
+                <ul class="nav-items">
+                    <li><a href="/components/overview" class="nav-item" sid="components-overview">Overview</a></li>
+                    <li><a href="/components/template-syntax" class="nav-item" sid="components-template-syntax">Template Syntax</a></li>
+                    <li><a href="/components/actions" class="nav-item" sid="components-actions">Actions</a></li>
+                    <li><a href="/components/outlets" class="nav-item" sid="components-outlets">Outlets</a></li>
+                    <li><a href="/components/continuations" class="nav-item" sid="components-continuations">Continuations</a></li>
+                </ul>
+            </div>
+            
+            <!-- Page Components Section -->
+            <div class="nav-section">
+                <h3 class="nav-section-title">Page Components</h3>
+                <ul class="nav-items">
+                    <li><a href="/page-components/overview" class="nav-item" sid="page-components-overview">Overview</a></li>
+                    <li><a href="/page-components/templates" class="nav-item" sid="page-components-templates">Page Templates</a></li>
+                </ul>
+            </div>
+            
+            <!-- Static Resources Section -->
+            <div class="nav-section">
+                <h3 class="nav-section-title">Static Resources</h3>
+                <ul class="nav-items">
+                    <li><a href="/static-resources/overview" class="nav-item" sid="static-resources-overview">Overview</a></li>
+                    <li><a href="/static-resources/spry-mkssr" class="nav-item" sid="static-resources-spry-mkssr">Using spry-mkssr</a></li>
+                </ul>
+            </div>
+        </aside>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Highlight the current page link
+        highlight_current_link();
+    }
+    
+    private void highlight_current_link() {
+        // Get the sid for the current path and add active class
+        string? sid = get_sid_for_path(current_path);
+        if (sid != null) {
+            var link = this[sid];
+            if (link != null) {
+                link.add_class("active");
+            }
+        }
+    }
+    
+    private string? get_sid_for_path(string path) {
+        switch (path) {
+            case "/": return "home-link";
+            case "/components/overview": return "components-overview";
+            case "/components/template-syntax": return "components-template-syntax";
+            case "/components/actions": return "components-actions";
+            case "/components/outlets": return "components-outlets";
+            case "/components/continuations": return "components-continuations";
+            case "/page-components/overview": return "page-components-overview";
+            case "/page-components/templates": return "page-components-templates";
+            case "/static-resources/overview": return "static-resources-overview";
+            case "/static-resources/spry-mkssr": return "static-resources-spry-mkssr";
+            default: return null;
+        }
+    }
+}

+ 106 - 0
demo/DemoComponents/ProgressDemo.vala

@@ -0,0 +1,106 @@
+using Spry;
+using Inversion;
+
+/**
+ * ProgressDemo - A progress bar demo for the Continuations documentation
+ * 
+ * Demonstrates:
+ * - spry-continuation attribute for SSE
+ * - spry-dynamic for updatable sections
+ * - continuation() method for real-time updates
+ * - ContinuationContext API
+ */
+public class ProgressDemo : Component {
+    
+    public int percent { get; set; default = 0; }
+    public string status { get; set; default = "Ready"; }
+    public bool is_running { get; set; default = false; }
+    
+    public override string markup { get {
+        return """
+        <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("{{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("{{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;">
+                <button sid="start-btn" spry-action=":Start" spry-target="progress"
+                        class="progress-btn" style="padding: 10px 20px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: background 0.2s;">Start Task</button>
+                <button sid="reset-btn" spry-action=":Reset" spry-target="progress"
+                        class="progress-btn secondary" style="padding: 10px 20px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: background 0.2s;">Reset</button>
+            </div>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        this["status-text"].text_content = status;
+        this["percent-text"].text_content = @"$percent%";
+        
+        if (is_running) {
+            this["start-btn"].set_attribute("disabled", "disabled");
+            this["start-btn"].text_content = "Running...";
+        } else {
+            this["start-btn"].remove_attribute("disabled");
+            this["start-btn"].text_content = percent >= 100 ? "Complete!" : "Start Task";
+        }
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        switch (action) {
+            case "Start":
+                if (!is_running && percent < 100) {
+                    is_running = true;
+                    status = "Starting...";
+                }
+                break;
+            case "Reset":
+                percent = 0;
+                status = "Ready";
+                is_running = false;
+                break;
+        }
+    }
+    
+    public async override void continuation(ContinuationContext ctx) throws Error {
+        if (!is_running) return;
+        
+        for (int i = percent; i <= 100; i += 5) {
+            percent = i;
+            
+            if (i < 30) {
+                status = @"Processing... $i%";
+            } else if (i < 60) {
+                status = @"Building... $i%";
+            } else if (i < 90) {
+                status = @"Finalizing... $i%";
+            } else {
+                status = @"Almost done... $i%";
+            }
+            
+            // Send updates via SSE to the dynamic sections
+            yield ctx.send_dynamic("progress-bar");
+            yield ctx.send_dynamic("status");
+            
+            // Simulate work
+            Timeout.add(200, () => {
+                continuation.callback();
+                return false;
+            });
+            yield;
+        }
+        
+        percent = 100;
+        status = "Complete!";
+        is_running = false;
+        
+        yield ctx.send_dynamic("progress-bar");
+        yield ctx.send_dynamic("status");
+    }
+}

+ 53 - 0
demo/DemoComponents/SimpleCounterDemo.vala

@@ -0,0 +1,53 @@
+using Spry;
+using Inversion;
+
+/**
+ * SimpleCounterDemo - A basic counter demo for the Actions documentation
+ * 
+ * Demonstrates:
+ * - spry-action for same-component actions
+ * - spry-target for scoped targeting
+ * - State management via a singleton store
+ */
+public class SimpleCounterDemo : Component {
+    
+    private CounterDemoStore store = inject<CounterDemoStore>();
+    
+    public override string markup { get {
+        return """
+        <div sid="counter" class="demo-counter">
+            <div class="counter-display" sid="display">0</div>
+            <div class="counter-controls">
+                <button class="counter-btn decrement" 
+                        spry-action=":Decrement" 
+                        spry-target="counter">−</button>
+                <button class="counter-btn increment" 
+                        spry-action=":Increment" 
+                        spry-target="counter">+</button>
+            </div>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        this["display"].text_content = store.count.to_string();
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        switch (action) {
+            case "Increment":
+                store.count++;
+                break;
+            case "Decrement":
+                store.count--;
+                break;
+        }
+    }
+}
+
+/**
+ * CounterDemoStore - Singleton store to persist counter state
+ */
+public class CounterDemoStore : Object {
+    public int count { get; set; default = 0; }
+}

+ 178 - 0
demo/DemoComponents/TodoListDemo.vala

@@ -0,0 +1,178 @@
+using Spry;
+using Astralis;
+using Inversion;
+using Invercargill.DataStructures;
+
+/**
+ * TodoListDemo - A todo list demo for the Outlets documentation
+ * 
+ * Demonstrates:
+ * - Using spry-outlet for dynamic child components
+ * - set_outlet_children() to populate outlets
+ * - Parent/child component composition
+ */
+public class TodoListDemo : Component {
+    
+    private TodoDemoStore store = inject<TodoDemoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    private HttpContext http_context = inject<HttpContext>();
+    
+    public override string markup { get {
+        return """
+        <div sid="todo-list" class="demo-todo-list">
+            <div class="todo-header">
+                <h3>Todo List</h3>
+                <span class="todo-count" sid="count"></span>
+            </div>
+            <form class="todo-form" spry-action=":Add" spry-target="todo-list">
+                <input type="text" name="title" placeholder="Add a task..." sid="input"/>
+                <button type="submit" class="todo-add-btn">Add</button>
+            </form>
+            <ul class="todo-items" sid="items">
+                <spry-outlet sid="items-outlet"/>
+            </ul>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Update count
+        this["count"].text_content = @"$(store.items.length) items";
+        
+        // Build list items
+        var children = new Series<Renderable>();
+        foreach (var item in store.items) {
+            var component = factory.create<TodoItemDemo>();
+            component.item_id = item.id;
+            children.add(component);
+        }
+        set_outlet_children("items-outlet", children);
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        if (action == "Add") {
+            var title = http_context.request.query_params.get_any_or_default("title");
+            if (title != null && title.strip().length > 0) {
+                store.add(title.strip());
+            }
+        }
+    }
+}
+
+/**
+ * TodoItemDemo - Individual todo item component
+ */
+public class TodoItemDemo : Component {
+    
+    private TodoDemoStore store = inject<TodoDemoStore>();
+    private HttpContext http_context = inject<HttpContext>();
+    
+    public int item_id { set; get; }
+    
+    public override string markup { get {
+        return """
+        <li sid="item" class="todo-item">
+            <span sid="title" class="todo-title"></span>
+            <button sid="toggle" spry-action=":Toggle" spry-target="item" 
+                    class="todo-toggle"></button>
+            <button sid="delete" spry-action=":Delete" spry-target="item"
+                    hx-swap="delete" class="todo-delete">×</button>
+        </li>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // If item_id is not set (fresh instance from action), try to get it from query params
+        if (item_id == 0) {
+            var id_str = http_context.request.query_params.get_any_or_default("id");
+            if (id_str != null) {
+                item_id = int.parse(id_str);
+            }
+        }
+        
+        var item = store.get_by_id(item_id);
+        if (item == null) return;
+        
+        this["title"].text_content = item.title;
+        this["item"].set_attribute("hx-vals", @"{\"id\":$item_id}");
+        
+        if (item.completed) {
+            this["item"].add_class("completed");
+            this["toggle"].text_content = "↩";
+        } else {
+            this["toggle"].text_content = "✓";
+        }
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        var id_str = http_context.request.query_params.get_any_or_default("id");
+        var id = int.parse(id_str);
+        
+        switch (action) {
+            case "Toggle":
+                store.toggle(id);
+                break;
+            case "Delete":
+                store.remove(id);
+                break;
+        }
+    }
+}
+
+/**
+ * TodoDemoStore - Singleton store for todo items
+ */
+public class TodoDemoStore : Object {
+    
+    public Series<TodoDemoItem> items { get; set; default = new Series<TodoDemoItem>(); }
+    private int _next_id = 1;
+    
+    public TodoDemoStore() {
+        // Add some initial items
+        add("Learn Spry components");
+        add("Build a web app");
+        add("Deploy to production");
+    }
+    
+    public void add(string title) {
+        items.add(new TodoDemoItem(_next_id++, title));
+    }
+    
+    public TodoDemoItem? get_by_id(int id) {
+        foreach (var item in items) {
+            if (item.id == id) return item;
+        }
+        return null;
+    }
+    
+    public void toggle(int id) {
+        var item = get_by_id(id);
+        if (item != null) {
+            item.completed = !item.completed;
+        }
+    }
+    
+    public void remove(int id) {
+        // Build a new list without the item
+        var new_items = new Series<TodoDemoItem>();
+        foreach (var item in items) {
+            if (item.id != id) {
+                new_items.add(item);
+            }
+        }
+        items = new_items;
+    }
+}
+
+/**
+ * TodoDemoItem - Single todo item data
+ */
+public class TodoDemoItem : Object {
+    public int id { get; construct; }
+    public string title { get; construct set; }
+    public bool completed { get; set; default = false; }
+    
+    public TodoDemoItem(int id, string title) {
+        Object(id: id, title: title);
+    }
+}

+ 83 - 0
demo/Main.vala

@@ -0,0 +1,83 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Spry;
+using Demo.Static;
+
+void main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+    
+    try {
+        var application = new WebApplication(port);
+        
+        // Enable compression
+        application.use_compression();
+        
+        // Add Spry module for component actions
+        application.add_module<SpryModule>();
+        
+        // Configure templates
+        var spry_cfg = application.configure_with<SpryConfigurator>();
+        spry_cfg.add_template<MainTemplate>("");
+
+        // Register static resources
+        application.container.register_startup<DocsCssResource>()
+            .as<Spry.StaticResource>();
+
+        // Register demo stores (singletons for state persistence)
+        application.add_singleton<CounterDemoStore>();
+        application.add_singleton<TodoDemoStore>();
+        
+        // Add Components
+        application.add_transient<CodeBlockComponent>();
+        application.add_transient<DemoHostComponent>();
+        application.add_transient<NavSidebarComponent>();
+        
+        // Add Demo Components
+        application.add_transient<SimpleCounterDemo>();
+        application.add_transient<TodoListDemo>();
+        application.add_transient<TodoItemDemo>();
+        application.add_transient<ProgressDemo>();
+        
+        // Register page components
+        application.add_transient<HomePage>();
+        application.add_endpoint<HomePage>(new EndpointRoute("/"));
+        
+        // Components documentation pages
+        application.add_transient<ComponentsOverviewPage>();
+        application.add_endpoint<ComponentsOverviewPage>(new EndpointRoute("/components/overview"));
+        
+        application.add_transient<ComponentsTemplateSyntaxPage>();
+        application.add_endpoint<ComponentsTemplateSyntaxPage>(new EndpointRoute("/components/template-syntax"));
+        
+        application.add_transient<ComponentsActionsPage>();
+        application.add_endpoint<ComponentsActionsPage>(new EndpointRoute("/components/actions"));
+        
+        application.add_transient<ComponentsOutletsPage>();
+        application.add_endpoint<ComponentsOutletsPage>(new EndpointRoute("/components/outlets"));
+        
+        application.add_transient<ComponentsContinuationsPage>();
+        application.add_endpoint<ComponentsContinuationsPage>(new EndpointRoute("/components/continuations"));
+        
+        // Page Components documentation pages
+        application.add_transient<PageComponentsOverviewPage>();
+        application.add_endpoint<PageComponentsOverviewPage>(new EndpointRoute("/page-components/overview"));
+        
+        application.add_transient<PageTemplatesPage>();
+        application.add_endpoint<PageTemplatesPage>(new EndpointRoute("/page-components/templates"));
+        
+        // Static Resources documentation pages
+        application.add_transient<StaticResourcesOverviewPage>();
+        application.add_endpoint<StaticResourcesOverviewPage>(new EndpointRoute("/static-resources/overview"));
+        
+        application.add_transient<StaticResourcesMkssrPage>();
+        application.add_endpoint<StaticResourcesMkssrPage>(new EndpointRoute("/static-resources/spry-mkssr"));
+        
+        application.run();
+        
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        Process.exit(1);
+    }
+}

+ 49 - 0
demo/MainTemplate.vala

@@ -0,0 +1,49 @@
+using Astralis;
+using Spry;
+using Inversion;
+
+/**
+ * MainTemplate - The base template for documentation pages
+ * 
+ * Provides a clean, minimal HTML structure for documentation:
+ * - HTML5 document structure
+ * - NavSidebarComponent for navigation
+ * - docs.css stylesheet for documentation styling
+ * - htmx.js and htmx-sse.js for interactive components
+ * - Template outlet for page content
+ */
+public class MainTemplate : PageTemplate {
+    
+    private NavSidebarComponent nav;
+    private RouteContext route_context = inject<RouteContext>();
+    
+    public override string markup { get {
+        return """
+        <!DOCTYPE html>
+        <html lang="en">
+        <head>
+            <meta charset="utf-8">
+            <meta name="viewport" content="width=device-width, initial-scale=1">
+            <title>Spry Documentation</title>
+            <link rel="stylesheet" spry-res="docs.css">
+        </head>
+        <body>
+            <div class="page-container">
+                <spry-component name="NavSidebarComponent" sid="nav"/>
+                <main class="main-content">
+                    <spry-template-outlet/>
+                </main>
+            </div>
+            <script spry-res="htmx.js"></script>
+            <script spry-res="htmx-sse.js"></script>
+        </body>
+        </html>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Get the nav component and set the current path from the route context
+        nav = get_component_child<NavSidebarComponent>("nav");
+        nav.current_path = route_context.requested_path;
+    }
+}

+ 316 - 0
demo/Pages/ComponentsActionsPage.vala

@@ -0,0 +1,316 @@
+using Spry;
+using Inversion;
+
+/**
+ * ComponentsActionsPage - Documentation for Spry component actions
+ * 
+ * This page covers how actions work, the spry-action attribute syntax,
+ * and how to handle user interactions in components.
+ */
+public class ComponentsActionsPage : PageComponent {
+    
+    public const string ROUTE = "/components/actions";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>Actions</h1>
+            
+            <section class="doc-section">
+                <p>
+                    Actions are the primary way to handle user interactions in Spry. When a user
+                    clicks a button, submits a form, or triggers any HTMX event, an action is sent
+                    to the server and handled by your component.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>How Actions Work</h2>
+                <p>
+                    The flow of an action request:
+                </p>
+                <ol>
+                    <li>User interacts with an element that has <code>spry-action</code></li>
+                    <li>HTMX sends a request to the Spry action endpoint</li>
+                    <li>Spry creates a <strong>new</strong> component instance and calls <code>handle_action()</code></li>
+                    <li>Your component modifies state in stores (not instance variables!)</li>
+                    <li>Spry calls <code>prepare()</code> to update the template from store data</li>
+                    <li>The updated HTML is sent back and swapped into the page</li>
+                </ol>
+                
+                <div class="warning-box">
+                    <p>
+                        <strong>⚠️ Critical:</strong> A <strong>new component instance</strong> is created for each action request.
+                        Any instance variables (like <code>is_on</code>, <code>count</code>) will NOT retain their values.
+                        Always store state in singleton stores, not component instance fields.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The <code>spry-action</code> Attribute</h2>
+                <p>
+                    The <code>spry-action</code> attribute specifies which action to trigger. There are
+                    two syntaxes depending on whether you're targeting the same component or a different one.
+                </p>
+                
+                <h3>Same-Component Actions</h3>
+                <p>
+                    Use <code>:ActionName</code> to trigger an action on the same component type that contains
+                    the element.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="same-action-html"/>
+                <spry-component name="CodeBlockComponent" sid="same-action-vala"/>
+                
+                <h3>Cross-Component Actions</h3>
+                <p>
+                    Use <code>ComponentName:ActionName</code> to trigger an action on a different component type.
+                    This is useful when an interaction in one component should update another.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="cross-action-html"/>
+                <spry-component name="CodeBlockComponent" sid="cross-action-vala"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The <code>spry-target</code> Attribute</h2>
+                <p>
+                    The <code>spry-target</code> attribute specifies which element should be replaced
+                    when the action completes. It uses the <code>sid</code> of an element within the
+                    same component.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="target-example"/>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Tip:</strong> <code>spry-target</code> only works within the same component.
+                        For cross-component targeting, use <code>hx-target="#element-id"</code> with a
+                        global <code>id</code> attribute.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Passing Data with <code>hx-vals</code></h2>
+                <p>
+                    Since a new component instance is created for each request, you need to pass data
+                    explicitly using <code>hx-vals</code>. This data becomes available in query parameters,
+                    allowing you to identify which item to operate on.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="hx-vals-html"/>
+                <spry-component name="CodeBlockComponent" sid="hx-vals-vala"/>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Tip:</strong> Set <code>hx-vals</code> on a parent element - it will be
+                        inherited by all child elements. This avoids repetition and keeps your code clean.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Swap Strategies</h2>
+                <p>
+                    Control how the response is inserted with <code>hx-swap</code>:
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Value</th>
+                            <th>Description</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td><code>outerHTML</code></td>
+                            <td>Replace the entire target element (default for most cases)</td>
+                        </tr>
+                        <tr>
+                            <td><code>innerHTML</code></td>
+                            <td>Replace only the content inside the target</td>
+                        </tr>
+                        <tr>
+                            <td><code>delete</code></td>
+                            <td>Remove the target element (no response content needed)</td>
+                        </tr>
+                        <tr>
+                            <td><code>beforebegin</code></td>
+                            <td>Insert content before the target element</td>
+                        </tr>
+                        <tr>
+                            <td><code>afterend</code></td>
+                            <td>Insert content after the target element</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Complete Example: Toggle with Store</h2>
+                <p>
+                    Here's a complete example showing proper state management using a store.
+                    The store is a singleton that persists state across requests:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="store-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Live Demo: Counter</h2>
+                <p>
+                    Try this interactive counter to see actions in action. The counter state
+                    is stored in a singleton store, so it persists between requests:
+                </p>
+                
+                <spry-component name="DemoHostComponent" sid="counter-demo"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/components/outlets" class="nav-card">
+                        <h3>Outlets →</h3>
+                        <p>Compose components with outlets</p>
+                    </a>
+                    <a href="/components/continuations" class="nav-card">
+                        <h3>Continuations →</h3>
+                        <p>Real-time updates with SSE</p>
+                    </a>
+                    <a href="/components/template-syntax" class="nav-card">
+                        <h3>Template Syntax ←</h3>
+                        <p>Review template attributes</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Same-component action examples
+        var same_action_html = get_component_child<CodeBlockComponent>("same-action-html");
+        same_action_html.language = "HTML";
+        same_action_html.code = "<div sid=\"item\">\n" +
+            "    <span sid=\"status\">Off</span>\n" +
+            "    <button spry-action=\":Toggle\" spry-target=\"item\">Toggle</button>\n" +
+            "</div>";
+        
+        var same_action_vala = get_component_child<CodeBlockComponent>("same-action-vala");
+        same_action_vala.language = "Vala";
+        same_action_vala.code = "public async override void handle_action(string action) throws Error {\n" +
+            "    // Get item ID from hx-vals (passed via query params)\n" +
+            "    var id = get_id_from_query_params();\n\n" +
+            "    if (action == \"Toggle\") {\n" +
+            "        // Modify state in the STORE, not instance variables!\n" +
+            "        store.toggle(id);\n" +
+            "    }\n" +
+            "    // prepare() is called automatically to update template from store\n" +
+            "}";
+        
+        // Cross-component action examples
+        var cross_action_html = get_component_child<CodeBlockComponent>("cross-action-html");
+        cross_action_html.language = "HTML";
+        cross_action_html.code = "<!-- In AddFormComponent -->\n" +
+            "<form spry-action=\"TodoList:Add\" hx-target=\"#todo-list\" hx-swap=\"outerHTML\">\n" +
+            "    <input name=\"title\" type=\"text\"/>\n" +
+            "    <button type=\"submit\">Add</button>\n" +
+            "</form>";
+        
+        var cross_action_vala = get_component_child<CodeBlockComponent>("cross-action-vala");
+        cross_action_vala.language = "Vala";
+        cross_action_vala.code = "// In TodoListComponent\n" +
+            "public async override void handle_action(string action) throws Error {\n" +
+            "    if (action == \"Add\") {\n" +
+            "        var title = http_context.request.query_params.get_any_or_default(\"title\");\n" +
+            "        // Modify state in the store\n" +
+            "        store.add(title);\n" +
+            "    }\n" +
+            "    // prepare() rebuilds the list from store data\n" +
+            "}";
+        
+        // Target example
+        var target_example = get_component_child<CodeBlockComponent>("target-example");
+        target_example.language = "HTML";
+        target_example.code = "<div sid=\"item\" class=\"item\">\n" +
+            "    <span sid=\"title\">Item Title</span>\n" +
+            "    <button spry-action=\":Delete\" spry-target=\"item\" hx-swap=\"delete\">\n" +
+            "        Delete\n" +
+            "    </button>\n" +
+            "</div>";
+        
+        // hx-vals examples
+        var hx_vals_html = get_component_child<CodeBlockComponent>("hx-vals-html");
+        hx_vals_html.language = "HTML";
+        hx_vals_html.code = "<div sid=\"item\" hx-vals='{\"id\": 42}'>\n" +
+            "    <!-- Children inherit hx-vals -->\n" +
+            "    <button spry-action=\":Toggle\" spry-target=\"item\">Toggle</button>\n" +
+            "    <button spry-action=\":Delete\" spry-target=\"item\">Delete</button>\n" +
+            "</div>";
+        
+        var hx_vals_vala = get_component_child<CodeBlockComponent>("hx-vals-vala");
+        hx_vals_vala.language = "Vala";
+        hx_vals_vala.code = "public async override void handle_action(string action) throws Error {\n" +
+            "    // Get the id from query params (passed via hx-vals)\n" +
+            "    var id_str = http_context.request.query_params.get_any_or_default(\"id\");\n" +
+            "    var id = int.parse(id_str);\n\n" +
+            "    // Use the id to find and modify the item IN THE STORE\n" +
+            "    switch (action) {\n" +
+            "        case \"Toggle\":\n" +
+            "            store.toggle(id);\n" +
+            "            break;\n" +
+            "        case \"Delete\":\n" +
+            "            store.remove(id);\n" +
+            "            break;\n" +
+            "    }\n" +
+            "}";
+        
+        // Complete example with store
+        var store_example = get_component_child<CodeBlockComponent>("store-example");
+        store_example.language = "Vala";
+        store_example.code = "// First, define a store (singleton) to hold state\n" +
+            "public class ToggleStore : Object {\n" +
+            "    private bool _is_on = false;\n" +
+            "    public bool is_on { get { return _is_on; } }\n\n" +
+            "    public void toggle() {\n" +
+            "        _is_on = !_is_on;\n" +
+            "    }\n" +
+            "}\n\n" +
+            "// Register as singleton in your app:\n" +
+            "// application.add_singleton<ToggleStore>();\n\n" +
+            "// Then use it in your component:\n" +
+            "public class ToggleComponent : Component {\n" +
+            "    private ToggleStore store = inject<ToggleStore>();  // Inject singleton\n\n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <div sid=\"toggle\" class=\"toggle-container\">\n" +
+            "            <span sid=\"status\" class=\"status\"></span>\n" +
+            "            <button sid=\"btn\" spry-action=\":Toggle\" spry-target=\"toggle\">\n" +
+            "            </button>\n" +
+            "        </div>\n" +
+            "        \"\"\";\n" +
+            "    }}\n\n" +
+            "    public override void prepare() throws Error {\n" +
+            "        // Read state from store\n" +
+            "        this[\"status\"].text_content = store.is_on ? \"ON\" : \"OFF\";\n" +
+            "        this[\"btn\"].text_content = store.is_on ? \"Turn Off\" : \"Turn On\";\n" +
+            "    }\n\n" +
+            "    public async override void handle_action(string action) throws Error {\n" +
+            "        if (action == \"Toggle\") {\n" +
+            "            // Modify state in the store - this persists!\n" +
+            "            store.toggle();\n" +
+            "        }\n" +
+            "    }\n" +
+            "}";
+        
+        // Set up the counter demo
+        var demo = get_component_child<DemoHostComponent>("counter-demo");
+        demo.demo_component_name = "SimpleCounterDemo";
+        demo.source_file = "demo/DemoComponents/SimpleCounterDemo.vala";
+    }
+}

+ 266 - 0
demo/Pages/ComponentsContinuationsPage.vala

@@ -0,0 +1,266 @@
+using Spry;
+using Inversion;
+
+/**
+ * ComponentsContinuationsPage - Documentation for Spry continuations (SSE)
+ * 
+ * This page covers what continuations are, the spry-continuation attribute,
+ * and how to send real-time updates to clients.
+ */
+public class ComponentsContinuationsPage : PageComponent {
+    
+    public const string ROUTE = "/components/continuations";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>Continuations</h1>
+            
+            <section class="doc-section">
+                <p>
+                    Continuations enable real-time updates from server to client using Server-Sent Events (SSE).
+                    They're perfect for progress indicators, live status updates, and any scenario where
+                    the server needs to push updates to the client over time.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>What are Continuations?</h2>
+                <p>
+                    A continuation is a long-running server process that can send multiple updates
+                    to the client over a single HTTP connection. Unlike regular requests that return
+                    once, continuations can push updates as they happen.
+                </p>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Use Cases:</strong> Progress bars, build status, live logs,
+                        real-time notifications, file upload progress, long-running task monitoring.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The <code>spry-continuation</code> Attribute</h2>
+                <p>
+                    Add <code>spry-continuation</code> to an element to enable SSE for its children.
+                    This attribute is shorthand for:
+                </p>
+                <ul>
+                    <li><code>hx-ext="sse"</code> - Enable HTMX SSE extension</li>
+                    <li><code>sse-connect="(auto-endpoint)"</code> - Connect to SSE endpoint</li>
+                    <li><code>sse-close="_spry-close"</code> - Close event name</li>
+                </ul>
+                
+                <spry-component name="CodeBlockComponent" sid="continuation-attr"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The <code>spry-dynamic</code> Attribute</h2>
+                <p>
+                    Mark child elements with <code>spry-dynamic="name"</code> to make them updatable.
+                    When you call <code>send_dynamic("name")</code>, that element's HTML is sent to
+                    the client and swapped in place.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="dynamic-attr"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The <code>continuation()</code> Method</h2>
+                <p>
+                    Override <code>continuation(ContinuationContext context)</code> in your component
+                    to implement long-running processes that send updates.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="continuation-method"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>ContinuationContext API</h2>
+                <p>
+                    The <code>ContinuationContext</code> parameter provides methods for sending updates:
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Method</th>
+                            <th>Description</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td><code>send_dynamic(name)</code></td>
+                            <td>Send a dynamic section (by spry-dynamic name) as an SSE event</td>
+                        </tr>
+                        <tr>
+                            <td><code>send_json(event_type, node)</code></td>
+                            <td>Send JSON data as an SSE event</td>
+                        </tr>
+                        <tr>
+                            <td><code>send_string(event_type, data)</code></td>
+                            <td>Send raw string data as an SSE event</td>
+                        </tr>
+                        <tr>
+                            <td><code>send_full_update(event_type)</code></td>
+                            <td>Send the entire component document</td>
+                        </tr>
+                        <tr>
+                            <td><code>send_event(event)</code></td>
+                            <td>Send a custom SseEvent</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Required Scripts</h2>
+                <p>
+                    Include the HTMX SSE extension in your page for continuations to work:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="scripts"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The <code>spry-unique</code> Attribute</h2>
+                <p>
+                    Use <code>spry-unique</code> on elements that need stable IDs for targeting.
+                    Spry generates unique IDs automatically.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="unique-attr"/>
+                
+                <div class="warning-box">
+                    <p>
+                        <strong>⚠️ Restrictions:</strong> Cannot use <code>spry-unique</code> with an
+                        explicit <code>id</code> attribute, or inside <code>spry-per-*</code> loops.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Cancellation Handling</h2>
+                <p>
+                    Override <code>continuation_canceled()</code> to clean up resources when the
+                    client disconnects:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="canceled-method"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Live Demo: Progress Bar</h2>
+                <p>
+                    This demo shows a progress bar that updates in real-time using SSE.
+                    Click "Start Task" to see continuations in action!
+                </p>
+                
+                <spry-component name="DemoHostComponent" sid="progress-demo"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/page-components/overview" class="nav-card">
+                        <h3>Page Components →</h3>
+                        <p>Learn about page-level components</p>
+                    </a>
+                    <a href="/components/outlets" class="nav-card">
+                        <h3>Outlets ←</h3>
+                        <p>Component composition</p>
+                    </a>
+                    <a href="/components/actions" class="nav-card">
+                        <h3>Actions ←</h3>
+                        <p>Handle user interactions</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // spry-continuation attribute example
+        var continuation_attr = get_component_child<CodeBlockComponent>("continuation-attr");
+        continuation_attr.language = "HTML";
+        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(\"{{this.percent}}%\")'>\n" +
+            "        </div>\n" +
+            "    </div>\n" +
+            "    <div spry-dynamic=\"status\">\n" +
+            "        <strong>Status:</strong> <span spry-unique content-expr=\"this.status\">Ready</span>\n" +
+            "    </div>\n" +
+            "</div>";
+        
+        // spry-dynamic attribute example
+        var dynamic_attr = get_component_child<CodeBlockComponent>("dynamic-attr");
+        dynamic_attr.language = "HTML";
+        dynamic_attr.code = "<!-- spry-dynamic marks elements for SSE swapping -->\n" +
+            "<div class=\"progress-container\" spry-dynamic=\"progress-bar\">\n" +
+            "    <div class=\"progress-bar\">...</div>\n" +
+            "</div>\n\n" +
+            "<div class=\"status\" spry-dynamic=\"status\">\n" +
+            "    <strong>Status:</strong> <span>Processing...</span>\n" +
+            "</div>\n\n" +
+            "<!-- Automatically gets: sse-swap=\"_spry-dynamic-{name}\" hx-swap=\"outerHTML\" -->";
+        
+        // continuation() method example
+        var continuation_method = get_component_child<CodeBlockComponent>("continuation-method");
+        continuation_method.language = "Vala";
+        continuation_method.code = "public async override void continuation(ContinuationContext ctx) throws Error {\n" +
+            "    for (int i = 0; i <= 100; i += 10) {\n" +
+            "        percent = i;\n" +
+            "        status = @\"Processing... $(i)%\";\n\n" +
+            "        // Send dynamic section updates to the client\n" +
+            "        yield ctx.send_dynamic(\"progress-bar\");\n" +
+            "        yield ctx.send_dynamic(\"status\");\n\n" +
+            "        // Simulate async work\n" +
+            "        Timeout.add(500, () => {\n" +
+            "            continuation.callback();\n" +
+            "            return false;\n" +
+            "        });\n" +
+            "        yield;\n" +
+            "    }\n\n" +
+            "    status = \"Complete!\";\n" +
+            "    yield ctx.send_dynamic(\"status\");\n" +
+            "}";
+        
+        // Required scripts example
+        var scripts = get_component_child<CodeBlockComponent>("scripts");
+        scripts.language = "HTML";
+        scripts.code = "<!-- In your page template or component -->\n" +
+            "<script spry-res=\"htmx.js\"></script>\n" +
+            "<script spry-res=\"htmx-sse.js\"></script>";
+        
+        // spry-unique attribute example
+        var unique_attr = get_component_child<CodeBlockComponent>("unique-attr");
+        unique_attr.language = "HTML";
+        unique_attr.code = "<!-- spry-unique generates a stable unique ID -->\n" +
+            "<div class=\"progress-bar\" spry-unique>\n" +
+            "    <!-- Gets ID like: _spry-unique-0-abc123 -->\n" +
+            "</div>\n\n" +
+            "<!-- Use with content-expr for dynamic content -->\n" +
+            "<span spry-unique content-expr=\"this.status\">Loading...</span>";
+        
+        // continuation_canceled example
+        var canceled_method = get_component_child<CodeBlockComponent>("canceled-method");
+        canceled_method.language = "Vala";
+        canceled_method.code = "public async override void continuation_canceled() throws Error {\n" +
+            "    // Client disconnected - clean up resources\n" +
+            "    cancel_background_task();\n" +
+            "    release_file_handles();\n" +
+            "    log_message(\"Task canceled by client\");\n" +
+            "}";
+        
+        // Set up the demo host
+        var demo = get_component_child<DemoHostComponent>("progress-demo");
+        demo.demo_component_name = "ProgressDemo";
+        demo.source_file = "demo/DemoComponents/ProgressDemo.vala";
+    }
+}

+ 268 - 0
demo/Pages/ComponentsOutletsPage.vala

@@ -0,0 +1,268 @@
+using Spry;
+using Inversion;
+
+/**
+ * ComponentsOutletsPage - Documentation for Spry outlets
+ * 
+ * This page covers what outlets are, how to use them for component
+ * composition, and the parent/child component pattern.
+ */
+public class ComponentsOutletsPage : PageComponent {
+    
+    public const string ROUTE = "/components/outlets";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>Outlets</h1>
+            
+            <section class="doc-section">
+                <p>
+                    Outlets are placeholders in your component templates where child components
+                    can be dynamically inserted. They enable powerful component composition
+                    patterns, especially for lists and dynamic content.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>What are Outlets?</h2>
+                <p>
+                    An outlet is a special element in your markup that acts as a insertion point
+                    for child components. Think of it like a socket where you can plug in
+                    different components at runtime.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="outlet-syntax"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Using <code>set_outlet_children()</code></h2>
+                <p>
+                    The <code>set_outlet_children(sid, children)</code> method populates an outlet
+                    with child components. Call it in your <code>prepare()</code> method after
+                    creating child components with the ComponentFactory.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="set-outlet-vala"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Parent/Child Component Pattern</h2>
+                <p>
+                    The typical pattern for outlets involves:
+                </p>
+                <ol>
+                    <li>A <strong>parent component</strong> with an outlet and a method to set children</li>
+                    <li>A <strong>child component</strong> that displays individual items</li>
+                    <li>A <strong>store</strong> that holds the data</li>
+                </ol>
+                
+                <h3>Parent Component</h3>
+                <spry-component name="CodeBlockComponent" sid="parent-component"/>
+                
+                <h3>Child Component</h3>
+                <spry-component name="CodeBlockComponent" sid="child-component"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Creating Lists with Outlets</h2>
+                <p>
+                    Outlets are perfect for rendering dynamic lists. Here's the complete pattern:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="list-pattern"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Outlets vs <code>&lt;spry-component&gt;</code></h2>
+                <p>
+                    When should you use outlets vs declarative child components?
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Feature</th>
+                            <th><code>&lt;spry-outlet&gt;</code></th>
+                            <th><code>&lt;spry-component&gt;</code></th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td>Use Case</td>
+                            <td>Dynamic lists, multiple items</td>
+                            <td>Single, known child components</td>
+                        </tr>
+                        <tr>
+                            <td>Population</td>
+                            <td><code>set_outlet_children()</code> in prepare()</td>
+                            <td>Automatic, configured via properties</td>
+                        </tr>
+                        <tr>
+                            <td>Access</td>
+                            <td>Not directly accessible</td>
+                            <td><code>get_component_child<T>()</code></td>
+                        </tr>
+                        <tr>
+                            <td>Examples</td>
+                            <td>Todo lists, data tables, feeds</td>
+                            <td>Headers, sidebars, fixed sections</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Live Demo: Todo List</h2>
+                <p>
+                    This interactive demo shows outlets in action. The todo list uses an outlet
+                    to render individual todo items. Try adding, toggling, and deleting items!
+                </p>
+                
+                <spry-component name="DemoHostComponent" sid="todo-demo"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/components/continuations" class="nav-card">
+                        <h3>Continuations →</h3>
+                        <p>Real-time updates with SSE</p>
+                    </a>
+                    <a href="/components/actions" class="nav-card">
+                        <h3>Actions ←</h3>
+                        <p>Handle user interactions</p>
+                    </a>
+                    <a href="/components/template-syntax" class="nav-card">
+                        <h3>Template Syntax ←</h3>
+                        <p>Review template attributes</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Outlet syntax example
+        var outlet_syntax = get_component_child<CodeBlockComponent>("outlet-syntax");
+        outlet_syntax.language = "HTML";
+        outlet_syntax.code = "<div sid=\"list\">\n" +
+            "    <!-- This outlet will be filled with child components -->\n" +
+            "    <spry-outlet sid=\"items\"/>\n" +
+            "</div>";
+        
+        // set_outlet_children example
+        var set_outlet_vala = get_component_child<CodeBlockComponent>("set-outlet-vala");
+        set_outlet_vala.language = "Vala";
+        set_outlet_vala.code = "public override async void prepare() throws Error {\n" +
+            "    var factory = inject<ComponentFactory>();\n" +
+            "    var store = inject<TodoStore>();\n\n" +
+            "    // Create child components for each item\n" +
+            "    var children = new Series<Renderable>();\n" +
+            "    foreach (var item in store.items) {\n" +
+            "        var component = factory.create<TodoItemComponent>();\n" +
+            "        component.item_id = item.id;  // Pass data to child\n" +
+            "        children.add(component);\n" +
+            "    }\n\n" +
+            "    // Populate the outlet\n" +
+            "    set_outlet_children(\"items\", children);\n" +
+            "}";
+        
+        // Parent component example
+        var parent_component = get_component_child<CodeBlockComponent>("parent-component");
+        parent_component.language = "Vala";
+        parent_component.code = "public class TodoListComponent : Component {\n" +
+            "    private TodoStore store = inject<TodoStore>();\n" +
+            "    private ComponentFactory factory = inject<ComponentFactory>();\n\n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <div sid=\"list\" class=\"todo-list\">\n" +
+            "            <ul sid=\"items\">\n" +
+            "                <spry-outlet sid=\"items-outlet\"/>\n" +
+            "            </ul>\n" +
+            "        </div>\n" +
+            "        \"\"\";\n" +
+            "    }}\n\n" +
+            "    public override async void prepare() throws Error {\n" +
+            "        var children = new Series<Renderable>();\n" +
+            "        foreach (var item in store.items) {\n" +
+            "            var component = factory.create<TodoItemComponent>();\n" +
+            "            component.item_id = item.id;\n" +
+            "            children.add(component);\n" +
+            "        }\n" +
+            "        set_outlet_children(\"items-outlet\", children);\n" +
+            "    }\n" +
+            "}";
+        
+        // Child component example
+        var child_component = get_component_child<CodeBlockComponent>("child-component");
+        child_component.language = "Vala";
+        child_component.code = "public class TodoItemComponent : Component {\n" +
+            "    private TodoStore store = inject<TodoStore>();\n" +
+            "    private HttpContext http_context = inject<HttpContext>();\n\n" +
+            "    public int item_id { get; set; }  // Set by parent\n\n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <li sid=\"item\" class=\"todo-item\">\n" +
+            "            <span sid=\"title\"></span>\n" +
+            "            <button spry-action=\":Toggle\" spry-target=\"item\">Toggle</button>\n" +
+            "        </li>\n" +
+            "        \"\"\";\n" +
+            "    }}\n\n" +
+            "    public override void prepare() throws Error {\n" +
+            "        var item = store.get_by_id(item_id);\n" +
+            "        this[\"title\"].text_content = item.title;\n" +
+            "        // Pass item_id via hx-vals for handle_action\n" +
+            "        this[\"item\"].set_attribute(\"hx-vals\", @\"{\\\"id\\\":$item_id}\");\n" +
+            "    }\n\n" +
+            "    public async override void handle_action(string action) throws Error {\n" +
+            "        var id = int.parse(http_context.request.query_params.get_any_or_default(\"id\"));\n" +
+            "        if (action == \"Toggle\") {\n" +
+            "            store.toggle(id);\n" +
+            "        }\n" +
+            "    }\n" +
+            "}";
+        
+        // List pattern example
+        var list_pattern = get_component_child<CodeBlockComponent>("list-pattern");
+        list_pattern.language = "Vala";
+        list_pattern.code = "// 1. Define a store to hold data\n" +
+            "public class TodoStore : Object {\n" +
+            "    public Series<TodoItem> items { get; default = new Series<TodoItem>(); }\n\n" +
+            "    public void add(string title) { /* ... */ }\n" +
+            "    public void toggle(int id) { /* ... */ }\n" +
+            "    public void remove(int id) { /* ... */ }\n" +
+            "}\n\n" +
+            "// 2. Register as singleton\n" +
+            "application.add_singleton<TodoStore>();\n\n" +
+            "// 3. Parent component uses outlet\n" +
+            "public class TodoListComponent : Component {\n" +
+            "    private TodoStore store = inject<TodoStore>();\n\n" +
+            "    public override string markup { get {\n" +
+            "        return \"<ul><spry-outlet sid=\"items\"/></ul>\";\n" +
+            "    }}\n\n" +
+            "    public override async void prepare() throws Error {\n" +
+            "        var children = new Series<Renderable>();\n" +
+            "        foreach (var item in store.items) {\n" +
+            "            var child = factory.create<TodoItemComponent>();\n" +
+            "            child.item_id = item.id;\n" +
+            "            children.add(child);\n" +
+            "        }\n" +
+            "        set_outlet_children(\"items\", children);\n" +
+            "    }\n" +
+            "}\n\n" +
+            "// 4. Child component handles individual items\n" +
+            "public class TodoItemComponent : Component {\n" +
+            "    public int item_id { get; set; }\n" +
+            "    // ... markup and methods\n" +
+            "}";
+        
+        // Set up the demo host
+        var demo = get_component_child<DemoHostComponent>("todo-demo");
+        demo.demo_component_name = "TodoListDemo";
+        demo.source_file = "demo/DemoComponents/TodoListDemo.vala";
+    }
+}

+ 228 - 0
demo/Pages/ComponentsOverviewPage.vala

@@ -0,0 +1,228 @@
+using Spry;
+using Inversion;
+
+/**
+ * ComponentsOverviewPage - Introduction to Spry Components
+ * 
+ * This page provides a comprehensive overview of what Spry components are,
+ * their lifecycle, key methods, and basic usage patterns.
+ */
+public class ComponentsOverviewPage : PageComponent {
+    
+    public const string ROUTE = "/components/overview";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>Components Overview</h1>
+            
+            <section class="doc-section">
+                <h2>What are Spry Components?</h2>
+                <p>
+                    Spry components are the building blocks of your web application. They combine
+                    HTML templates with behavior logic in a single, cohesive Vala class. Each component
+                    is self-contained with its own markup, state management, and action handling.
+                </p>
+                <p>
+                    Unlike traditional web frameworks where templates and logic are separated,
+                    Spry components embrace a component-based architecture where everything related
+                    to a UI element lives in one place.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Component Lifecycle</h2>
+                <p>
+                    Understanding the component lifecycle is crucial for building effective Spry applications.
+                    The template DOM is <strong>fresh on every request</strong>, meaning state is not
+                    persisted between requests.
+                </p>
+                
+                <div class="lifecycle-diagram">
+                    <div class="lifecycle-step">
+                        <div class="step-number">1</div>
+                        <div class="step-content">
+                            <h4>Component Creation</h4>
+                            <p>Component is instantiated via ComponentFactory</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">2</div>
+                        <div class="step-content">
+                            <h4>prepare_once()</h4>
+                            <p>One-time initialization (called only before first prepare)</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">3</div>
+                        <div class="step-content">
+                            <h4>prepare()</h4>
+                            <p>Fetch data from stores, populate template elements</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">4</div>
+                        <div class="step-content">
+                            <h4>handle_action()</h4>
+                            <p>Handle user interactions (if action triggered)</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">5</div>
+                        <div class="step-content">
+                            <h4>Serialization</h4>
+                            <p>Template is rendered to HTML and sent to client</p>
+                        </div>
+                    </div>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Key Methods</h2>
+                <p>
+                    Every component inherits from the base <code>Component</code> class and can
+                    override these key methods:
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Method</th>
+                            <th>Description</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td><code>markup</code></td>
+                            <td>Property returning the HTML template string with Spry attributes</td>
+                        </tr>
+                        <tr>
+                            <td><code>prepare()</code></td>
+                            <td>Called before serialization. Fetch data and populate template here.</td>
+                        </tr>
+                        <tr>
+                            <td><code>prepare_once()</code></td>
+                            <td>Called only once before the first prepare(). For one-time initialization.</td>
+                        </tr>
+                        <tr>
+                            <td><code>handle_action()</code></td>
+                            <td>Called when HTMX triggers an action. Modify state here.</td>
+                        </tr>
+                        <tr>
+                            <td><code>continuation()</code></td>
+                            <td>For real-time updates via Server-Sent Events (SSE).</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Basic Component Example</h2>
+                <p>
+                    Here's a simple counter component demonstrating the core concepts:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="example-code"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Important: Template State is Not Persisted</h2>
+                <div class="warning-box">
+                    <p>
+                        <strong>⚠️ Critical Concept:</strong> The template DOM is fresh on every request.
+                        Any state set via setters (like <code>title</code>, <code>completed</code>) is
+                        NOT available in <code>handle_action()</code>.
+                    </p>
+                    <p>
+                        Always use <code>prepare()</code> to fetch data from stores and set up the template.
+                        To pass data to actions, use one of these approaches:
+                    </p>
+                </div>
+                
+                <div class="info-box">
+                    <h4>Option 1: spry-context (Recommended for Properties)</h4>
+                    <p>
+                        Use <code>&lt;spry-context property="name"/&gt;</code> to automatically preserve
+                        property values across requests.
+                    </p>
+                    <ul>
+                        <li>Properties are encrypted and included in action URLs automatically</li>
+                        <li>Values are restored to properties before <code>handle_action()</code> is called</li>
+                        <li>Example: <code>&lt;spry-context property="item_id"/&gt;</code></li>
+                    </ul>
+                </div>
+                
+                <div class="info-box">
+                    <h4>Option 2: hx-vals (Manual Query Parameters)</h4>
+                    <p>
+                        Use the <code>hx-vals</code> attribute to pass data as query parameters.
+                    </p>
+                    <ul>
+                        <li>Values are included in the request as query parameters</li>
+                        <li>Must be read manually via <code>http_context.request.query_params.get_any_or_default("key")</code></li>
+                        <li>Example: <code>hx-vals='{"id": "123"}'</code></li>
+                    </ul>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/components/template-syntax" class="nav-card">
+                        <h3>Template Syntax →</h3>
+                        <p>Learn about Spry's special HTML attributes</p>
+                    </a>
+                    <a href="/components/actions" class="nav-card">
+                        <h3>Actions →</h3>
+                        <p>Handle user interactions with actions</p>
+                    </a>
+                    <a href="/components/outlets" class="nav-card">
+                        <h3>Outlets →</h3>
+                        <p>Compose components with outlets</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        var code = get_component_child<CodeBlockComponent>("example-code");
+        code.language = "Vala";
+        code.code = "using Spry;\n\n" +
+            "public class CounterComponent : Component {\n" +
+            "    private CounterStore store = inject<CounterStore>();\n\n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <div sid=\"counter\" class=\"counter\">\n" +
+            "            <span sid=\"count\"></span>\n" +
+            "            <button sid=\"inc\" spry-action=\":Increment\"\n" +
+            "                    spry-target=\"counter\">+</button>\n" +
+            "            <button sid=\"dec\" spry-action=\":Decrement\"\n" +
+            "                    spry-target=\"counter\">-</button>\n" +
+            "        </div>\n" +
+            "        \"\"\";\n" +
+            "    }}\n\n" +
+            "    public override void prepare() throws Error {\n" +
+            "        this[\"count\"].text_content = store.count.to_string();\n" +
+            "    }\n\n" +
+            "    public async override void handle_action(string action) throws Error {\n" +
+            "        switch (action) {\n" +
+            "            case \"Increment\":\n" +
+            "                store.count++;\n" +
+            "                break;\n" +
+            "            case \"Decrement\":\n" +
+            "                store.count--;\n" +
+            "                break;\n" +
+            "        }\n" +
+            "        // prepare() is called automatically after handle_action()\n" +
+            "    }\n" +
+            "}";
+    }
+}

+ 329 - 0
demo/Pages/ComponentsTemplateSyntaxPage.vala

@@ -0,0 +1,329 @@
+using Spry;
+
+/**
+ * ComponentsTemplateSyntaxPage - Documentation for Spry template syntax
+ * 
+ * This page covers all the special HTML attributes and elements used in
+ * Spry component templates.
+ */
+public class ComponentsTemplateSyntaxPage : PageComponent {
+    
+    public const string ROUTE = "/components/template-syntax";
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>Template Syntax</h1>
+            
+            <section class="doc-section">
+                <p>
+                    Spry extends HTML with special attributes that enable reactive behavior,
+                    component composition, and dynamic content. This page covers all the
+                    template syntax available in Spry components.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Special Attributes</h2>
+                
+                <h3><code>sid</code> - Spry ID</h3>
+                <p>
+                    The <code>sid</code> attribute assigns a unique identifier to an element within
+                    a component. Use it to select and manipulate elements in your Vala code.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="sid-html"/>
+                <spry-component name="CodeBlockComponent" sid="sid-vala"/>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>spry-action</code> - HTMX Action Binding</h3>
+                <p>
+                    The <code>spry-action</code> attribute binds an element to trigger a component action
+                    when interacted with. The action is sent to the server via HTMX.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="action-example"/>
+                
+                <p>
+                    See the <a href="/components/actions">Actions</a> page for more details.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>spry-target</code> - Scoped Targeting</h3>
+                <p>
+                    The <code>spry-target</code> attribute specifies which element should be replaced
+                    when an action completes. It targets elements by their <code>sid</code> within the
+                    same component.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="target-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>spry-global</code> - Out-of-Band Swaps</h3>
+                <p>
+                    The <code>spry-global</code> attribute marks an element for out-of-band updates.
+                    When included in a response, HTMX will swap this element anywhere on the page
+                    that has a matching <code>id</code>.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="global-html"/>
+                <spry-component name="CodeBlockComponent" sid="global-vala"/>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>*-expr</code> - Expression Attributes</h3>
+                <p>
+                    Expression attributes dynamically set HTML attributes based on component properties.
+                    Use <code>content-expr</code> for text content, <code>class-*-expr</code> for
+                    conditional classes, and <code>any-attr-expr</code> for any other attribute.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="expr-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>spry-if</code> / <code>spry-else-if</code> / <code>spry-else</code> - Conditionals</h3>
+                <p>
+                    Use these attributes for conditional rendering. Only elements with truthy
+                    conditions will be included in the output.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="conditional-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>spry-per-*</code> - Loop Rendering</h3>
+                <p>
+                    Use <code>spry-per-{varname}="expression"</code> to iterate over collections.
+                    The variable name becomes available in nested expressions.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="loop-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Special Elements</h2>
+                
+                <h3><code>&lt;spry-outlet&gt;</code> - Child Component Outlets</h3>
+                <p>
+                    Outlets are placeholders where child components can be inserted dynamically.
+                    Use <code>set_outlet_children()</code> to populate outlets.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="outlet-example"/>
+                
+                <p>
+                    See the <a href="/components/outlets">Outlets</a> page for more details.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>&lt;spry-component&gt;</code> - Declarative Child Components</h3>
+                <p>
+                    Use <code>&lt;spry-component&gt;</code> to declare child components directly in
+                    your template. Access them with <code>get_component_child&lt;T&gt;()</code>.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="component-html"/>
+                <spry-component name="CodeBlockComponent" sid="component-vala"/>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>&lt;spry-context&gt;</code> - Context Property Preservation</h3>
+                <p>
+                    The <code>&lt;spry-context&gt;</code> tag marks a property to be preserved across
+                    action requests. When a component action is triggered, properties marked with this
+                    tag are encrypted and included in the action URL, then restored when the action
+                    is handled.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="context-example"/>
+                
+                <h4>How It Works</h4>
+                <ul>
+                    <li>Properties marked with <code>&lt;spry-context property="name"/&gt;</code> are tracked</li>
+                    <li>When an action is triggered, these properties are encrypted using <code>CryptographyProvider</code></li>
+                    <li>The encrypted context is included in the action URL</li>
+                    <li>The server decrypts and restores the properties before calling <code>handle_action()</code></li>
+                </ul>
+                
+                <div class="warning-box">
+                    <h4>⚠️ Security Warning: Replay Attacks</h4>
+                    <p>
+                        The encrypted context can be captured and replayed by malicious actors. While the
+                        data cannot be tampered with (it's cryptographically signed), an attacker could
+                        capture a valid request and replay it later.
+                    </p>
+                    <ul>
+                        <li><strong>Do not</strong> use context for sensitive data that could be exploited if replayed</li>
+                        <li><strong>Do not</strong> use context for authentication or authorization decisions</li>
+                        <li><strong>Consider</strong> adding timestamps or expiration for time-sensitive operations</li>
+                    </ul>
+                    <p>
+                        <strong>Example attack:</strong> If context contains admin privileges, an attacker
+                        could capture a request from an admin session and replay it to gain unauthorized access.
+                    </p>
+                </div>
+                
+                <h4>Best Practices</h4>
+                <ul>
+                    <li>Use context for non-sensitive data like IDs, flags, or UI configuration</li>
+                    <li>Validate context data on the server before using it</li>
+                    <li>Keep context data minimal - only include what's needed</li>
+                </ul>
+            </section>
+            
+            <section class="doc-section">
+                <h3><code>spry-res</code> - Static Resources</h3>
+                <p>
+                    Use <code>spry-res</code> to reference Spry's built-in static resources.
+                    This resolves to <code>/_spry/res/{resource-name}</code> automatically.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="res-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/components/actions" class="nav-card">
+                        <h3>Actions →</h3>
+                        <p>Handle user interactions with actions</p>
+                    </a>
+                    <a href="/components/outlets" class="nav-card">
+                        <h3>Outlets →</h3>
+                        <p>Compose components with outlets</p>
+                    </a>
+                    <a href="/components/continuations" class="nav-card">
+                        <h3>Continuations →</h3>
+                        <p>Real-time updates with SSE</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // sid examples
+        var sid_html = get_component_child<CodeBlockComponent>("sid-html");
+        sid_html.language = "HTML";
+        sid_html.code = """<div sid="container">
+    <span sid="title">Hello</span>
+    <button sid="submit-btn">Submit</button>
+</div>""";
+        
+        var sid_vala = get_component_child<CodeBlockComponent>("sid-vala");
+        sid_vala.language = "Vala";
+        sid_vala.code = """// Access elements by sid in prepare() or handle_action()
+this["title"].text_content = "Updated Title";
+this["submit-btn"].set_attribute("disabled", "true");""";
+        
+        // spry-action example
+        var action_example = get_component_child<CodeBlockComponent>("action-example");
+        action_example.language = "HTML";
+        action_example.code = """<!-- Same-component action -->
+<button spry-action=":Toggle">Toggle</button>
+
+<!-- Cross-component action -->
+<button spry-action="ListComponent:Add">Add Item</button>""";
+        
+        // spry-target example
+        var target_example = get_component_child<CodeBlockComponent>("target-example");
+        target_example.language = "HTML";
+        target_example.code = """<div sid="item" class="item">
+    <span sid="title">Item Title</span>
+    <button spry-action=":Delete" spry-target="item">Delete</button>
+</div>""";
+        
+        // spry-global examples
+        var global_html = get_component_child<CodeBlockComponent>("global-html");
+        global_html.language = "HTML";
+        global_html.code = """<!-- In HeaderComponent -->
+<div id="header" spry-global="header">
+    <h1>Todo App</h1>
+    <span sid="count">0 items</span>
+</div>""";
+        
+        var global_vala = get_component_child<CodeBlockComponent>("global-vala");
+        global_vala.language = "Vala";
+        global_vala.code = """// In another component's handle_action()
+add_globals_from(header);  // Includes header in response for OOB swap""";
+        
+        // Expression attributes example
+        var expr_example = get_component_child<CodeBlockComponent>("expr-example");
+        expr_example.language = "HTML";
+        expr_example.code = """<!-- Content expression -->
+<span content-expr='format("{{this.percent}}%")'>0%</span>
+
+<!-- Style expression -->
+<div style-width-expr='format("{{this.percent}}%")'></div>
+
+<!-- Conditional class -->
+<div class-completed-expr="this.is_completed">Task</div>
+
+<!-- Any attribute expression -->
+<input disabled-expr="this.is_readonly">""";
+        
+        // Conditional example
+        var conditional_example = get_component_child<CodeBlockComponent>("conditional-example");
+        conditional_example.language = "HTML";
+        conditional_example.code = """<div spry-if="this.is_admin">Admin Panel</div>
+<div spry-else-if="this.is_moderator">Moderator Panel</div>
+<div spry-else>User Panel</div>""";
+        
+        // Loop example
+        var loop_example = get_component_child<CodeBlockComponent>("loop-example");
+        loop_example.language = "HTML";
+        loop_example.code = """<ul>
+    <li spry-per-task="this.tasks">
+        <span content-expr="task.name"></span>
+        <span content-expr="task.status"></span>
+    </li>
+</ul>""";
+        
+        // Outlet example
+        var outlet_example = get_component_child<CodeBlockComponent>("outlet-example");
+        outlet_example.language = "HTML";
+        outlet_example.code = """<div sid="list">
+    <spry-outlet sid="items"/>
+</div>""";
+        
+        // Component examples
+        var component_html = get_component_child<CodeBlockComponent>("component-html");
+        component_html.language = "HTML";
+        component_html.code = """<div>
+    <spry-component name="HeaderComponent" sid="header"/>
+    <spry-component name="TodoListComponent" sid="todo-list"/>
+    <spry-component name="FooterComponent" sid="footer"/>
+</div>""";
+        
+        var component_vala = get_component_child<CodeBlockComponent>("component-vala");
+        component_vala.language = "Vala";
+        component_vala.code = """public override async void prepare() throws Error {
+    var header = get_component_child<HeaderComponent>("header");
+    header.title = "My App";
+}""";
+        
+        // Context example
+        var context_example = get_component_child<CodeBlockComponent>("context-example");
+        context_example.language = "HTML";
+        context_example.code = """<!-- Mark properties to be preserved across actions -->
+<spry-context property="demo_component_name"/>
+<spry-context property="source_file"/>
+
+<div sid="host">
+    <span content-expr="this.demo_component_name">Demo</span>
+    <button spry-action=":ShowSource" spry-target="host">Show Source</button>
+</div>""";
+        
+        // Static resources example
+        var res_example = get_component_child<CodeBlockComponent>("res-example");
+        res_example.language = "HTML";
+        res_example.code = """<script spry-res="htmx.js"></script>
+<script spry-res="htmx-sse.js"></script>""";
+    }
+}

+ 118 - 0
demo/Pages/HomePage.vala

@@ -0,0 +1,118 @@
+using Spry;
+using Inversion;
+
+/**
+ * HomePage - Documentation landing page for the Spry framework
+ * 
+ * Clean, minimal design focused on helping developers get started
+ * with Spry quickly.
+ */
+public class HomePage : PageComponent {
+    
+    public const string ROUTE = "/";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div class="doc-content">
+            
+            <!-- Hero Section -->
+            <section class="home-hero">
+                <h1 class="hero-title">Spry</h1>
+                <p class="hero-tagline">A component-based web framework for Vala</p>
+            </section>
+            
+            <!-- Introduction -->
+            <section class="doc-section">
+                <p class="intro">
+                    Spry is a web framework that lets you build dynamic, interactive web applications
+                    using Vala. It features HTMX integration for reactive UIs, dependency injection
+                    for clean architecture, and a component model that keeps markup and logic together.
+                </p>
+                <p>
+                    Whether you're building a simple web service or a complex interactive application,
+                    Spry provides the tools you need with full type safety and native performance.
+                </p>
+            </section>
+            
+            <!-- Quick Links -->
+            <section class="doc-section">
+                <h2>Documentation</h2>
+                <div class="quick-links">
+                    <a href="/components/overview" class="quick-link-card">
+                        <h3>Components</h3>
+                        <p>Learn about Spry's component model, lifecycle, and how to build interactive UIs.</p>
+                    </a>
+                    <a href="/page-components/overview" class="quick-link-card">
+                        <h3>Page Components</h3>
+                        <p>Understand how to create pages and use templates for consistent layouts.</p>
+                    </a>
+                    <a href="/static-resources/overview" class="quick-link-card">
+                        <h3>Static Resources</h3>
+                        <p>Serve CSS, JavaScript, images, and other static assets efficiently.</p>
+                    </a>
+                </div>
+            </section>
+            
+            <!-- Get Started Code Example -->
+            <section class="doc-section">
+                <h2>Get Started</h2>
+                <p>
+                    Here's a minimal "Hello World" Spry application to get you started:
+                </p>
+                <spry-component name="CodeBlockComponent" sid="hello-code"/>
+            </section>
+            
+            <!-- Features -->
+            <section class="doc-section">
+                <h2>Why Spry?</h2>
+                <ul class="features-list">
+                    <li>
+                        <strong>Type-Safe</strong> — Full Vala type safety catches errors at compile time,
+                        not runtime.
+                    </li>
+                    <li>
+                        <strong>Component-Based</strong> — Build encapsulated, reusable components with
+                        markup and logic in one place.
+                    </li>
+                    <li>
+                        <strong>HTMX Integration</strong> — Create dynamic interfaces without writing
+                        JavaScript. HTMX handles the complexity.
+                    </li>
+                    <li>
+                        <strong>Dependency Injection</strong> — Clean architecture with Inversion of
+                        Control for services and stores.
+                    </li>
+                    <li>
+                        <strong>Native Performance</strong> — Compiled to native code for maximum
+                        throughput and minimal resource usage.
+                    </li>
+                    <li>
+                        <strong>Free & Open Source</strong> — Released under a permissive license,
+                        free to use for any project.
+                    </li>
+                </ul>
+            </section>
+            
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        var helloCode = get_component_child<CodeBlockComponent>("hello-code");
+        helloCode.language = "Vala";
+        helloCode.code = "public class HelloPage : PageComponent {\n" +
+        "    public override string markup { get {\n" +
+        "        return \"\"\"<!DOCTYPE html>\n" +
+        "<html>\n" +
+        "<body>\n" +
+        "    <h1>Hello, Spry!</h1>\n" +
+        "</body>\n" +
+        "</html>\"\"\";\n" +
+        "    }}\n" +
+        "}\n\n" +
+        "// In Main.vala\n" +
+        "spry_cfg.add_page<HelloPage>(new EndpointRoute(\"/\"));";
+    }
+}

+ 267 - 0
demo/Pages/PageComponentsOverviewPage.vala

@@ -0,0 +1,267 @@
+using Spry;
+using Inversion;
+
+/**
+ * PageComponentsOverviewPage - Introduction to PageComponent
+ * 
+ * This page explains what PageComponents are, how they differ from regular
+ * Components, and when to use them.
+ */
+public class PageComponentsOverviewPage : PageComponent {
+    
+    public const string ROUTE = "/page-components/overview";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div class="doc-content">
+            <div class="doc-hero">
+                <h1>Page Components</h1>
+                <p class="doc-subtitle">Combine Component and Endpoint into a single powerful abstraction</p>
+            </div>
+            
+            <section class="doc-section">
+                <h2>What are Page Components?</h2>
+                <p>
+                    A <code>PageComponent</code> is a special type of component that acts as both a 
+                    <strong>Component</strong> AND an <strong>Endpoint</strong>. This means it can render
+                    HTML templates <em>and</em> handle HTTP requests at a specific route.
+                </p>
+                <p>
+                    In traditional web frameworks, you often need separate classes for routes/endpoints
+                    and for rendering views. PageComponent eliminates this duplication by combining
+                    both responsibilities into a single cohesive class.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>PageComponent vs Component</h2>
+                <p>
+                    Understanding when to use <code>PageComponent</code> vs <code>Component</code> is essential:
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Feature</th>
+                            <th>Component</th>
+                            <th>PageComponent</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td>Has markup</td>
+                            <td>✓ Yes</td>
+                            <td>✓ Yes</td>
+                        </tr>
+                        <tr>
+                            <td>Handles HTTP routes</td>
+                            <td>✗ No</td>
+                            <td>✓ Yes</td>
+                        </tr>
+                        <tr>
+                            <td>Can be nested in outlets</td>
+                            <td>✓ Yes</td>
+                            <td>✗ No (top-level only)</td>
+                        </tr>
+                        <tr>
+                            <td>Implements Endpoint</td>
+                            <td>✗ No</td>
+                            <td>✓ Yes</td>
+                        </tr>
+                        <tr>
+                            <td>Wrapped by templates</td>
+                            <td>✗ No</td>
+                            <td>✓ Yes</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>When to Use Each</h2>
+                
+                <div class="feature-grid">
+                    <div class="feature-card">
+                        <h3>Use PageComponent when:</h3>
+                        <ul>
+                            <li>Creating top-level pages (routes)</li>
+                            <li>Building documentation pages</li>
+                            <li>Rendering full HTML documents</li>
+                            <li>The component needs its own URL</li>
+                        </ul>
+                    </div>
+                    <div class="feature-card">
+                        <h3>Use Component when:</h3>
+                        <ul>
+                            <li>Building reusable UI elements</li>
+                            <li>Creating widgets or cards</li>
+                            <li>Nesting inside other components</li>
+                            <li>Rendering partial content</li>
+                        </ul>
+                    </div>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Basic PageComponent Example</h2>
+                <p>
+                    Here's how to create a simple PageComponent:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="basic-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Route Registration</h2>
+                <p>
+                    PageComponents are registered as endpoints in your application's Main.vala.
+                    You register both the component type and its route:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="route-registration"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Dependency Injection in Pages</h2>
+                <p>
+                    PageComponents support the same dependency injection patterns as regular components.
+                    Use <code>inject<T>()</code> to access services, stores, and factories:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="di-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Template Wrapping</h2>
+                <p>
+                    One of the most powerful features of PageComponents is automatic template wrapping.
+                    When a PageComponent renders, it is automatically wrapped by matching 
+                    <code>PageTemplate</code> instances.
+                </p>
+                <p>
+                    This means your PageComponent markup only needs to contain the <em>content</em>
+                    of the page, not the full HTML document structure. The template provides the
+                    <code><html></code>, <code><head></code>, <code><body></code>,
+                    navigation, and footer.
+                </p>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Tip:</strong> Learn more about templates in the 
+                        <a href="/page-components/templates">Page Templates</a> documentation.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>How handle_request() Works</h2>
+                <p>
+                    The <code>PageComponent</code> base class implements the <code>Endpoint</code>
+                    interface's <code>handle_request()</code> method. This method:
+                </p>
+                
+                <ol class="doc-list">
+                    <li>Collects all matching <code>PageTemplate</code> instances sorted by prefix depth</li>
+                    <li>Renders each template in order</li>
+                    <li>Finds <code>&lt;spry-template-outlet/&gt;</code> elements in each template</li>
+                    <li>Nests the content into the outlet, building the complete document</li>
+                    <li>Returns the final HTML as an <code>HttpResult</code></li>
+                </ol>
+                
+                <p>
+                    You typically don't need to override <code>handle_request()</code> - the default
+                    implementation handles everything for you.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/page-components/templates" class="nav-card">
+                        <h3>Page Templates →</h3>
+                        <p>Learn how to create layout templates that wrap your pages</p>
+                    </a>
+                    <a href="/components/overview" class="nav-card">
+                        <h3>Components Overview →</h3>
+                        <p>Review the basics of Spry components</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Basic PageComponent example
+        var basic_example = get_component_child<CodeBlockComponent>("basic-example");
+        basic_example.language = "vala";
+        basic_example.code = "using Spry;\n" +
+            "using Inversion;\n\n" +
+            "public class DocumentationPage : PageComponent {\n" +
+            "    \n" +
+            "    // Dependency injection works the same as Component\n" +
+            "    private ComponentFactory factory = inject<ComponentFactory>();\n" +
+            "    \n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <div class=\"page-content\">\n" +
+            "            <h1>My Documentation Page</h1>\n" +
+            "            <p>This page handles its own route.</p>\n" +
+            "        </div>\n" +
+            "        \"\"\";\n" +
+            "    }}\n" +
+            "    \n" +
+            "    public override async void prepare() throws Error {\n" +
+            "        // Set up page content\n" +
+            "    }\n" +
+            "}";
+        
+        // Route registration example
+        var route_reg = get_component_child<CodeBlockComponent>("route-registration");
+        route_reg.language = "vala";
+        route_reg.code = "// In Main.vala:\n\n" +
+            "// Register the page component with the dependency container\n" +
+            "application.add_transient<DocumentationPage>();\n\n" +
+            "// Register the page as an endpoint at a specific route\n" +
+            "application.add_endpoint<DocumentationPage>(\n" +
+            "    new EndpointRoute(\"/docs/page\")\n" +
+            ");\n\n" +
+            "// Now visiting /docs/page will render DocumentationPage\n" +
+            "// automatically wrapped by any matching PageTemplate";
+        
+        // Dependency injection example
+        var di_example = get_component_child<CodeBlockComponent>("di-example");
+        di_example.language = "vala";
+        di_example.code = "public class UserProfilePage : PageComponent {\n" +
+            "    \n" +
+            "    // Inject services you need\n" +
+            "    private ComponentFactory factory = inject<ComponentFactory>();\n" +
+            "    private UserStore user_store = inject<UserStore>();\n" +
+            "    private HttpContext http_context = inject<HttpContext>();\n" +
+            "    \n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <div class=\"profile-page\">\n" +
+            "            <h2 sid=\"username\"></h2>\n" +
+            "            <p sid=\"email\"></p>\n" +
+            "        </div>\n" +
+            "        \"\"\";\n" +
+            "    }}\n" +
+            "    \n" +
+            "    public override async void prepare() throws Error {\n" +
+            "        // Access route parameters from http_context\n" +
+            "        var user_id = http_context.request.query_params\n" +
+            "            .get_any_or_default(\"id\", \"guest\");\n" +
+            "        \n" +
+            "        // Use injected store to fetch data\n" +
+            "        var user = yield user_store.get_user(user_id);\n" +
+            "        \n" +
+            "        // Populate template\n" +
+            "        this[\"username\"].text_content = user.name;\n" +
+            "        this[\"email\"].text_content = user.email;\n" +
+            "    }\n" +
+            "}";
+    }
+}

+ 419 - 0
demo/Pages/PageTemplatesPage.vala

@@ -0,0 +1,419 @@
+using Spry;
+using Inversion;
+
+/**
+ * PageTemplatesPage - Documentation for Page Templates
+ * 
+ * This page explains how PageTemplates work, how to create them,
+ * and how they wrap PageComponents to provide site-wide layouts.
+ */
+public class PageTemplatesPage : PageComponent {
+    
+    public const string ROUTE = "/page-components/templates";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div class="doc-content">
+            <div class="doc-hero">
+                <h1>Page Templates</h1>
+                <p class="doc-subtitle">Create reusable layouts that wrap your page components</p>
+            </div>
+            
+            <section class="doc-section">
+                <h2>What are Page Templates?</h2>
+                <p>
+                    A <code>PageTemplate</code> is a special component that provides the outer HTML
+                    structure for your pages. Templates define the <code><html></code>,
+                    <code><head></code>, <code><body></code>, and common elements like
+                    navigation headers and footers.
+                </p>
+                <p>
+                    The key feature of a PageTemplate is the <code>&lt;spry-template-outlet/&gt;</code>
+                    element, which marks where page content will be inserted. When a PageComponent
+                    renders, it automatically gets nested inside matching templates.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The Template Outlet</h2>
+                <p>
+                    The <code>&lt;spry-template-outlet/&gt;</code> element is the placeholder where
+                    page content gets inserted. Every PageTemplate must include at least one outlet.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="outlet-example"/>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 How it works:</strong> When rendering, Spry finds all templates
+                        matching the current route, renders them in order of specificity, and nests
+                        each one's content into the previous template's outlet.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Creating a MainTemplate</h2>
+                <p>
+                    Here's a complete example of a site-wide template that provides the HTML
+                    document structure, head elements, navigation, and footer:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="main-template"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Template Registration</h2>
+                <p>
+                    Templates are registered with a <strong>prefix</strong> that determines which
+                    routes they apply to. The prefix is passed via metadata during registration:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="template-registration"/>
+                
+                <p>
+                    In this example, the <code>TemplateRoutePrefix("")</code> with an empty string
+                    matches <strong>all routes</strong>, making it the default site-wide template.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Route Prefix Matching</h2>
+                <p>
+                    Templates can target specific sections of your site using route prefixes.
+                    The prefix determines which routes the template will wrap:
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Prefix</th>
+                            <th>Matches Routes</th>
+                            <th>Use Case</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td><code>""</code> (empty)</td>
+                            <td>All routes</td>
+                            <td>Site-wide default template</td>
+                        </tr>
+                        <tr>
+                            <td><code>"/admin"</code></td>
+                            <td>/admin/*, /admin/settings, etc.</td>
+                            <td>Admin section layout</td>
+                        </tr>
+                        <tr>
+                            <td><code>"/docs"</code></td>
+                            <td>/docs/*, /docs/getting-started, etc.</td>
+                            <td>Documentation section</td>
+                        </tr>
+                        <tr>
+                            <td><code>"/api"</code></td>
+                            <td>/api/* routes</td>
+                            <td>API documentation or explorer</td>
+                        </tr>
+                    </tbody>
+                </table>
+                
+                <h3>How Matching Works</h3>
+                <p>
+                    The <code>TemplateRoutePrefix.matches_route()</code> method compares the template's
+                    prefix segments against the route segments. A template matches if its prefix
+                    segments are a prefix of the route segments.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="matching-logic"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Multiple Templates</h2>
+                <p>
+                    You can have multiple templates for different sections of your site.
+                    Templates are sorted by <strong>rank</strong> (prefix depth) and applied
+                    from lowest to highest rank:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="multiple-templates"/>
+                
+                <h3>Template Nesting Order</h3>
+                <p>
+                    For a route like <code>/admin/users</code>, templates would be applied in order:
+                </p>
+                
+                <ol class="doc-list">
+                    <li><strong>MainTemplate</strong> (prefix: "") - rank 0</li>
+                    <li><strong>AdminTemplate</strong> (prefix: "/admin") - rank 1</li>
+                    <li><strong>PageComponent</strong> - The actual page content</li>
+                </ol>
+                
+                <p>
+                    Each template's outlet receives the content from the next item in the chain,
+                    creating nested layouts.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Section-Specific Template Example</h2>
+                <p>
+                    Here's an example of a template specifically for the admin section that
+                    adds an admin sidebar:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="admin-template"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Head Content Merging</h2>
+                <p>
+                    When templates wrap pages, the <code><head></code> elements are automatically
+                    merged. If a PageComponent or nested template has <code><head></code> content,
+                    those elements are appended to the outer template's head.
+                </p>
+                
+                <p>
+                    This allows pages to add their own stylesheets, scripts, or meta tags while
+                    still benefiting from the template's common head elements.
+                </p>
+                
+                <div class="warning-box">
+                    <p>
+                        <strong>⚠️ Note:</strong> Head merging only works when templates render
+                        actual <code><head></code> elements. Make sure your templates include
+                        a proper HTML structure with head and body sections.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Template vs Component</h2>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Feature</th>
+                            <th>PageTemplate</th>
+                            <th>Component</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td>Base class</td>
+                            <td><code>Component</code></td>
+                            <td><code>Component</code></td>
+                        </tr>
+                        <tr>
+                            <td>Has markup</td>
+                            <td>✓ Yes</td>
+                            <td>✓ Yes</td>
+                        </tr>
+                        <tr>
+                            <td>Contains outlet</td>
+                            <td>✓ Required</td>
+                            <td>✗ Optional</td>
+                        </tr>
+                        <tr>
+                            <td>Route matching</td>
+                            <td>By prefix</td>
+                            <td>N/A</td>
+                        </tr>
+                        <tr>
+                            <td>Wraps pages</td>
+                            <td>✓ Yes</td>
+                            <td>✗ No</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Best Practices</h2>
+                
+                <ul class="doc-list">
+                    <li><strong>Keep templates focused:</strong> Each template should handle one level of layout (site-wide, section-specific)</li>
+                    <li><strong>Use semantic HTML:</strong> Include proper <code><header></code>, <code><main></code>, <code><footer></code> elements</li>
+                    <li><strong>Include common resources:</strong> Add shared stylesheets and scripts in your main template</li>
+                    <li><strong>Plan your prefix hierarchy:</strong> Design your URL structure to work with template prefixes</li>
+                    <li><strong>Don't duplicate content:</strong> Let templates handle repeated elements like navigation</li>
+                </ul>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/page-components/overview" class="nav-card">
+                        <h3>← Page Components</h3>
+                        <p>Learn about PageComponent basics</p>
+                    </a>
+                    <a href="/components/outlets" class="nav-card">
+                        <h3>Component Outlets →</h3>
+                        <p>Use outlets for nested components</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Outlet example
+        var outlet_example = get_component_child<CodeBlockComponent>("outlet-example");
+        outlet_example.language = "xml";
+        outlet_example.code = "<!DOCTYPE html>\n" +
+            "<html lang=\"en\">\n" +
+            "<head>\n" +
+            "    <title>My Site</title>\n" +
+            "    <link rel=\"stylesheet\" href=\"/styles/main.css\">\n" +
+            "</head>\n" +
+            "<body>\n" +
+            "    <header>\n" +
+            "        <nav><!-- Site navigation --></nav>\n" +
+            "    </header>\n" +
+            "    \n" +
+            "    <main>\n" +
+            "        <!-- Page content is inserted here -->\n" +
+            "        <spry-template-outlet />\n" +
+            "    </main>\n" +
+            "    \n" +
+            "    <footer>\n" +
+            "        <p>&copy; 2024 My Site</p>\n" +
+            "    </footer>\n" +
+            "</body>\n" +
+            "</html>";
+        
+        // Main template example
+        var main_template = get_component_child<CodeBlockComponent>("main-template");
+        main_template.language = "vala";
+        main_template.code = "using Spry;\n\n" +
+            "/**\n" +
+            " * MainTemplate - Site-wide layout template\n" +
+            " * \n" +
+            " * Wraps all pages with common HTML structure,\n" +
+            " * navigation, and footer.\n" +
+            " */\n" +
+            "public class MainTemplate : PageTemplate {\n" +
+            "    \n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <!DOCTYPE html>\n" +
+            "        <html lang=\"en\">\n" +
+            "        <head>\n" +
+            "            <meta charset=\"UTF-8\">\n" +
+            "            <meta name=\"viewport\" \n" +
+            "                  content=\"width=device-width, initial-scale=1.0\">\n" +
+            "            <title>My Spry Application</title>\n" +
+            "            <link rel=\"stylesheet\" href=\"/styles/main.css\">\n" +
+            "            <script spry-res=\"htmx.js\"></script>\n" +
+            "        </head>\n" +
+            "        <body>\n" +
+            "            <header class=\"site-header\">\n" +
+            "                <nav>\n" +
+            "                    <a href=\"/\" class=\"logo\">My App</a>\n" +
+            "                    <ul>\n" +
+            "                        <li><a href=\"/docs\">Docs</a></li>\n" +
+            "                        <li><a href=\"/about\">About</a></li>\n" +
+            "                    </ul>\n" +
+            "                </nav>\n" +
+            "            </header>\n" +
+            "            \n" +
+            "            <main>\n" +
+            "                <spry-template-outlet />\n" +
+            "            </main>\n" +
+            "            \n" +
+            "            <footer class=\"site-footer\">\n" +
+            "                <p>&copy; 2024 My App. Built with Spry.</p>\n" +
+            "            </footer>\n" +
+            "        </body>\n" +
+            "        </html>\n" +
+            "        \"\"\";\n" +
+            "    }}\n" +
+            "}";
+        
+        // Template registration
+        var template_reg = get_component_child<CodeBlockComponent>("template-registration");
+        template_reg.language = "vala";
+        template_reg.code = "// In Main.vala:\n\n" +
+            "var spry_cfg = application.configure_with<SpryConfigurator>();\n\n" +
+            "// Register template with empty prefix (matches all routes)\n" +
+            "spry_cfg.add_template<MainTemplate>(\"\");\n\n" +
+            "// The TemplateRoutePrefix is created internally from the string\n" +
+            "// and used for route matching";
+        
+        // Matching logic
+        var matching_logic = get_component_child<CodeBlockComponent>("matching-logic");
+        matching_logic.language = "vala";
+        matching_logic.code = "// TemplateRoutePrefix matching logic (simplified)\n\n" +
+            "public class TemplateRoutePrefix : Object {\n" +
+            "    public uint rank { get; private set; }\n" +
+            "    public string prefix { get; private set; }\n" +
+            "    \n" +
+            "    public TemplateRoutePrefix(string prefix) {\n" +
+            "        this.prefix = prefix;\n" +
+            "        // Rank is the number of path segments\n" +
+            "        rank = prefix.split(\"/\").length - 1;\n" +
+            "    }\n" +
+            "    \n" +
+            "    public bool matches_route(RouteContext context) {\n" +
+            "        // Returns true if prefix segments match\n" +
+            "        // the beginning of the route segments\n" +
+            "        return context.matched_route.route_segments\n" +
+            "            .starts_with(this.prefix_segments);\n" +
+            "    }\n" +
+            "}\n\n" +
+            "// Example: prefix \"/admin\" matches:\n" +
+            "//   /admin          ✓ (exact match)\n" +
+            "//   /admin/users    ✓ (prefix match)\n" +
+            "//   /admin/settings ✓ (prefix match)\n" +
+            "//   /user/admin     ✗ (not a prefix match)";
+        
+        // Multiple templates
+        var multiple_templates = get_component_child<CodeBlockComponent>("multiple-templates");
+        multiple_templates.language = "vala";
+        multiple_templates.code = "// In Main.vala:\n\n" +
+            "var spry_cfg = application.configure_with<SpryConfigurator>();\n\n" +
+            "// Site-wide template (rank 0, matches everything)\n" +
+            "spry_cfg.add_template<MainTemplate>(\"\");\n\n" +
+            "// Admin section template (rank 1, matches /admin/*)\n" +
+            "spry_cfg.add_template<AdminTemplate>(\"/admin\");\n\n" +
+            "// Documentation template (rank 1, matches /docs/*)\n" +
+            "spry_cfg.add_template<DocsTemplate>(\"/docs\");\n\n" +
+            "// API docs template (rank 2, matches /docs/api/*)\n" +
+            "spry_cfg.add_template<ApiDocsTemplate>(\"/docs/api\");";
+        
+        // Admin template example
+        var admin_template = get_component_child<CodeBlockComponent>("admin-template");
+        admin_template.language = "vala";
+        admin_template.code = "using Spry;\n\n" +
+            "/**\n" +
+            " * AdminTemplate - Layout for admin section\n" +
+            " * \n" +
+            " * Adds an admin sidebar to all /admin/* pages.\n" +
+            " */\n" +
+            "public class AdminTemplate : PageTemplate {\n" +
+            "    \n" +
+            "    public override string markup { get {\n" +
+            "        return \"\"\"\n" +
+            "        <div class=\"admin-layout\">\n" +
+            "            <aside class=\"admin-sidebar\">\n" +
+            "                <h3>Admin</h3>\n" +
+            "                <nav>\n" +
+            "                    <a href=\"/admin\">Dashboard</a>\n" +
+            "                    <a href=\"/admin/users\">Users</a>\n" +
+            "                    <a href=\"/admin/settings\">Settings</a>\n" +
+            "                </nav>\n" +
+            "            </aside>\n" +
+            "            \n" +
+            "            <div class=\"admin-content\">\n" +
+            "                <!-- Page content inserted here -->\n" +
+            "                <spry-template-outlet />\n" +
+            "            </div>\n" +
+            "        </div>\n" +
+            "        \"\"\";\n" +
+            "    }}\n" +
+            "}\n\n" +
+            "// Register with /admin prefix\n" +
+            "// spry_cfg.add_template<AdminTemplate>(\"/admin\");";
+    }
+}

+ 414 - 0
demo/Pages/StaticResourcesMkssrPage.vala

@@ -0,0 +1,414 @@
+using Spry;
+using Inversion;
+
+/**
+ * StaticResourcesMkssrPage - Documentation for the spry-mkssr tool
+ * 
+ * This page explains how to use spry-mkssr to generate static resources
+ * for Spry applications.
+ */
+public class StaticResourcesMkssrPage : PageComponent {
+    
+    public const string ROUTE = "/static-resources/spry-mkssr";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>spry-mkssr Tool</h1>
+            
+            <section class="doc-section">
+                <h2>What is spry-mkssr?</h2>
+                <p>
+                    <code>spry-mkssr</code> is a command-line tool that generates Spry Static Resource
+                    files from any input file. It handles compression, hashing, and metadata generation
+                    so your web application can serve optimized static content efficiently.
+                </p>
+                <p>
+                    The tool can generate two types of output:
+                </p>
+                <ul>
+                    <li><strong>SSR files</strong> (<code>.ssr</code>) - Pre-compiled resource files read at runtime</li>
+                    <li><strong>Vala source files</strong> - Embedded resources compiled into your binary</li>
+                </ul>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Command-Line Usage</h2>
+                
+                <spry-component name="CodeBlockComponent" sid="usage-code"/>
+                
+                <h3>Options</h3>
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Option</th>
+                            <th>Description</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td><code>-o, --output=FILE</code></td>
+                            <td>Output file name (default: input.ssr or ClassNameResource.vala)</td>
+                        </tr>
+                        <tr>
+                            <td><code>-c, --content-type=TYPE</code></td>
+                            <td>Override content type (e.g., text/css, application/javascript)</td>
+                        </tr>
+                        <tr>
+                            <td><code>-n, --name=NAME</code></td>
+                            <td>Override resource name (default: input filename)</td>
+                        </tr>
+                        <tr>
+                            <td><code>--vala</code></td>
+                            <td>Generate Vala source file instead of SSR</td>
+                        </tr>
+                        <tr>
+                            <td><code>--ns=NAMESPACE</code></td>
+                            <td>Namespace for generated Vala class (requires --vala)</td>
+                        </tr>
+                        <tr>
+                            <td><code>-v, --version</code></td>
+                            <td>Show version information</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Generating SSR Files</h2>
+                <p>
+                    By default, spry-mkssr generates <code>.ssr</code> files that can be read
+                    at runtime by <code>FileStaticResource</code>:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="ssr-example"/>
+                
+                <p>
+                    The generated SSR file contains:
+                </p>
+                <ul>
+                    <li><strong>Magic header</strong> - "spry-sr\0" identifier</li>
+                    <li><strong>Resource name</strong> - Used in URLs</li>
+                    <li><strong>Content type</strong> - MIME type for HTTP headers</li>
+                    <li><strong>SHA-512 hash</strong> - For ETag generation</li>
+                    <li><strong>Encoding headers</strong> - Offset and size for each encoding</li>
+                    <li><strong>Compressed data</strong> - gzip, zstd, brotli, and identity</li>
+                </ul>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Generating Vala Source Files</h2>
+                <p>
+                    Use the <code>--vala</code> flag to generate a Vala source file that embeds
+                    the resource directly into your binary:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="vala-example"/>
+                
+                <p>
+                    This creates a class that extends <code>ConstantStaticResource</code> with
+                    the resource data embedded as <code>const uint8[]</code> arrays.
+                </p>
+                
+                <h3>Namespace Configuration</h3>
+                <p>
+                    Use <code>--ns</code> to place the generated class in a namespace:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="namespace-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Generated Vala Class Structure</h2>
+                <p>
+                    The generated Vala class follows this structure:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="class-structure"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Integrating with Meson</h2>
+                <p>
+                    The recommended way to use spry-mkssr is with meson's 
+                    <code>custom_target</code> to generate resources at build time:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="meson-example"/>
+                
+                <h3>Complete meson.build Example</h3>
+                <spry-component name="CodeBlockComponent" sid="meson-complete"/>
+                
+                <div class="info-box">
+                    <p>
+                        <strong>💡 Tip:</strong> The <code>@INPUT@</code> and <code>@OUTPUT@</code>
+                        placeholders are automatically replaced by meson with the actual file paths.
+                    </p>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Compression Details</h2>
+                <p>
+                    spry-mkssr compresses resources with the highest compression levels:
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Encoding</th>
+                            <th>Level</th>
+                            <th>Best For</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td>gzip</td>
+                            <td>9 (maximum)</td>
+                            <td>Universal compatibility</td>
+                        </tr>
+                        <tr>
+                            <td>zstd</td>
+                            <td>19 (maximum)</td>
+                            <td>Modern browsers, fast decompression</td>
+                        </tr>
+                        <tr>
+                            <td>brotli</td>
+                            <td>11 (maximum)</td>
+                            <td>Best compression for text assets</td>
+                        </tr>
+                        <tr>
+                            <td>identity</td>
+                            <td>N/A</td>
+                            <td>No compression (always included)</td>
+                        </tr>
+                    </tbody>
+                </table>
+                
+                <p>
+                    Encodings are only included if they result in smaller file sizes than
+                    the original. The tool reports compression statistics during generation:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="compression-output"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Content Type Detection</h2>
+                <p>
+                    spry-mkssr automatically detects content types based on file extensions
+                    using GLib's content type guessing. You can override this with the
+                    <code>-c</code> flag:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="content-type-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Resource Naming</h2>
+                <p>
+                    By default, the resource name is derived from the input filename. For
+                    Vala source generation, the class name is created by converting the
+                    resource name to PascalCase and appending "Resource":
+                </p>
+                
+                <table class="doc-table">
+                    <thead>
+                        <tr>
+                            <th>Input File</th>
+                            <th>Resource Name</th>
+                            <th>Generated Class</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr>
+                            <td>docs.css</td>
+                            <td>docs.css</td>
+                            <td>DocsResource.vala → DocsResource</td>
+                        </tr>
+                        <tr>
+                            <td>htmx.min.js</td>
+                            <td>htmx.min.js</td>
+                            <td>HtmxMinResource.vala → HtmxMinResource</td>
+                        </tr>
+                        <tr>
+                            <td>-n htmx.js htmx.min.js</td>
+                            <td>htmx.js</td>
+                            <td>HtmxResource.vala → HtmxResource</td>
+                        </tr>
+                    </tbody>
+                </table>
+                
+                <p>
+                    Use the <code>-n</code> flag to set a specific resource name:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="naming-example"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/static-resources/overview" class="nav-card">
+                        <h3>← Static Resources Overview</h3>
+                        <p>Back to static resources introduction</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        var usage_code = get_component_child<CodeBlockComponent>("usage-code");
+        usage_code.language = "Bash";
+        usage_code.code = "spry-mkssr [OPTION?] INPUT_FILE\n\n" +
+            "# Examples:\n" +
+            "spry-mkssr styles.css                    # Creates styles.css.ssr\n" +
+            "spry-mkssr -o bundle.ssr app.js          # Creates bundle.ssr\n" +
+            "spry-mkssr --vala logo.png               # Creates LogoResource.vala\n" +
+            "spry-mkssr --vala --ns=MyApp styles.css  # Creates StylesResource.vala in MyApp namespace";
+        
+        var ssr_example = get_component_child<CodeBlockComponent>("ssr-example");
+        ssr_example.language = "Bash";
+        ssr_example.code = "# Generate an SSR file from a CSS file\n" +
+            "spry-mkssr styles.css\n" +
+            "# Output: styles.css.ssr\n\n" +
+            "# Generate with custom output name\n" +
+            "spry-mkssr -o resources/bundle.css.ssr styles.css\n" +
+            "# Output: resources/bundle.css.ssr\n\n" +
+            "# The resource name in URLs will be \"styles.css\"\n" +
+            "# URL: /_spry/res/styles.css";
+        
+        var vala_example = get_component_child<CodeBlockComponent>("vala-example");
+        vala_example.language = "Bash";
+        vala_example.code = "# Generate a Vala source file for embedding\n" +
+            "spry-mkssr --vala styles.css\n" +
+            "# Output: StylesResource.vala\n\n" +
+            "# The generated class name is derived from the filename\n" +
+            "# styles.css → StylesResource";
+        
+        var namespace_example = get_component_child<CodeBlockComponent>("namespace-example");
+        namespace_example.language = "Bash";
+        namespace_example.code = "# Generate with namespace\n" +
+            "spry-mkssr --vala --ns=MyApp.Static styles.css\n" +
+            "# Output: StylesResource.vala\n\n" +
+            "# Generated class:\n" +
+            "# namespace MyApp.Static {\n" +
+            "#     public class StylesResource : ConstantStaticResource {\n" +
+            "#         ...\n" +
+            "#     }\n" +
+            "# }";
+        
+        var class_structure = get_component_child<CodeBlockComponent>("class-structure");
+        class_structure.language = "Vala";
+        class_structure.code = "// Generated by spry-mkssr\n" +
+            "public class DocsResource : ConstantStaticResource {\n\n" +
+            "    // Resource metadata\n" +
+            "    public override string name { get { return \"docs.css\"; } }\n" +
+            "    public override string file_hash { get { return \"a1b2c3d4...\"; } }\n" +
+            "    public override string content_type { get { return \"text/css\"; } }\n\n" +
+            "    // Select best encoding based on client support\n" +
+            "    public override string get_best_encoding(Set<string> supported) {\n" +
+            "        if (supported.has(\"br\")) return \"br\";\n" +
+            "        if (supported.has(\"zstd\")) return \"zstd\";\n" +
+            "        if (supported.has(\"gzip\")) return \"gzip\";\n" +
+            "        return \"identity\";\n" +
+            "    }\n\n" +
+            "    // Return the data for a specific encoding\n" +
+            "    public override unowned uint8[] get_encoding(string encoding) {\n" +
+            "        switch (encoding) {\n" +
+            "            case \"br\": return BR_DATA;\n" +
+            "            case \"zstd\": return ZSTD_DATA;\n" +
+            "            case \"gzip\": return GZIP_DATA;\n" +
+            "            default: return IDENTITY_DATA;\n" +
+            "        }\n" +
+            "    }\n\n" +
+            "    // Embedded compressed data\n" +
+            "    private const uint8[] IDENTITY_DATA = { /* ... */ };\n" +
+            "    private const uint8[] GZIP_DATA = { /* ... */ };\n" +
+            "    private const uint8[] ZSTD_DATA = { /* ... */ };\n" +
+            "    private const uint8[] BR_DATA = { /* ... */ };\n" +
+            "}";
+        
+        var meson_example = get_component_child<CodeBlockComponent>("meson-example");
+        meson_example.language = "Meson";
+        meson_example.code = "# Generate a Vala resource file from CSS\n" +
+            "docs_css_resource = custom_target('docs-css-resource',\n" +
+            "    input: 'Static/docs.css',\n" +
+            "    output: 'DocsCssResource.vala',\n" +
+            "    command: [spry_mkssr, '--vala', '--ns=Demo.Static', \n" +
+            "              '-n', 'docs.css', '-c', 'text/css', \n" +
+            "              '-o', '@OUTPUT@', '@INPUT@']\n" +
+            ")\n\n" +
+            "# Then include in your executable sources:\n" +
+            "executable('my-app',\n" +
+            "    app_sources,\n" +
+            "    docs_css_resource,  # Add generated file\n" +
+            "    dependencies: [spry_dep]\n" +
+            ")";
+        
+        var meson_complete = get_component_child<CodeBlockComponent>("meson-complete");
+        meson_complete.language = "Meson";
+        meson_complete.code = "# Find the spry-mkssr tool\n" +
+            "spry_mkssr = find_program('spry-mkssr')\n\n" +
+            "# Generate multiple resources\n" +
+            "css_resource = custom_target('css-resource',\n" +
+            "    input: 'Static/styles.css',\n" +
+            "    output: 'StylesResource.vala',\n" +
+            "    command: [spry_mkssr, '--vala', '--ns=MyApp.Resources',\n" +
+            "              '-o', '@OUTPUT@', '@INPUT@']\n" +
+            ")\n\n" +
+            "js_resource = custom_target('js-resource',\n" +
+            "    input: 'Static/app.js',\n" +
+            "    output: 'AppResource.vala',\n" +
+            "    command: [spry_mkssr, '--vala', '--ns=MyApp.Resources',\n" +
+            "              '-c', 'application/javascript',\n" +
+            "              '-o', '@OUTPUT@', '@INPUT@']\n" +
+            ")\n\n" +
+            "# Build executable with embedded resources\n" +
+            "executable('my-app',\n" +
+            "    'Main.vala',\n" +
+            "    css_resource,\n" +
+            "    js_resource,\n" +
+            "    dependencies: [spry_dep]\n" +
+            ")";
+        
+        var compression_output = get_component_child<CodeBlockComponent>("compression-output");
+        compression_output.language = "Bash";
+        compression_output.code = "$ spry-mkssr --vala docs.css\n" +
+            "Compressing with gzip...\n" +
+            "  gzip: 16094 -> 3245 bytes (20.2%)\n" +
+            "Compressing with zstd...\n" +
+            "  zstd: 16094 -> 2987 bytes (18.6%)\n" +
+            "Compressing with brotli...\n" +
+            "  brotli: 16094 -> 2654 bytes (16.5%)\n" +
+            "Generated: DocsResource.vala";
+        
+        var content_type_example = get_component_child<CodeBlockComponent>("content-type-example");
+        content_type_example.language = "Bash";
+        content_type_example.code = "# Automatic detection (recommended)\n" +
+            "spry-mkssr styles.css        # → text/css\n" +
+            "spry-mkssr app.js            # → application/javascript\n" +
+            "spry-mkssr logo.png          # → image/png\n\n" +
+            "# Manual override\n" +
+            "spry-mkssr -c text/plain data.json   # Force text/plain\n" +
+            "spry-mkssr -c application/wasm module.wasm  # Custom MIME type";
+        
+        var naming_example = get_component_child<CodeBlockComponent>("naming-example");
+        naming_example.language = "Bash";
+        naming_example.code = "# Default naming\n" +
+            "spry-mkssr --vala htmx.min.js\n" +
+            "# Resource: \"htmx.min.js\", Class: HtmxMinResource\n\n" +
+            "# Custom resource name\n" +
+            "spry-mkssr --vala -n htmx.js htmx.min.js\n" +
+            "# Resource: \"htmx.js\", Class: HtmxResource\n" +
+            "# URL: /_spry/res/htmx.js\n\n" +
+            "# This is useful for:\n" +
+            "# - Hiding .min suffix from URLs\n" +
+            "# - Using versioned files with clean names\n" +
+            "# - Renaming resources for organization";
+    }
+}

+ 259 - 0
demo/Pages/StaticResourcesOverviewPage.vala

@@ -0,0 +1,259 @@
+using Spry;
+using Inversion;
+
+/**
+ * StaticResourcesOverviewPage - Introduction to Spry Static Resources
+ * 
+ * This page provides a comprehensive overview of static resource handling in Spry,
+ * including the different types of resources and how to use them.
+ */
+public class StaticResourcesOverviewPage : PageComponent {
+    
+    public const string ROUTE = "/static-resources/overview";
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div sid="page" class="doc-content">
+            <h1>Static Resources Overview</h1>
+            
+            <section class="doc-section">
+                <h2>What are Static Resources?</h2>
+                <p>
+                    Spry provides a built-in system for serving static assets like CSS, JavaScript,
+                    images, and fonts. The static resource system handles content negotiation,
+                    compression, and caching automatically.
+                </p>
+                <p>
+                    Unlike traditional web frameworks that serve static files directly from disk,
+                    Spry's static resources are pre-compressed and can be embedded directly into
+                    your binary for single-binary deployments.
+                </p>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Types of Static Resources</h2>
+                <p>
+                    Spry provides three implementations of the 
+                    <code>StaticResource</code> interface:
+                </p>
+                
+                <div class="resource-types">
+                    <div class="resource-type-card">
+                        <h3>FileStaticResource</h3>
+                        <p>
+                            Reads pre-compiled <code>.ssr</code> (Spry Static Resource) files from
+                            disk at runtime. These files contain the resource data along with
+                            pre-compressed versions (gzip, zstd, brotli) and metadata.
+                        </p>
+                        <ul>
+                            <li>Loaded from <code>.ssr</code> files generated by spry-mkssr</li>
+                            <li>Contains all compression variants in a single file</li>
+                            <li>Includes SHA-512 hash for ETags</li>
+                        </ul>
+                    </div>
+                    
+                    <div class="resource-type-card">
+                        <h3>MemoryStaticResource</h3>
+                        <p>
+                            Holds static resource data entirely in memory. Useful for
+                            dynamically generated resources or when you want to load
+                            resources from a custom source.
+                        </p>
+                        <ul>
+                            <li>All data held in RAM</li>
+                            <li>Supports all compression formats</li>
+                            <li>Created programmatically at runtime</li>
+                        </ul>
+                    </div>
+                    
+                    <div class="resource-type-card">
+                        <h3>ConstantStaticResource</h3>
+                        <p>
+                            Embeds static resources directly into your binary at compile time.
+                            The resource data becomes part of your executable, enabling true
+                            single-binary deployments.
+                        </p>
+                        <ul>
+                            <li>Compiled into your binary</li>
+                            <li>Generated by spry-mkssr with <code>--vala</code> flag</li>
+                            <li>Zero runtime file I/O</li>
+                        </ul>
+                    </div>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The StaticResource Interface</h2>
+                <p>
+                    All static resources implement the <code>StaticResource</code> interface,
+                    which defines the contract for serving static content:
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="interface-code"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>The StaticResourceProvider Endpoint</h2>
+                <p>
+                    Spry includes a built-in endpoint for serving static resources. The
+                    <code>StaticResourceProvider</code> handles all the complexity of content
+                    negotiation and caching.
+                </p>
+                
+                <h3>Route Pattern</h3>
+                <p>
+                    Static resources are served from the route:
+                </p>
+                <div class="code-inline">
+                    <code>/_spry/res/{resource}</code>
+                </div>
+                <p>
+                    Where <code>{resource}</code> is the name of the resource (e.g., 
+                    <code>docs.css</code>, <code>htmx.js</code>).
+                </p>
+                
+                <h3>Content Negotiation</h3>
+                <p>
+                    The provider automatically selects the best compression format based on
+                    the client's <code>Accept-Encoding</code> header. Supported encodings:
+                </p>
+                <ul>
+                    <li><strong>gzip</strong> - Universal browser support</li>
+                    <li><strong>zstd</strong> - Modern, high-performance compression</li>
+                    <li><strong>br</strong> (Brotli) - Best compression ratios for text</li>
+                    <li><strong>identity</strong> - No compression (always available)</li>
+                </ul>
+                
+                <h3>ETag-Based Caching</h3>
+                <p>
+                    Each resource generates an ETag based on its content hash and encoding.
+                    The provider handles <code>If-None-Match</code> requests, returning
+                    <code>304 Not Modified</code> when the client's cached version is current.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="etag-code"/>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Using Resources in Templates</h2>
+                <p>
+                    Spry provides the <code>spry-res</code> attribute for easily referencing
+                    static resources in your HTML templates. This attribute automatically
+                    generates the correct URL for your resource.
+                </p>
+                
+                <spry-component name="CodeBlockComponent" sid="template-usage"/>
+                
+                <p>
+                    The <code>spry-res</code> attribute works with any element that has
+                    <code>href</code> or <code>src</code> attributes:
+                </p>
+                <ul>
+                    <li><code><link></code> for stylesheets</li>
+                    <li><code><script></code> for JavaScript</li>
+                    <li><code><img></code> for images</li>
+                    <li><code><source></code> for media elements</li>
+                </ul>
+            </section>
+            
+            <section class="doc-section">
+                <h2>How It Works</h2>
+                <div class="lifecycle-diagram">
+                    <div class="lifecycle-step">
+                        <div class="step-number">1</div>
+                        <div class="step-content">
+                            <h4>Build Time</h4>
+                            <p>Use spry-mkssr to generate .ssr files or Vala source files</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">2</div>
+                        <div class="step-content">
+                            <h4>Registration</h4>
+                            <p>Register resources via dependency injection</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">3</div>
+                        <div class="step-content">
+                            <h4>Request</h4>
+                            <p>Client requests /_spry/res/resource-name</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">4</div>
+                        <div class="step-content">
+                            <h4>Negotiation</h4>
+                            <p>Server selects best encoding based on Accept-Encoding</p>
+                        </div>
+                    </div>
+                    <div class="lifecycle-arrow">↓</div>
+                    <div class="lifecycle-step">
+                        <div class="step-number">5</div>
+                        <div class="step-content">
+                            <h4>Response</h4>
+                            <p>Compressed content sent with ETag for caching</p>
+                        </div>
+                    </div>
+                </div>
+            </section>
+            
+            <section class="doc-section">
+                <h2>Next Steps</h2>
+                <div class="nav-cards">
+                    <a href="/static-resources/spry-mkssr" class="nav-card">
+                        <h3>spry-mkssr Tool →</h3>
+                        <p>Learn how to generate static resources</p>
+                    </a>
+                </div>
+            </section>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        var interface_code = get_component_child<CodeBlockComponent>("interface-code");
+        interface_code.language = "Vala";
+        interface_code.code = "public interface StaticResource : Object {\n" +
+            "    // The unique name of this resource (e.g., \"docs.css\")\n" +
+            "    public abstract string name { get; }\n\n" +
+            "    // Returns the best encoding supported by the client\n" +
+            "    public abstract string get_best_encoding(Set<string> supported);\n\n" +
+            "    // Returns the ETag for a specific encoding\n" +
+            "    public abstract string get_etag_for(string encoding);\n\n" +
+            "    // Converts the resource to an HTTP result\n" +
+            "    public abstract HttpResult to_result(string encoding, \n" +
+            "                                         StatusCode status = StatusCode.OK) \n" +
+            "        throws Error;\n" +
+            "}";
+        
+        var etag_code = get_component_child<CodeBlockComponent>("etag-code");
+        etag_code.language = "Vala";
+        etag_code.code = "// ETag format: \"{encoding}-{hash}\"\n" +
+            "// Example: \"br-a1b2c3d4e5f6...\"\n\n" +
+            "// The provider checks If-None-Match automatically:\n" +
+            "if (request.headers.get_or_empty(\"If-None-Match\").contains(etag)) {\n" +
+            "    return new HttpEmptyResult(StatusCode.NOT_MODIFIED);\n" +
+            "}\n\n" +
+            "// Fresh content is returned with the ETag header\n" +
+            "response.set_header(\"ETag\", etag);";
+        
+        var template_usage = get_component_child<CodeBlockComponent>("template-usage");
+        template_usage.language = "HTML";
+        template_usage.code = "<!-- Stylesheets -->\n" +
+            "<link rel=\"stylesheet\" spry-res=\"docs.css\">\n\n" +
+            "<!-- JavaScript -->\n" +
+            "<script spry-res=\"htmx.js\"></script>\n" +
+            "<script spry-res=\"htmx-sse.js\"></script>\n\n" +
+            "<!-- Images -->\n" +
+            "<img spry-res=\"logo.png\" alt=\"Logo\">\n\n" +
+            "<!-- Results in URLs like: -->\n" +
+            "<!-- /_spry/res/docs.css -->\n" +
+            "<!-- /_spry/res/htmx.js -->";
+    }
+}

+ 1190 - 0
demo/Static/docs.css

@@ -0,0 +1,1190 @@
+/* ==========================================================================
+   Documentation Site CSS Theme
+   A clean, minimal theme for documentation sites
+   ========================================================================== */
+
+/* ==========================================================================
+   CSS Custom Properties (Variables)
+   ========================================================================== */
+
+:root {
+  /* Colors - Light Mode */
+  --color-background: #ffffff;
+  --color-sidebar-bg: #f8f9fa;
+  --color-text: #24292f;
+  --color-text-muted: #57606a;
+  --color-primary: #0969da;
+  --color-primary-hover: #0550ae;
+  --color-border: #d0d7de;
+  --color-code-bg: #f6f8fa;
+  --color-code-border: #d0d7de;
+  --color-table-header-bg: #f6f8fa;
+  --color-blockquote-border: #d0d7de;
+  --color-blockquote-bg: #f6f8fa;
+  --color-hover-bg: #eaeef2;
+  --color-active-bg: #ddf4ff;
+  --color-active-border: #0969da;
+
+  /* Layout */
+  --sidebar-width: 280px;
+  --content-max-width: 800px;
+  --sidebar-padding: 1.5rem;
+  --content-padding: 2rem;
+
+  /* Typography */
+  --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+  --font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+  --font-size-base: 16px;
+  --font-size-small: 0.875rem;
+  --font-size-smaller: 0.75rem;
+  --line-height-base: 1.65;
+  --line-height-heading: 1.25;
+
+  /* Spacing */
+  --spacing-xs: 0.25rem;
+  --spacing-sm: 0.5rem;
+  --spacing-md: 1rem;
+  --spacing-lg: 1.5rem;
+  --spacing-xl: 2rem;
+  --spacing-xxl: 3rem;
+
+  /* Transitions */
+  --transition-fast: 150ms ease;
+  --transition-normal: 250ms ease;
+}
+
+/* ==========================================================================
+   Base Reset & Typography
+   ========================================================================== */
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+html {
+  font-size: var(--font-size-base);
+  scroll-behavior: smooth;
+}
+
+body {
+  margin: 0;
+  padding: 0;
+  font-family: var(--font-family);
+  font-size: 1rem;
+  line-height: var(--line-height-base);
+  color: var(--color-text);
+  background-color: var(--color-background);
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* Headings */
+h1, h2, h3, h4, h5, h6 {
+  margin: var(--spacing-xxl) 0 var(--spacing-md);
+  font-weight: 600;
+  line-height: var(--line-height-heading);
+  color: var(--color-text);
+}
+
+h1 {
+  font-size: 2rem;
+  padding-bottom: var(--spacing-md);
+  border-bottom: 1px solid var(--color-border);
+}
+
+h2 {
+  font-size: 1.5rem;
+  padding-bottom: var(--spacing-sm);
+  border-bottom: 1px solid var(--color-border);
+}
+
+h3 {
+  font-size: 1.25rem;
+}
+
+h4 {
+  font-size: 1.125rem;
+}
+
+h5 {
+  font-size: 1rem;
+}
+
+h6 {
+  font-size: var(--font-size-small);
+  color: var(--color-text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+/* First heading should not have top margin */
+h1:first-child,
+h2:first-child,
+h3:first-child {
+  margin-top: 0;
+}
+
+/* Paragraphs */
+p {
+  margin: var(--spacing-md) 0;
+}
+
+/* ==========================================================================
+   Layout
+   ========================================================================== */
+
+.docs-container {
+  display: flex;
+  min-height: 100vh;
+}
+
+/* Sidebar Navigation */
+.docs-sidebar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: var(--sidebar-width);
+  height: 100vh;
+  background-color: var(--color-sidebar-bg);
+  border-right: 1px solid var(--color-border);
+  overflow-y: auto;
+  padding: var(--sidebar-padding);
+  z-index: 100;
+}
+
+.docs-sidebar-header {
+  padding-bottom: var(--spacing-md);
+  margin-bottom: var(--spacing-md);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.docs-sidebar-logo {
+  font-size: 1.25rem;
+  font-weight: 600;
+  color: var(--color-text);
+  text-decoration: none;
+}
+
+.docs-sidebar-logo:hover {
+  color: var(--color-primary);
+}
+
+/* Main Content Area */
+.docs-main {
+  margin-left: var(--sidebar-width);
+  flex: 1;
+  min-width: 0;
+}
+
+.docs-content {
+  max-width: var(--content-max-width);
+  margin: 0 auto;
+  padding: var(--content-padding);
+}
+
+/* ==========================================================================
+   Navigation Sidebar
+   ========================================================================== */
+
+/* NavSidebarComponent styles */
+.nav-sidebar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: var(--sidebar-width);
+  height: 100vh;
+  background-color: var(--color-sidebar-bg);
+  border-right: 1px solid var(--color-border);
+  overflow-y: auto;
+  padding: var(--sidebar-padding);
+  z-index: 100;
+}
+
+.nav-section {
+  margin-bottom: var(--spacing-md);
+}
+
+.nav-section-title {
+  padding: var(--spacing-sm) var(--spacing-md);
+  margin: 0 0 var(--spacing-xs);
+  font-size: var(--font-size-small);
+  font-weight: 600;
+  color: var(--color-text);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+.nav-items {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.nav-item {
+  display: block;
+  padding: var(--spacing-sm) var(--spacing-md);
+  color: var(--color-text-muted);
+  text-decoration: none;
+  font-size: var(--font-size-small);
+  border-radius: 4px;
+  border-left: 3px solid transparent;
+  transition: all var(--transition-fast);
+}
+
+.nav-item:hover {
+  color: var(--color-text);
+  background-color: var(--color-hover-bg);
+  text-decoration: none;
+}
+
+.nav-item.active {
+  color: var(--color-primary);
+  background-color: var(--color-active-bg);
+  border-left-color: var(--color-active-border);
+  font-weight: 500;
+}
+
+.docs-nav {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.docs-nav-section {
+  margin-bottom: var(--spacing-md);
+}
+
+.docs-nav-section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: var(--spacing-sm) var(--spacing-md);
+  font-size: var(--font-size-small);
+  font-weight: 600;
+  color: var(--color-text);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  cursor: pointer;
+  background: none;
+  border: none;
+  width: 100%;
+  text-align: left;
+  border-radius: 4px;
+  transition: background-color var(--transition-fast);
+}
+
+.docs-nav-section-title:hover {
+  background-color: var(--color-hover-bg);
+}
+
+.docs-nav-section-title::after {
+  content: "";
+  display: inline-block;
+  width: 6px;
+  height: 6px;
+  border-right: 2px solid currentColor;
+  border-bottom: 2px solid currentColor;
+  transform: rotate(-45deg);
+  transition: transform var(--transition-fast);
+}
+
+.docs-nav-section.collapsed .docs-nav-section-title::after {
+  transform: rotate(45deg);
+}
+
+.docs-nav-section.collapsed .docs-nav-items {
+  display: none;
+}
+
+.docs-nav-items {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.docs-nav-item {
+  margin: 0;
+}
+
+.docs-nav-link {
+  display: block;
+  padding: var(--spacing-sm) var(--spacing-md);
+  color: var(--color-text-muted);
+  text-decoration: none;
+  font-size: var(--font-size-small);
+  border-radius: 4px;
+  border-left: 3px solid transparent;
+  transition: all var(--transition-fast);
+}
+
+.docs-nav-link:hover {
+  color: var(--color-text);
+  background-color: var(--color-hover-bg);
+}
+
+.docs-nav-link.active {
+  color: var(--color-primary);
+  background-color: var(--color-active-bg);
+  border-left-color: var(--color-active-border);
+  font-weight: 500;
+}
+
+/* Nested navigation items */
+.docs-nav-items .docs-nav-items {
+  margin-left: var(--spacing-md);
+}
+
+.docs-nav-items .docs-nav-items .docs-nav-link {
+  padding-left: var(--spacing-lg);
+  font-size: calc(var(--font-size-small) - 0.0625rem);
+}
+
+/* ==========================================================================
+   Links
+   ========================================================================== */
+
+a {
+  color: var(--color-primary);
+  text-decoration: none;
+  transition: color var(--transition-fast);
+}
+
+a:hover {
+  color: var(--color-primary-hover);
+  text-decoration: underline;
+}
+
+a:focus {
+  outline: 2px solid var(--color-primary);
+  outline-offset: 2px;
+}
+
+/* ==========================================================================
+   Code Blocks
+   ========================================================================== */
+
+code {
+  font-family: var(--font-family-mono);
+  font-size: 0.9em;
+  padding: var(--spacing-xs) var(--spacing-sm);
+  background-color: var(--color-code-bg);
+  border-radius: 4px;
+  border: 1px solid var(--color-code-border);
+}
+
+/* Inline code */
+p > code,
+li > code,
+td > code {
+  font-size: 0.85em;
+}
+
+/* Block code */
+pre {
+  margin: var(--spacing-lg) 0;
+  padding: var(--spacing-md);
+  background-color: var(--color-code-bg);
+  border: 1px solid var(--color-code-border);
+  border-radius: 6px;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+pre code {
+  display: block;
+  padding: 0;
+  background: none;
+  border: none;
+  font-size: var(--font-size-small);
+  line-height: 1.5;
+  white-space: pre;
+}
+
+/* Code block with language indicator */
+pre[data-lang] {
+  position: relative;
+}
+
+pre[data-lang]::before {
+  content: attr(data-lang);
+  position: absolute;
+  top: var(--spacing-sm);
+  right: var(--spacing-sm);
+  font-size: var(--font-size-smaller);
+  color: var(--color-text-muted);
+  text-transform: uppercase;
+  font-family: var(--font-family);
+}
+
+/* Syntax highlighting base colors (for use with highlighters like Prism.js) */
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+  color: var(--color-text-muted);
+}
+
+.token.punctuation {
+  color: var(--color-text);
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol {
+  color: #0550ae;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin {
+  color: #0a3069;
+}
+
+.token.operator,
+.token.entity,
+.token.url {
+  color: #cf222e;
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+  color: #cf222e;
+}
+
+.token.function,
+.token.class-name {
+  color: #8250df;
+}
+
+.token.regex,
+.token.important,
+.token.variable {
+  color: #953800;
+}
+
+/* ==========================================================================
+   Tables
+   ========================================================================== */
+
+table {
+  width: 100%;
+  margin: var(--spacing-lg) 0;
+  border-collapse: collapse;
+  border-spacing: 0;
+  font-size: var(--font-size-small);
+}
+
+th,
+td {
+  padding: var(--spacing-sm) var(--spacing-md);
+  text-align: left;
+  border: 1px solid var(--color-border);
+}
+
+th {
+  background-color: var(--color-table-header-bg);
+  font-weight: 600;
+}
+
+tr:nth-child(even) {
+  background-color: var(--color-code-bg);
+}
+
+table code {
+  font-size: 0.9em;
+}
+
+/* ==========================================================================
+   Lists
+   ========================================================================== */
+
+ul,
+ol {
+  margin: var(--spacing-md) 0;
+  padding-left: var(--spacing-xl);
+}
+
+li {
+  margin: var(--spacing-xs) 0;
+}
+
+li > ul,
+li > ol {
+  margin: var(--spacing-xs) 0;
+}
+
+/* Definition lists */
+dl {
+  margin: var(--spacing-lg) 0;
+}
+
+dt {
+  font-weight: 600;
+  margin-top: var(--spacing-md);
+}
+
+dd {
+  margin: 0;
+  padding-left: var(--spacing-xl);
+  color: var(--color-text-muted);
+}
+
+/* ==========================================================================
+   Blockquotes
+   ========================================================================== */
+
+blockquote {
+  margin: var(--spacing-lg) 0;
+  padding: var(--spacing-md) var(--spacing-lg);
+  background-color: var(--color-blockquote-bg);
+  border-left: 4px solid var(--color-blockquote-border);
+  border-radius: 0 4px 4px 0;
+}
+
+blockquote p {
+  margin: 0;
+}
+
+blockquote p + p {
+  margin-top: var(--spacing-md);
+}
+
+blockquote code {
+  background-color: rgba(0, 0, 0, 0.05);
+}
+
+/* ==========================================================================
+   Warning Boxes
+   ========================================================================== */
+
+.warning-box {
+  background: #fff3cd;
+  border: 1px solid #ffc107;
+  border-left: 4px solid #ffc107;
+  padding: 1rem;
+  margin: 1rem 0;
+  border-radius: 4px;
+}
+
+.warning-box h4 {
+  margin-top: 0;
+  margin-bottom: 0.5rem;
+  color: #856404;
+}
+
+.warning-box p {
+  margin: 0;
+  color: #856404;
+}
+
+.warning-box ul {
+  margin: 0.5rem 0 0;
+  padding-left: 1.5rem;
+  color: #856404;
+}
+
+.warning-box li {
+  margin: 0.25rem 0;
+}
+
+.warning-box code {
+  background-color: rgba(133, 100, 4, 0.1);
+  border-color: rgba(133, 100, 4, 0.2);
+}
+
+/* ==========================================================================
+   Horizontal Rules
+   ========================================================================== */
+
+hr {
+  margin: var(--spacing-xxl) 0;
+  padding: 0;
+  border: none;
+  border-top: 1px solid var(--color-border);
+}
+
+/* ==========================================================================
+   Page Layout (for full-page components)
+   ========================================================================== */
+
+.page-container {
+  display: flex;
+  min-height: 100vh;
+}
+
+.main-content {
+  margin-left: var(--sidebar-width);
+  flex: 1;
+  min-width: 0;
+}
+
+.doc-content {
+  max-width: var(--content-max-width);
+  margin: 0 auto;
+  padding: var(--content-padding);
+}
+
+.doc-section {
+  margin-bottom: var(--spacing-xxl);
+}
+
+/* ==========================================================================
+   Home Page Styles
+   ========================================================================== */
+
+.home-hero {
+  text-align: center;
+  padding: var(--spacing-xxl) 0;
+  margin-bottom: var(--spacing-xl);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.hero-title {
+  font-size: 3rem;
+  font-weight: 700;
+  margin: 0 0 var(--spacing-sm);
+  color: var(--color-text);
+  border-bottom: none;
+  padding-bottom: 0;
+}
+
+.hero-tagline {
+  font-size: 1.25rem;
+  color: var(--color-text-muted);
+  margin: 0;
+}
+
+.intro {
+  font-size: 1.125rem;
+  line-height: 1.7;
+  color: var(--color-text);
+}
+
+/* Quick Links Grid */
+.quick-links {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+  gap: var(--spacing-lg);
+  margin: var(--spacing-lg) 0;
+}
+
+.quick-link-card {
+  display: block;
+  padding: var(--spacing-lg);
+  background-color: var(--color-sidebar-bg);
+  border: 1px solid var(--color-border);
+  border-radius: 8px;
+  text-decoration: none;
+  transition: all var(--transition-fast);
+}
+
+.quick-link-card:hover {
+  background-color: var(--color-hover-bg);
+  border-color: var(--color-primary);
+  text-decoration: none;
+}
+
+.quick-link-card h3 {
+  margin: 0 0 var(--spacing-sm);
+  font-size: 1.125rem;
+  color: var(--color-primary);
+  border-bottom: none;
+  padding-bottom: 0;
+}
+
+.quick-link-card p {
+  margin: 0;
+  font-size: var(--font-size-small);
+  color: var(--color-text-muted);
+  line-height: 1.5;
+}
+
+/* Features List */
+.features-list {
+  list-style: none;
+  padding: 0;
+  margin: var(--spacing-lg) 0;
+}
+
+.features-list li {
+  padding: var(--spacing-sm) 0;
+  padding-left: var(--spacing-lg);
+  position: relative;
+}
+
+.features-list li::before {
+  content: "✓";
+  position: absolute;
+  left: 0;
+  color: var(--color-primary);
+  font-weight: 600;
+}
+
+.features-list li strong {
+  color: var(--color-text);
+}
+
+/* ==========================================================================
+   Demo Host Component
+   ========================================================================== */
+
+.demo-host {
+  margin: var(--spacing-xl) 0;
+  border: 2px solid var(--color-border);
+  border-radius: 8px;
+  overflow: hidden;
+  background-color: var(--color-background);
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.demo-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: var(--spacing-sm) var(--spacing-md);
+  background-color: var(--color-sidebar-bg);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.demo-title {
+  font-size: var(--font-size-small);
+  font-weight: 600;
+  color: var(--color-text);
+}
+
+.demo-content {
+  padding: var(--spacing-lg);
+  background-color: var(--color-background);
+  min-height: 100px;
+}
+
+/* Demo toggle buttons */
+.demo-toggle-group {
+  display: flex;
+  gap: 2px;
+  background-color: var(--color-code-bg);
+  border-radius: 4px;
+  padding: 2px;
+}
+
+.demo-toggle-btn {
+  padding: var(--spacing-xs) var(--spacing-md);
+  font-family: var(--font-family);
+  font-size: var(--font-size-smaller);
+  font-weight: 500;
+  color: var(--color-text-muted);
+  background-color: transparent;
+  border: none;
+  border-radius: 3px;
+  cursor: pointer;
+  transition: all var(--transition-fast);
+}
+
+.demo-toggle-btn:hover {
+  color: var(--color-text);
+}
+
+.demo-toggle-btn.active {
+  color: var(--color-text);
+  background-color: var(--color-background);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.demo-toggle-btn:focus {
+  outline: 2px solid var(--color-primary);
+  outline-offset: 1px;
+}
+
+/* Source code view */
+.demo-source {
+  margin: 0;
+  padding: var(--spacing-md);
+  background-color: var(--color-code-bg);
+  border-top: 1px solid var(--color-border);
+  overflow-x: auto;
+  font-family: var(--font-family-mono);
+  font-size: var(--font-size-small);
+  line-height: 1.5;
+}
+
+.demo-source code {
+  display: block;
+  padding: 0;
+  background: none;
+  border: none;
+  font-size: inherit;
+  white-space: pre;
+}
+
+/* Legacy demo-frame styles (for backwards compatibility) */
+.demo-frame {
+  border: 1px solid var(--color-border);
+  border-radius: 6px;
+  overflow: hidden;
+  background-color: var(--color-background);
+}
+
+.demo-frame-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: var(--spacing-sm) var(--spacing-md);
+  background-color: var(--color-sidebar-bg);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.demo-frame-title {
+  font-size: var(--font-size-small);
+  font-weight: 600;
+  color: var(--color-text);
+  margin: 0;
+}
+
+.demo-frame-content {
+  padding: var(--spacing-lg);
+}
+
+/* ==========================================================================
+   Code Block Component
+   ========================================================================== */
+
+.code-block {
+  margin: var(--spacing-lg) 0;
+  background-color: var(--color-code-bg);
+  border: 1px solid var(--color-code-border);
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.code-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: var(--spacing-sm) var(--spacing-md);
+  background-color: var(--color-sidebar-bg);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.code-dots {
+  display: flex;
+  gap: 6px;
+}
+
+.code-dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+}
+
+.code-dot.red {
+  background-color: #ff5f56;
+}
+
+.code-dot.yellow {
+  background-color: #ffbd2e;
+}
+
+.code-dot.green {
+  background-color: #27c93f;
+}
+
+.code-lang {
+  font-size: var(--font-size-smaller);
+  font-weight: 500;
+  color: var(--color-text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+.code-block pre {
+  margin: 0;
+  padding: var(--spacing-md);
+  background: none;
+  border: none;
+  overflow-x: auto;
+}
+
+.code-block pre code {
+  display: block;
+  padding: 0;
+  background: none;
+  border: none;
+  font-size: var(--font-size-small);
+  line-height: 1.6;
+  white-space: pre;
+}
+
+/* ==========================================================================
+   Lifecycle Diagram
+   ========================================================================== */
+
+.lifecycle-diagram {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: var(--spacing-xl) 0;
+  padding: var(--spacing-lg);
+  background-color: var(--color-sidebar-bg);
+  border-radius: 8px;
+  border: 1px solid var(--color-border);
+}
+
+.lifecycle-step {
+  display: flex;
+  align-items: flex-start;
+  gap: var(--spacing-md);
+  width: 100%;
+  max-width: 500px;
+  padding: var(--spacing-md);
+  background-color: var(--color-background);
+  border: 1px solid var(--color-border);
+  border-radius: 6px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.step-number {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 32px;
+  height: 32px;
+  background-color: var(--color-primary);
+  color: white;
+  font-weight: 600;
+  font-size: var(--font-size-small);
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.step-content {
+  flex: 1;
+}
+
+.step-content h4 {
+  margin: 0 0 var(--spacing-xs);
+  font-size: 1rem;
+  color: var(--color-text);
+}
+
+.step-content p {
+  margin: 0;
+  font-size: var(--font-size-small);
+  color: var(--color-text-muted);
+}
+
+.lifecycle-arrow {
+  font-size: 1.5rem;
+  color: var(--color-primary);
+  padding: var(--spacing-sm) 0;
+  line-height: 1;
+}
+
+/* ==========================================================================
+   Utility Classes
+   ========================================================================== */
+
+/* Text utilities */
+.text-muted {
+  color: var(--color-text-muted);
+}
+
+.text-small {
+  font-size: var(--font-size-small);
+}
+
+/* Spacing utilities */
+.mt-0 { margin-top: 0; }
+.mt-1 { margin-top: var(--spacing-sm); }
+.mt-2 { margin-top: var(--spacing-md); }
+.mt-3 { margin-top: var(--spacing-lg); }
+.mt-4 { margin-top: var(--spacing-xl); }
+
+.mb-0 { margin-bottom: 0; }
+.mb-1 { margin-bottom: var(--spacing-sm); }
+.mb-2 { margin-bottom: var(--spacing-md); }
+.mb-3 { margin-bottom: var(--spacing-lg); }
+.mb-4 { margin-bottom: var(--spacing-xl); }
+
+/* ==========================================================================
+   Responsive Design
+   ========================================================================== */
+
+/* Tablet and smaller */
+@media (max-width: 1024px) {
+  :root {
+    --sidebar-width: 240px;
+    --content-padding: 1.5rem;
+  }
+}
+
+/* Mobile */
+@media (max-width: 768px) {
+  :root {
+    --sidebar-width: 100%;
+    --content-padding: 1rem;
+  }
+
+  .docs-sidebar {
+    transform: translateX(-100%);
+    transition: transform var(--transition-normal);
+    width: 280px;
+    max-width: 85vw;
+  }
+
+  .docs-sidebar.open {
+    transform: translateX(0);
+  }
+
+  .nav-sidebar {
+    transform: translateX(-100%);
+    transition: transform var(--transition-normal);
+    width: 280px;
+    max-width: 85vw;
+  }
+
+  .nav-sidebar.open {
+    transform: translateX(0);
+  }
+
+  .docs-main {
+    margin-left: 0;
+  }
+
+  /* Mobile menu toggle */
+  .docs-menu-toggle {
+    position: fixed;
+    top: var(--spacing-md);
+    left: var(--spacing-md);
+    z-index: 101;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 40px;
+    height: 40px;
+    padding: 0;
+    background-color: var(--color-background);
+    border: 1px solid var(--color-border);
+    border-radius: 6px;
+    cursor: pointer;
+    transition: background-color var(--transition-fast);
+  }
+
+  .docs-menu-toggle:hover {
+    background-color: var(--color-hover-bg);
+  }
+
+  .docs-menu-toggle::before {
+    content: "";
+    display: block;
+    width: 18px;
+    height: 2px;
+    background-color: var(--color-text);
+    box-shadow: 0 -5px 0 var(--color-text), 0 5px 0 var(--color-text);
+  }
+
+  /* Overlay when sidebar is open */
+  .docs-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 99;
+    opacity: 0;
+    visibility: hidden;
+    transition: opacity var(--transition-normal), visibility var(--transition-normal);
+  }
+
+  .docs-overlay.visible {
+    opacity: 1;
+    visibility: visible;
+  }
+
+  /* Adjust typography for mobile */
+  h1 {
+    font-size: 1.75rem;
+  }
+
+  h2 {
+    font-size: 1.35rem;
+  }
+
+  h3 {
+    font-size: 1.15rem;
+  }
+
+  /* Scrollable tables on mobile */
+  table {
+    display: block;
+    overflow-x: auto;
+    -webkit-overflow-scrolling: touch;
+  }
+
+  /* Home page mobile adjustments */
+  .main-content {
+    margin-left: 0;
+  }
+
+  .hero-title {
+    font-size: 2rem;
+  }
+
+  .hero-tagline {
+    font-size: 1rem;
+  }
+
+  .quick-links {
+    grid-template-columns: 1fr;
+  }
+}
+
+/* Hide mobile menu toggle on desktop */
+@media (min-width: 769px) {
+  .docs-menu-toggle,
+  .docs-overlay {
+    display: none;
+  }
+}
+
+/* ==========================================================================
+   Print Styles
+   ========================================================================== */
+
+@media print {
+  .docs-sidebar,
+  .nav-sidebar,
+  .docs-menu-toggle,
+  .docs-overlay {
+    display: none !important;
+  }
+
+  .docs-main {
+    margin-left: 0 !important;
+  }
+
+  .docs-content {
+    max-width: none !important;
+    padding: 0 !important;
+  }
+
+  a {
+    color: var(--color-text) !important;
+    text-decoration: underline;
+  }
+
+  pre,
+  code {
+    border: none !important;
+    background-color: transparent !important;
+  }
+}

+ 40 - 0
demo/meson.build

@@ -0,0 +1,40 @@
+# Spry Framework Website
+# A modern, techy website showcasing the Spry framework and its ecosystem
+
+website_sources = files(
+    'Main.vala',
+    'MainTemplate.vala',
+    'Pages/HomePage.vala',
+    'Pages/ComponentsOverviewPage.vala',
+    'Pages/ComponentsTemplateSyntaxPage.vala',
+    'Pages/ComponentsActionsPage.vala',
+    'Pages/ComponentsOutletsPage.vala',
+    'Pages/ComponentsContinuationsPage.vala',
+    'Pages/PageComponentsOverviewPage.vala',
+    'Pages/PageTemplatesPage.vala',
+    'Pages/StaticResourcesOverviewPage.vala',
+    'Pages/StaticResourcesMkssrPage.vala',
+    'Components/CodeBlockComponent.vala',
+    'Components/DemoHostComponent.vala',
+    'Components/NavSidebarComponent.vala',
+    'DemoComponents/SimpleCounterDemo.vala',
+    'DemoComponents/TodoListDemo.vala',
+    'DemoComponents/ProgressDemo.vala',
+)
+
+# Generate Vala resource file from CSS
+docs_css_resource = custom_target('docs-css-resource',
+    input: 'Static/docs.css',
+    output: 'DocsCssResource.vala',
+    command: [spry_mkssr, '--vala', '--ns=Demo.Static', '-n', 'docs.css', '-c', 'text/css', '-o', '@OUTPUT@', '@INPUT@']
+)
+
+# Math library for particle physics (sin/cos in explode function)
+m_dep = meson.get_compiler('c').find_library('m', required: false)
+
+executable('spry-demo',
+    website_sources,
+    docs_css_resource,
+    dependencies: [spry_dep, astralis_dep, invercargill_dep, inversion_dep, m_dep],
+    install: true
+)

+ 23 - 21
examples/ProgressExample.vala

@@ -22,6 +22,10 @@ using Spry;
  */
 class ProgressComponent : Component {
     
+    public int percent { get; set; }
+    public string status { get; set; default = "Initializing..."; }
+    public Series<string> completed_tasks { get; set; default = new Series<string>(); }
+    
     public override string markup { get {
         return """
         <!DOCTYPE html>
@@ -75,18 +79,19 @@ class ProgressComponent : Component {
         <p class="info">This example demonstrates Spry's continuation feature for real-time progress updates via Server-Sent Events (SSE).</p>
         
         <div spry-continuation>
-            <div class="progress-container" sse-swap="progress">
-                <div class="progress-bar" id="progress-bar" style="width: 0%">
+            <div class="progress-container" spry-dynamic="progress-bar">
+                <div class="progress-bar" spry-unique
+                content-expr='format("{{this.percent}}%")' style-width-expr='format("{{this.percent}}%")'>
                     0%
                 </div>
             </div>
             
-            <div class="status" sse-swap="status">
-                <strong>Status:</strong> Initializing...
+            <div class="status" spry-dynamic="status">
+                <strong>Status:</strong> <span spry-unique content-expr="this.status">Initializing...</span>
             </div>
             
-            <div class="log" id="log">
-                <div sse-swap="log">Waiting for task to start...</div>
+            <div class="log" sid="log" spry-dynamic="log">
+                <div spry-per-task="this.completed_tasks" content-expr="task"></div>
             </div>
         </div>
         </body>
@@ -101,7 +106,7 @@ class ProgressComponent : Component {
      * The event data should be HTML content that will be swapped into elements
      * with matching sse-swap="eventname" attributes.
      */
-    public async override void continuation(SseStream stream) throws Error {
+    public async override void continuation(ContinuationContext continuation_context) throws Error {
         // Simulate a long-running task with progress updates
         var steps = new string[] {
             "Initializing task...",
@@ -119,20 +124,19 @@ class ProgressComponent : Component {
         };
         
         for (int i = 0; i < steps.length; i++) {
-            // Calculate progress percentage
-            int percent = (int)(((i + 1) / (double)steps.length) * 100);
+            // Update the template
+            percent = (int)(((i + 1) / (double)steps.length) * 100);
+            status = steps[i];
+            completed_tasks.add_start(steps[i]);
             
             // Send progress bar update - HTML that will be swapped into the progress bar
-            yield stream.send_event(new SseEvent.with_type("progress", 
-                @"<div class=\"progress-bar\" id=\"progress-bar\" style=\"width: $percent%\">$percent%</div>"));
+            yield continuation_context.send_dynamic("progress-bar");
             
             // Send status update - HTML that will be swapped into the status div
-            yield stream.send_event(new SseEvent.with_type("status", 
-                @"<strong>Status:</strong> $(steps[i])"));
+            yield continuation_context.send_dynamic("status");
             
             // Send log message - HTML that will be appended to the log
-            yield stream.send_event(new SseEvent.with_type("log", 
-                @"<div class=\"log-entry\">$(steps[i])</div>"));
+            yield continuation_context.send_dynamic("log");
             
             // Simulate work being done (500ms per step)
             Timeout.add(500, () => {
@@ -143,12 +147,10 @@ class ProgressComponent : Component {
         }
         
         // Send final completion messages
-        yield stream.send_event(new SseEvent.with_type("progress", 
-            "<div class=\"progress-bar\" id=\"progress-bar\" style=\"width: 100%\">100% ✓</div>"));
-        yield stream.send_event(new SseEvent.with_type("status", 
-            "<strong>Status:</strong> Task completed successfully!"));
-        yield stream.send_event(new SseEvent.with_type("log", 
-            "<div class=\"log-entry\">✓ All tasks completed!</div>"));
+        percent = 100;
+        status = "Task completed successfully!";
+        yield continuation_context.send_dynamic("progress-bar");
+        yield continuation_context.send_dynamic("status");
     }
 }
 

+ 46 - 73
examples/TodoComponent.vala

@@ -10,8 +10,10 @@ using Spry;
  * Demonstrates using the Spry.Component class with IoC composition.
  * Shows how to:
  *   - Build a complete CRUD interface with Components
+ *   - Use <spry-component name="MyComponent"> for declarative child components
+ *   - Use get_component_child<T>(sid) to access child components
+ *   - Use <spry-outlet> for dynamic lists (multiple items from data)
  *   - Use inject<T>() for dependency injection
- *   - Use ComponentFactory to create component instances
  *   - Use spry-action and spry-target for declarative HTMX interactions
  *   - Use handle_action() for action handling
  * 
@@ -278,8 +280,16 @@ class FeaturesComponent : Component {
                 <code>class MyComponent : Component { public override string markup { get { return "..."; } } }</code>
             </div>
             <div class="feature">
-                <strong>Using Outlets:</strong>
-                <code>&lt;spry-outlet sid="content"/&gt;</code>
+                <strong>Declarative Child Components:</strong>
+                <code>&lt;spry-component name="HeaderComponent" sid="header"/&gt;</code>
+            </div>
+            <div class="feature">
+                <strong>Accessing Child Components:</strong>
+                <code>var header = get_component_child&lt;HeaderComponent&gt;("header");</code>
+            </div>
+            <div class="feature">
+                <strong>Using Outlets (for lists):</strong>
+                <code>&lt;spry-outlet sid="items"/&gt;</code>
             </div>
             <div class="feature">
                 <strong>Preparing Templates:</strong>
@@ -291,11 +301,7 @@ class FeaturesComponent : Component {
             </div>
             <div class="feature">
                 <strong>IoC Injection:</strong>
-                <code>private ComponentFactory factory = inject<ComponentFactory>();</code>
-            </div>
-            <div class="feature">
-                <strong>Creating Components:</strong>
-                <code>var child = factory.create&lt;MyComponent&gt;();</code>
+                <code>private ComponentFactory factory = inject&lt;ComponentFactory&gt;();</code>
             </div>
         </div>
         """;
@@ -318,29 +324,16 @@ class FooterComponent : Component {
 }
 
 /**
- * PageLayoutComponent - The main page structure.
+ * TodoPage - The main page structure.
+ *
+ * Inherits from PageComponent to act as both a Component and Endpoint.
+ * Uses <spry-component name="..."> syntax for declarative child components.
+ * Child components are accessed via get_component_child<T>(sid) in prepare().
  */
-class PageLayoutComponent : Component {
-    
-    public void set_header(Renderable component) {
-        set_outlet_child("header", component);
-    }
+class TodoPage : PageComponent {
     
-    public void set_todo_list(Renderable component) {
-        set_outlet_child("todo-list", component);
-    }
-    
-    public void set_add_form(Renderable component) {
-        set_outlet_child("add-form", component);
-    }
-    
-    public void set_features(Renderable component) {
-        set_outlet_child("features", component);
-    }
-    
-    public void set_footer(Renderable component) {
-        set_outlet_child("footer", component);
-    }
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
     
     public override string markup { get {
         return """
@@ -380,31 +373,20 @@ class PageLayoutComponent : Component {
             </style>
         </head>
         <body>
-            <spry-outlet sid="header"/>
-            <spry-outlet sid="todo-list"/>
-            <spry-outlet sid="add-form"/>
-            <spry-outlet sid="features"/>
-            <spry-outlet sid="footer"/>
+            <spry-component name="HeaderComponent" sid="header"/>
+            <spry-component name="TodoListComponent" sid="todo-list"/>
+            <spry-component name="AddFormComponent" sid="add-form"/>
+            <spry-component name="FeaturesComponent" sid="features"/>
+            <spry-component name="FooterComponent" sid="footer"/>
         </body>
         </html>
         """;
     }}
-}
-
-// Home page endpoint - builds the component tree
-class HomePageEndpoint : Object, Endpoint {
-    private TodoStore todo_store = inject<TodoStore>();
-    private ComponentFactory factory = inject<ComponentFactory>();
     
-    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
-        // Create page layout
-        var page = factory.create<PageLayoutComponent>();
-        
-        // Create header - prepare() fetches stats from store automatically
-        page.set_header(factory.create<HeaderComponent>());
-        
-        // Create todo list - only need to set item_id, prepare() handles the rest
-        var todo_list = factory.create<TodoListComponent>();
+    // Called before serialization to populate the todo list
+    public override async void prepare() throws Error {
+        // Get the TodoListComponent child and populate it
+        var todo_list = get_component_child<TodoListComponent>("todo-list");
         
         var count = todo_store.count();
         if (count == 0) {
@@ -418,19 +400,6 @@ class HomePageEndpoint : Object, Endpoint {
             });
             todo_list.set_items(items);
         }
-        page.set_todo_list(todo_list);
-        
-        // Add form
-        page.set_add_form(factory.create<AddFormComponent>());
-        
-        // Features
-        page.set_features(factory.create<FeaturesComponent>());
-        
-        // Footer
-        page.set_footer(factory.create<FooterComponent>());
-        
-        // to_result() handles all outlet replacement automatically
-        return yield page.to_result();
     }
 }
 
@@ -490,18 +459,22 @@ void main(string[] args) {
         // Register the todo store as singleton
         application.add_singleton<TodoStore>();
         
-        // Register components as transient (created via factory)
-        application.add_transient<EmptyListComponent>();
-        application.add_transient<TodoItemComponent>();
-        application.add_transient<TodoListComponent>();
-        application.add_transient<HeaderComponent>();
-        application.add_transient<AddFormComponent>();
-        application.add_transient<FeaturesComponent>();
-        application.add_transient<FooterComponent>();
-        application.add_transient<PageLayoutComponent>();
+        // Configure Spry components and pages
+        var spry_cfg = application.configure_with<SpryConfigurator>();
+        
+        // Register child components
+        spry_cfg.add_component<EmptyListComponent>();
+        spry_cfg.add_component<TodoItemComponent>();
+        spry_cfg.add_component<TodoListComponent>();
+        spry_cfg.add_component<HeaderComponent>();
+        spry_cfg.add_component<AddFormComponent>();
+        spry_cfg.add_component<FeaturesComponent>();
+        spry_cfg.add_component<FooterComponent>();
+        
+        // Register the page (acts as both Component and Endpoint)
+        spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));
         
-        // Register endpoints
-        application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
+        // Register JSON API endpoint
         application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
         
         application.run();

+ 1043 - 0
examples/UsersExample.vala

@@ -0,0 +1,1043 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillSql;
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Spry.Authentication.Components;
+using Spry.Authorisation;
+
+/**
+ * UsersExample.vala - Complete example demonstrating the Spry Authentication system
+ * 
+ * This example demonstrates:
+ * 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 UserManagementComponent (for users with permission)
+ * 9. Authorisation Context - Using AuthorisationContext for permission checking
+ * 
+ * Route structure:
+ *   /              -> HomePage (public landing page)
+ *   /register      -> RegisterPage (create new account)
+ *   /login         -> LoginPage (uses LoginFormComponent)
+ *   /logout        -> LogoutEndpoint (clears session and redirects)
+ *   /dashboard     -> DashboardPage (protected - requires authentication)
+ *   /admin/users   -> UserAdminPage (protected - requires "user-management" permission, uses UserManagementComponent)
+ */
+
+// =============================================================================
+// APPLICATION PERMISSIONS - Define permissions available in this application
+// =============================================================================
+
+/**
+ * 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.
+ */
+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");
+}
+
+// =============================================================================
+// STYLESHEETS - CSS content served as FastResources
+// =============================================================================
+
+private const string MAIN_CSS = """
+/* Base Reset & Layout */
+* { box-sizing: border-box; margin: 0; padding: 0; }
+html, body {
+    height: 100%;
+}
+body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+    line-height: 1.6;
+    background: #f5f7fa;
+    display: flex;
+    flex-direction: column;
+    min-height: 100vh;
+}
+
+/* Header */
+header {
+    background: #2c3e50;
+    color: white;
+    padding: 1rem 2rem;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    flex-shrink: 0;
+}
+header nav a {
+    color: white;
+    margin-right: 1.5rem;
+    text-decoration: none;
+    font-weight: 500;
+    transition: opacity 0.2s;
+}
+header nav a:hover { opacity: 0.8; }
+header nav a:last-child { margin-right: 0; }
+header .user-info {
+    font-size: 0.9rem;
+}
+header .user-info a {
+    margin-left: 1rem;
+    color: #3498db;
+}
+
+/* Main Content */
+main.container {
+    flex: 1;
+    max-width: 800px;
+    width: 100%;
+    margin: 0 auto;
+    padding: 2rem 1rem;
+}
+
+/* Cards */
+.card {
+    background: white;
+    border-radius: 12px;
+    padding: 2rem;
+    margin-bottom: 1.5rem;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+}
+
+/* Typography */
+h1 { color: #2c3e50; margin-bottom: 1rem; }
+h2 { color: #34495e; margin-bottom: 0.75rem; margin-top: 1.5rem; }
+p { color: #555; margin-bottom: 1rem; }
+ul { margin-left: 1.5rem; margin-bottom: 1rem; }
+li { margin-bottom: 0.5rem; color: #555; }
+
+/* Links */
+a { color: #3498db; text-decoration: none; transition: color 0.2s; }
+a:hover { color: #2980b9; text-decoration: underline; }
+
+/* Forms */
+.form-group {
+    margin-bottom: 1.25rem;
+}
+.form-group label {
+    display: block;
+    margin-bottom: 0.5rem;
+    font-weight: 500;
+    color: #34495e;
+}
+.form-group input {
+    width: 100%;
+    padding: 0.75rem;
+    border: 2px solid #e0e0e0;
+    border-radius: 6px;
+    font-size: 1rem;
+    transition: border-color 0.2s;
+}
+.form-group input:focus {
+    outline: none;
+    border-color: #3498db;
+}
+.form-group-checkbox {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+.form-group-checkbox input {
+    width: auto;
+}
+.form-group-checkbox label {
+    margin: 0;
+    font-weight: normal;
+}
+
+/* Buttons */
+button, .btn {
+    background: #3498db;
+    color: white;
+    border: none;
+    padding: 0.75rem 1.5rem;
+    border-radius: 6px;
+    cursor: pointer;
+    font-size: 1rem;
+    font-weight: 500;
+    transition: all 0.2s;
+    text-decoration: none;
+    display: inline-block;
+}
+button:hover, .btn:hover {
+    background: #2980b9;
+    text-decoration: none;
+}
+.btn-secondary {
+    background: #6c757d;
+}
+.btn-secondary:hover {
+    background: #545b62;
+}
+
+/* Alerts */
+.alert {
+    padding: 1rem;
+    border-radius: 6px;
+    margin-bottom: 1rem;
+}
+.alert-success {
+    background: #d4edda;
+    color: #155724;
+    border: 1px solid #c3e6cb;
+}
+.alert-error {
+    background: #f8d7da;
+    color: #721c24;
+    border: 1px solid #f5c6cb;
+}
+.error-message {
+    color: #dc3545;
+    font-size: 0.9rem;
+    margin-top: 0.5rem;
+}
+
+/* Login Form */
+.spry-login-form {
+    max-width: 400px;
+}
+.login-btn {
+    width: 100%;
+    margin-top: 1rem;
+}
+
+/* Footer */
+footer {
+    background: #2c3e50;
+    color: rgba(255,255,255,0.7);
+    padding: 1.5rem;
+    text-align: center;
+    flex-shrink: 0;
+}
+
+/* Dashboard */
+.welcome-section {
+    margin-bottom: 2rem;
+}
+.permission-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    margin-top: 0.5rem;
+}
+.permission-badge {
+    background: #e9ecef;
+    color: #495057;
+    padding: 0.25rem 0.75rem;
+    border-radius: 20px;
+    font-size: 0.85rem;
+}
+.permission-badge.admin {
+    background: #ffc107;
+    color: #856404;
+}
+""";
+
+// =============================================================================
+// PAGE TEMPLATE - Provides consistent layout for all pages
+// =============================================================================
+
+/**
+ * MainLayoutTemplate - Base template for all pages
+ * 
+ * Provides:
+ * - HTML document structure
+ * - Common <head> elements (scripts, styles)
+ * - Site-wide header with navigation (changes based on auth state)
+ * - Site-wide footer
+ */
+public class MainLayoutTemplate : PageTemplate {
+    
+    private SessionService _session_service = inject<SessionService>();
+    private UserService _user_service = inject<UserService>();
+    private HttpContext _http_context = inject<HttpContext>();
+    
+    public User? current_user { get; private set; }
+    
+    public override string markup { get {
+        return """
+        <!DOCTYPE html>
+        <html lang="en">
+        <head>
+            <meta charset="UTF-8">
+            <meta name="viewport" content="width=device-width, initial-scale=1.0">
+            <title>Spry Authentication Example</title>
+            <link rel="stylesheet" href="/styles/main.css">
+            <script spry-res="htmx.js"></script>
+        </head>
+        <body>
+            <header>
+                <nav>
+                    <a href="/">Home</a>
+                    <a href="/dashboard">Dashboard</a>
+                    <a href="/admin/users">User Admin</a>
+                </nav>
+                <div class="user-info" sid="user-info">
+                    <!-- Will be populated based on auth state -->
+                </div>
+            </header>
+            <main class="container">
+                <spry-template-outlet />
+            </main>
+            <footer>
+                <p>Built with Spry Framework - Authentication Example</p>
+            </footer>
+        </body>
+        </html>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Try to authenticate the request
+        var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
+        
+        if (auth_result.is_authenticated && auth_result.user != null) {
+            current_user = auth_result.user;
+            this["user-info"].inner_html = @"Logged in as $(auth_result.user.username) | <a href=\"/logout\">Logout</a>";
+        } else {
+            this["user-info"].inner_html = "<a href=\"/login\">Login</a> | <a href=\"/register\">Register</a>";
+        }
+    }
+}
+
+// =============================================================================
+// PAGE COMPONENTS - Public pages
+// =============================================================================
+
+/**
+ * HomePage - Public landing page
+ * 
+ * This page is accessible to everyone, authenticated or not.
+ * It provides an overview of the Authentication system features.
+ */
+public class HomePage : PageComponent {
+    
+    private SessionService _session_service = inject<SessionService>();
+    private UserService _user_service = inject<UserService>();
+    private HttpContext _http_context = inject<HttpContext>();
+    
+    public User? current_user { get; private set; }
+    
+    public const string ROUTE = "/";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <h1>Welcome to the Spry Authentication Example</h1>
+            <p>This example demonstrates the complete Spry Authentication system including:</p>
+            
+            <h2>Features</h2>
+            <ul>
+                <li><strong>User Registration</strong> - Create new accounts with username/email/password</li>
+                <li><strong>Authentication</strong> - Login with session management</li>
+                <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>
+            <p sid="status-message"></p>
+            
+            <ul>
+                <li><a href="/register">Register</a> - Create a new account</li>
+                <li><a href="/login">Login</a> - Sign in to your account</li>
+                <li><a href="/dashboard">Dashboard</a> - Protected page (requires login)</li>
+                <li><a href="/admin/users">User Admin</a> - Admin page (requires permission)</li>
+            </ul>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Check if user is authenticated
+        var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
+        
+        if (auth_result.is_authenticated && auth_result.user != null) {
+            current_user = auth_result.user;
+            this["status-message"].text_content = @"You are logged in as <strong>$(auth_result.user.username)</strong>.";
+        } else {
+            this["status-message"].text_content = "You are not logged in. Register or login to access protected pages.";
+        }
+    }
+}
+
+/**
+ * RegisterPage - User registration page
+ * 
+ * Allows new users to create an account with username, email, and password.
+ * Demonstrates direct use of UserService.create_user_async().
+ */
+public class RegisterPage : PageComponent {
+    
+    private UserService _user_service = inject<UserService>();
+    private PermissionService _permission_service = inject<PermissionService>();
+    private HttpContext _http_context = inject<HttpContext>();
+    
+    public string? error_message { get; private set; default = null; }
+    public string? success_message { get; private set; default = null; }
+    public string preserved_username { get; private set; default = ""; }
+    public string preserved_email { get; private set; default = ""; }
+    
+    public const string ROUTE = "/register";
+    
+    public override string markup { get {
+        return """
+        <div class="card" sid="register-card" hx-swap="outerHTML">
+            <h1>Create Account</h1>
+            
+            <div spry-if="this.success_message != null" class="alert alert-success" sid="success-alert">
+                <span content-expr="this.success_message"></span>
+            </div>
+            
+            <form sid="register-form" spry-action=":Register" spry-target="register-card">
+                <div class="form-group">
+                    <label for="username">Username</label>
+                    <input type="text" name="username" sid="username-input" required 
+                           autocomplete="username" placeholder="Choose a username"/>
+                </div>
+                
+                <div class="form-group">
+                    <label for="email">Email</label>
+                    <input type="email" name="email" sid="email-input" required 
+                           autocomplete="email" placeholder="Enter your email"/>
+                </div>
+                
+                <div class="form-group">
+                    <label for="password">Password</label>
+                    <input type="password" name="password" sid="password-input" required 
+                           autocomplete="new-password" placeholder="Choose a password"/>
+                </div>
+                
+                <div class="form-group">
+                    <label for="confirm-password">Confirm Password</label>
+                    <input type="password" name="confirm_password" sid="confirm-password-input" required 
+                           autocomplete="new-password" placeholder="Confirm your password"/>
+                </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">Create Account</button>
+            </form>
+            
+            <p style="margin-top: 1.5rem;">
+                Already have an account? <a href="/login">Login here</a>
+            </p>
+        </div>
+        """;
+    }}
+    
+    public override async 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);
+        }
+    }
+    
+    public async override void handle_action(string action) throws Error {
+        if (action == "Register") {
+            yield handle_register_async();
+        }
+    }
+    
+    private async void handle_register_async() throws Error {
+        var query = _http_context.request.query_params;
+        
+        // Get form values
+        var username = (query.get_any_or_default("username") ?? "").strip();
+        var email = (query.get_any_or_default("email") ?? "").strip();
+        var password = query.get_any_or_default("password") ?? "";
+        var confirm_password = query.get_any_or_default("confirm_password") ?? "";
+        
+        // Preserve values for re-display
+        preserved_username = username;
+        preserved_email = email;
+        
+        // Validate inputs
+        if (username.length < 3) {
+            error_message = "Username must be at least 3 characters";
+            return;
+        }
+        
+        if (!email.contains("@") || !email.contains(".")) {
+            error_message = "Please enter a valid email address";
+            return;
+        }
+        
+        if (password.length < 6) {
+            error_message = "Password must be at least 6 characters";
+            return;
+        }
+        
+        if (password != confirm_password) {
+            error_message = "Passwords do not match";
+            return;
+        }
+        
+        // Attempt to create user
+        try {
+            var user = yield _user_service.create_user_async(username, email, password);
+            
+            // Grant basic permissions to new users
+            yield _permission_service.set_permission_async(user, PermissionService.USER_READ);
+            
+            success_message = @"Account created successfully! You can now <a href=\"/login\">login</a>.";
+            error_message = null;
+            
+            // Clear preserved values on success
+            preserved_username = "";
+            preserved_email = "";
+            
+        } catch (UserError.DUPLICATE_USERNAME e) {
+            error_message = "Username already exists. Please choose another.";
+        } catch (UserError.DUPLICATE_EMAIL e) {
+            error_message = "Email already registered. Please use another or login.";
+        } catch (Error e) {
+            error_message = "Registration failed: %s".printf(e.message);
+        }
+    }
+}
+
+// =============================================================================
+// PAGE COMPONENTS - Authentication pages
+// =============================================================================
+
+/**
+ * LoginPage - Login page using the built-in LoginFormComponent
+ * 
+ * This page demonstrates how to use the LoginFormComponent for authentication.
+ * The component handles:
+ * - Form display and validation
+ * - Authentication via UserService
+ * - Session creation via SessionService
+ * - Cookie management
+ * - Redirect after successful login
+ */
+public class LoginPage : PageComponent {
+    
+    private ComponentFactory _factory = inject<ComponentFactory>();
+    private LoginFormComponent _login_form;
+    
+    public const string ROUTE = "/login";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <h1>Login</h1>
+            <spry-outlet sid="login-form-outlet"/>
+            <p style="margin-top: 1.5rem;">
+                Don't have an account? <a href="/register">Register here</a>
+            </p>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        // Create and configure the login form component
+        _login_form = _factory.create<LoginFormComponent>();
+        _login_form.redirect_url = "/dashboard";  // Redirect here after successful login
+        
+        // Add the form to our outlet
+        add_outlet_child("login-form-outlet", _login_form);
+        
+        // Share globals with the login form (required for action handling)
+        add_globals_from(_login_form);
+    }
+}
+
+/**
+ * LogoutEndpoint - Handles logout by clearing the session
+ *
+ * This demonstrates:
+ * - Getting the current session from the cookie
+ * - Deleting the session from storage
+ * - Clearing the session cookie
+ * - Returning a redirect response
+ */
+public class LogoutEndpoint : Object, Endpoint {
+    
+    private SessionService _session_service = inject<SessionService>();
+    private UserService _user_service = inject<UserService>();
+    
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+        // Try to get the current session
+        var auth_result = yield _session_service.authenticate_request_async(http_context, _user_service);
+        
+        if (auth_result.is_authenticated && auth_result.session != null) {
+            // Delete the session from storage
+            yield _session_service.delete_session_async(auth_result.session.id);
+        }
+        
+        // Create redirect result with Location header
+        // Note: We use 302 (FOUND) redirect - StatusCode enum may not have FOUND, so we use the numeric value
+        var result = new HttpStringResult("Redirecting to home page...", 302);
+        result.set_header("Location", "/");
+        
+        // Clear the session cookie
+        _session_service.clear_session_cookie(result);
+        
+        return result;
+    }
+}
+
+// =============================================================================
+// PAGE COMPONENTS - Protected pages
+// =============================================================================
+
+/**
+ * DashboardPage - Protected dashboard page
+ * 
+ * This page demonstrates:
+ * - Checking authentication in prepare()
+ * - Redirecting unauthenticated users to login
+ * - Accessing the current user's information
+ * - Checking permissions
+ * - Displaying user-specific content
+ * - Using AuthorisationContext for permission checking
+ */
+public class DashboardPage : PageComponent {
+    
+    private SessionService _session_service = inject<SessionService>();
+    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; }
+    public Vector<string> permissions { get; private set; }
+    
+    public const string ROUTE = "/dashboard";
+    
+    public override string markup { get {
+        return """
+        <div class="card">
+            <!-- Not authenticated message -->
+            <div spry-if="!this.is_authenticated" class="alert alert-error">
+                <h2>Authentication Required</h2>
+                <p>You must be logged in to view this page.</p>
+                <p style="margin-top: 1rem;">
+                    <a href="/login" class="btn">Login</a>
+                    <a href="/register" class="btn btn-secondary" style="margin-left: 0.5rem;">Register</a>
+                </p>
+            </div>
+            
+            <!-- Authenticated content -->
+            <div spry-if="this.is_authenticated">
+                <div class="welcome-section">
+                    <h1 sid="welcome-heading">Dashboard</h1>
+                    <p>Welcome to your dashboard! This page demonstrates protected content.</p>
+                </div>
+                
+                <h2>Your Profile</h2>
+                <ul>
+                    <li><strong>Username:</strong> <span sid="username"></span></li>
+                    <li><strong>Email:</strong> <span sid="email"></span></li>
+                    <li><strong>User ID:</strong> <span sid="user-id"></span></li>
+                    <li><strong>Account Created:</strong> <span sid="created-at"></span></li>
+                </ul>
+                
+                <h2>Your Permissions</h2>
+                <div class="permission-list" sid="permission-list">
+                    <!-- Permissions will be listed here -->
+                </div>
+                
+                <div spry-if="this.is_admin" class="alert alert-success" style="margin-top: 1.5rem;">
+                    <strong>Admin Access:</strong> You have admin privileges.
+                    <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>
+            </div>
+        </div>
+        """;
+    }}
+    
+    // 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);
+        
+        if (!auth_result.is_authenticated || auth_result.user == null) {
+            // Not authenticated - show message and link to login
+            // Note: PageComponent doesn't have redirect(), so we show a message instead
+            is_authenticated = false;
+            return;
+        }
+        
+        is_authenticated = true;
+        
+        current_user = auth_result.user;
+        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;
+        this["email"].text_content = current_user.email;
+        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 - user.permissions returns string[] now
+        var perm_text = "";
+        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 (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
+// =============================================================================
+
+/**
+ * SeedData - Creates initial users for testing
+ * 
+ * This demonstrates how to:
+ * - Check if users exist before creating
+ * - Create users programmatically
+ * - Grant permissions to users
+ */
+public class SeedData : Object {
+    
+    public static async void ensure_admin_exists(UserService user_service, PermissionService permission_service) throws Error {
+        // Check if admin user exists
+        var admin_user = yield user_service.get_user_by_username_async("admin");
+        
+        if (admin_user != null) {
+            print("Admin user already exists\n");
+            return;
+        }
+        
+        print("Creating admin user...\n");
+        
+        // Create admin user
+        admin_user = yield user_service.create_user_async("admin", "admin@example.com", "admin123");
+        
+        // Grant admin permissions
+        yield permission_service.set_permission_async(admin_user, PermissionService.ADMIN);
+        yield permission_service.set_permission_async(admin_user, PermissionService.USER_MANAGEMENT);
+        yield permission_service.set_permission_async(admin_user, PermissionService.USER_CREATE);
+        yield permission_service.set_permission_async(admin_user, PermissionService.USER_READ);
+        yield permission_service.set_permission_async(admin_user, PermissionService.USER_UPDATE);
+        yield permission_service.set_permission_async(admin_user, PermissionService.USER_DELETE);
+        
+        print("Admin user created with username 'admin' and password 'admin123'\n");
+        
+        // Create a regular test user
+        var test_user = yield user_service.get_user_by_username_async("testuser");
+        if (test_user == null) {
+            print("Creating test user...\n");
+            test_user = yield user_service.create_user_async("testuser", "test@example.com", "test123");
+            yield permission_service.set_permission_async(test_user, PermissionService.USER_READ);
+            print("Test user created with username 'testuser' and password 'test123'\n");
+        }
+    }
+}
+
+// =============================================================================
+// APPLICATION SETUP
+// =============================================================================
+
+// =============================================================================
+// ASYNC MAIN LOOP - Required for async initialization
+// =============================================================================
+
+private MainLoop main_loop;
+private Connection global_connection;
+
+public static int main(string[] args) {
+    int port = args.length > 1 ? int.parse(args[1]) : 8080;
+    
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("              Spry Authentication Example - Complete Demo\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Port: %d\n", port);
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Public Endpoints:\n");
+    print("    /              - Home page (public)\n");
+    print("    /register      - Create new account\n");
+    print("    /login         - Login page\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Protected Endpoints (requires login):\n");
+    print("    /dashboard     - User dashboard\n");
+    print("    /logout        - Logout and clear session\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Admin Endpoints (requires 'user-management' permission):\n");
+    print("    /admin/users   - User management page\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("  Default Users:\n");
+    print("    admin / admin123    - Has all permissions\n");
+    print("    testuser / test123  - Regular user\n");
+    print("═══════════════════════════════════════════════════════════════\n");
+    print("\nPress Ctrl+C to stop the server\n\n");
+    
+    main_loop = new MainLoop();
+    
+    try {
+        // 1. Create the database connection FIRST
+        print("Creating database connection...\n");
+        initialize_database.begin((obj, res) => {
+            try {
+                initialize_database.end(res);
+                
+                // 2. Now start the application
+                start_application.begin(port, (obj, res) => {
+                    try {
+                        start_application.end(res);
+                    } catch (Error e) {
+                        printerr("Application error: %s\n", e.message);
+                        main_loop.quit();
+                    }
+                });
+            } catch (Error e) {
+                printerr("Database initialization error: %s\n", e.message);
+                main_loop.quit();
+            }
+        });
+        
+        main_loop.run();
+        return 0;
+        
+    } catch (Error e) {
+        printerr("Error: %s\n", e.message);
+        return 1;
+    }
+}
+
+/**
+ * Initialize database connection and create schema.
+ */
+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 database is initialized.
+ */
+private async void start_application(int port) throws Error {
+    var application = new WebApplication(port);
+    
+    // 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();
+    
+    // Add Spry module for component actions
+    application.add_module<SpryModule>();
+    
+    // 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);
+    
+    // Register template with route prefix
+    // MainLayoutTemplate applies to ALL routes (empty prefix)
+    var spry_cfg = application.configure_with<SpryConfigurator>();
+    spry_cfg.add_template<MainLayoutTemplate>("");
+    
+    // Register page components as endpoints
+    application.add_transient<HomePage>();
+    application.add_endpoint<HomePage>(new EndpointRoute(HomePage.ROUTE));
+    
+    application.add_transient<RegisterPage>();
+    application.add_endpoint<RegisterPage>(new EndpointRoute(RegisterPage.ROUTE));
+    
+    application.add_transient<LoginPage>();
+    application.add_endpoint<LoginPage>(new EndpointRoute(LoginPage.ROUTE));
+    
+    application.add_transient<DashboardPage>();
+    application.add_endpoint<DashboardPage>(new EndpointRoute(DashboardPage.ROUTE));
+    
+    // Register logout endpoint
+    application.add_endpoint<LogoutEndpoint>(new EndpointRoute("/logout"));
+    
+    // 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 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"), () => {
+        try {
+            return new FastResource.from_string(MAIN_CSS)
+                .with_content_type("text/css; charset=utf-8")
+                .with_default_compressors();
+        } catch (Error e) {
+            error("Failed to create main CSS resource: %s", e.message);
+        }
+    });
+    
+    print("Starting web server on port %d...\n\n", port);
+    application.run();
+}
+
+/**
+ * Seed initial data (admin and test users).
+ */
+private async void seed_initial_data(Container container) {
+    try {
+        var scope = container.create_scope();
+        var user_service = scope.resolve<UserService>();
+        var permission_service = scope.resolve<PermissionService>();
+        yield SeedData.ensure_admin_exists(user_service, permission_service);
+    } catch (Error e) {
+        printerr("Warning: Failed to seed initial data: %s\n", e.message);
+    }
+}

+ 8 - 0
examples/meson.build

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

+ 315 - 1
important-details.md

@@ -13,7 +13,7 @@
 - Centralizes data fetching logic in one place
 
 ```vala
-public override void prepare() throws Error {
+public override async void prepare() throws Error {
     var item = store.get_by_id(_item_id);
     if (item == null) return;
     
@@ -22,6 +22,18 @@ public override void prepare() throws Error {
 }
 ```
 
+### The `prepare_once()` Method
+- Called only once before the first `prepare()` call
+- Useful for one-time initialization that should not repeat on every render
+- Runs before `prepare()` in the same request lifecycle
+
+```vala
+public override async void prepare_once() throws Error {
+    // One-time setup, e.g., loading initial data
+    initial_data = yield fetch_initial_data();
+}
+```
+
 ### The `handle_action()` Method
 - Called when HTMX requests trigger an action
 - Modify state in stores, then let `prepare()` handle template updates
@@ -43,6 +55,131 @@ public async override void handle_action(string action) throws Error {
 }
 ```
 
+## Real-Time Updates with Continuations (SSE)
+
+### Overview
+
+The continuation feature allows a Component to send real-time updates to the client via Server-Sent Events (SSE). This is useful for:
+- Long-running task progress reporting
+- Real-time status updates
+- Live data streaming
+
+### The `continuation()` Method
+
+Override `continuation(ContinuationContext context)` to send SSE events:
+
+```vala
+public async override void continuation(ContinuationContext continuation_context) throws Error {
+    for (int i = 0; i <= 100; i += 10) {
+        percent = i;
+        status = @"Processing... $(i)%";
+        
+        // Send dynamic section updates to the client
+        yield continuation_context.send_dynamic("progress-bar");
+        yield continuation_context.send_dynamic("status");
+        
+        Timeout.add(500, () => {
+            continuation.callback();
+            return false;
+        });
+        yield;
+    }
+    
+    status = "Complete!";
+    yield continuation_context.send_dynamic("status");
+}
+```
+
+### The `continuation_canceled()` Method
+
+Called when the client disconnects from the SSE stream:
+
+```vala
+public async override void continuation_canceled() throws Error {
+    // Clean up resources when client disconnects
+    cleanup_task();
+}
+```
+
+### ContinuationContext API
+
+| Method | Description |
+|--------|-------------|
+| `send_dynamic(name)` | Send a dynamic section (by `spry-dynamic` name) as an SSE event |
+| `send_json(event_type, node)` | Send JSON data as an SSE event |
+| `send_string(event_type, data)` | Send raw string data as an SSE event |
+| `send_full_update(event_type)` | Send the entire component document |
+| `send_event(event)` | Send a custom `SseEvent` |
+
+### The `spry-continuation` Attribute
+
+Add `spry-continuation` to an element to enable SSE for its children:
+
+```vala
+public override string markup { get {
+    return """
+    <div spry-continuation>
+        <div class="progress-container" spry-dynamic="progress-bar">
+            <div class="progress-bar" spry-unique
+                 content-expr='format("{{this.percent}}%")' 
+                 style-width-expr='format("{{this.percent}}%")'>
+                0%
+            </div>
+        </div>
+        <div class="status" spry-dynamic="status">
+            <strong>Status:</strong> <span spry-unique content-expr="this.status">Initializing...</span>
+        </div>
+    </div>
+    """;
+}}
+```
+
+The `spry-continuation` attribute is shorthand for:
+- `hx-ext="sse"` - Enable HTMX SSE extension
+- `sse-connect="(auto-generated-endpoint)"` - Connect to the SSE endpoint
+- `sse-close="_spry-close"` - Close event name
+
+### The `spry-dynamic` Attribute
+
+Use `spry-dynamic="name"` on child elements to mark them as updatable sections:
+
+```html
+<div class="progress-container" spry-dynamic="progress-bar">...</div>
+<div class="status" spry-dynamic="status">...</div>
+```
+
+**Requirements:**
+- Must be a child of an element with `spry-continuation`
+- Automatically gets `sse-swap="_spry-dynamic-{name}"` and `hx-swap="outerHTML"`
+
+When `continuation_context.send_dynamic("progress-bar")` is called:
+1. The element with `spry-dynamic="progress-bar"` is located
+2. Its HTML content is rendered and sent as an SSE event
+3. HTMX swaps it into the matching element on the client
+
+### The `spry-unique` Attribute
+
+Use `spry-unique` to generate unique IDs for elements that need stable targeting:
+
+```html
+<div class="progress-bar" spry-unique>...</div>
+```
+
+**Restrictions:**
+- Cannot specify an `id` attribute on the same element
+- Cannot be used inside `spry-per-*` loops (or children of loops)
+
+The generated ID format is `_spry-unique-{counter}-{context_key}`.
+
+### Required Scripts for SSE
+
+Include the HTMX SSE extension in your markup:
+
+```html
+<script spry-res="htmx.js"></script>
+<script spry-res="htmx-sse.js"></script>
+```
+
 ## HTMX Integration
 
 ### Declarative Attributes
@@ -130,6 +267,77 @@ var id = int.parse(id_str);
 
 **Note**: `hx-vals` is inherited by child elements, so set it on the parent div rather than individual buttons.
 
+## Declarative Expression Attributes
+
+### Expression Attributes (`*-expr`)
+
+Use `*-expr` attributes to dynamically set any attribute based on component properties:
+
+```vala
+class ProgressComponent : Component {
+    public int percent { get; set; }
+    
+    public override string markup { get {
+        return """
+        <div class="progress-bar" 
+             content-expr='format("{{this.percent}}%")' 
+             style-width-expr='format("{{this.percent}}%")'>
+            0%
+        </div>
+        """;
+    }}
+}
+```
+
+Expression attribute patterns:
+- `content-expr="expression"` - Set text/HTML content
+- `class-expr="expression"` - Set CSS classes
+- `style-expr="expression"` - Set inline styles (e.g., `style-width-expr`)
+- `any-attr-expr="expression"` - Set any attribute dynamically
+
+### Conditional Class Expressions
+
+For `class-*` attributes, boolean expressions add/remove the class:
+
+```html
+<div class-completed-expr="this.is_completed">Item</div>
+```
+
+If `this.is_completed` is `true`, the `completed` class is added.
+
+### Conditional Rendering with `spry-if`
+
+Use `spry-if`, `spry-else-if`, and `spry-else` for conditional rendering:
+
+```html
+<div spry-if="this.is_admin">Admin Panel</div>
+<div spry-else-if="this.is_moderator">Moderator Panel</div>
+<div spry-else>User Panel</div>
+```
+
+### Loop Rendering with `spry-per-*`
+
+Use `spry-per-{varname}="expression"` to iterate over collections:
+
+```html
+<div spry-per-task="this.tasks">
+    <span content-expr="task.name"></span>
+</div>
+```
+
+The variable name after `spry-per-` (e.g., `task`) becomes available in nested expressions.
+
+### Static Resources with `spry-res`
+
+Use `spry-res` to reference Spry's built-in static resources:
+
+```html
+<script spry-res="htmx.js"></script>
+<script spry-res="htmx-sse.js"></script>
+```
+
+This resolves to `/_spry/res/{resource-name}` automatically.
+
 ## IoC Composition
 
 ### Registration Patterns
@@ -276,3 +484,109 @@ application.add_module<SpryModule>();
 ```
 
 This enables the declarative `spry-action` attributes to work without manual endpoint registration.
+
+## Declarative Child Components with `<spry-component>`
+
+### When to Use `<spry-component>` vs `<spry-outlet>`
+
+- **`<spry-component name="ComponentName" sid="..."/>`** - For single, known child components
+- **`<spry-outlet sid="..."/>`** - For dynamic lists (multiple items from data)
+
+### Declaring Child Components
+
+Use `<spry-component>` in markup for declarative composition:
+
+```vala
+class TodoPage : PageComponent {
+    public override string markup { get {
+        return """
+        <!DOCTYPE html>
+        <html>
+        <body>
+            <spry-component name="HeaderComponent" sid="header"/>
+            <spry-component name="TodoListComponent" sid="todo-list"/>
+            <spry-component name="AddFormComponent" sid="add-form"/>
+            <spry-component name="FooterComponent" sid="footer"/>
+        </body>
+        </html>
+        """;
+    }}
+}
+```
+
+### Accessing Child Components with `get_component_child<T>()`
+
+Use `get_component_child<T>(sid)` in `prepare()` to access and configure child components:
+
+```vala
+class TodoPage : PageComponent {
+    private TodoStore todo_store = inject<TodoStore>();
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override async void prepare() throws Error {
+        // Get child component and configure it
+        var todo_list = get_component_child<TodoListComponent>("todo-list");
+        
+        // Populate the list (which still uses spry-outlet for dynamic items)
+        var items = new Series<Renderable>();
+        todo_store.all().iterate((item) => {
+            var component = factory.create<TodoItemComponent>();
+            component.item_id = item.id;
+            items.add(component);
+        });
+        todo_list.set_items(items);
+    }
+}
+```
+
+## PageComponent - Combining Component and Endpoint
+
+`PageComponent` is a base class that acts as both a `Component` AND an `Endpoint`. This eliminates the need for separate endpoint classes:
+
+```vala
+// Before: Separate endpoint class
+class HomePageEndpoint : Object, Endpoint {
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
+        var page = factory.create<PageLayoutComponent>();
+        return yield page.to_result();
+    }
+}
+
+// After: PageComponent handles both roles
+class TodoPage : PageComponent {
+    public override string markup { get { return "..."; } }
+    public override async void prepare() throws Error { /* ... */ }
+}
+```
+
+## SpryConfigurator - Component and Page Registration
+
+Use `SpryConfigurator` to register components and pages in a structured way:
+
+```vala
+// Get the configurator
+var spry_cfg = application.configure_with<SpryConfigurator>();
+
+// Register child components (transient lifecycle)
+spry_cfg.add_component<HeaderComponent>();
+spry_cfg.add_component<TodoListComponent>();
+spry_cfg.add_component<TodoItemComponent>();
+spry_cfg.add_component<AddFormComponent>();
+spry_cfg.add_component<FooterComponent>();
+
+// Register pages (scoped lifecycle, acts as Endpoint)
+spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));
+
+// Other endpoints still use add_endpoint
+application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
+```
+
+### Registration Methods
+
+| Method | Lifecycle | Use Case |
+|--------|-----------|----------|
+| `add_component<T>()` | Transient | Child components created via factory |
+| `add_page<T>(route)` | Scoped | Page components that act as endpoints |
+| `add_template<T>(prefix)` | Transient | Page templates for layout wrapping |

+ 399 - 0
important-notes-on-writing-documentation-pages.md

@@ -0,0 +1,399 @@
+# Important Notes on Writing Documentation Pages for Spry
+
+This document captures key learnings and patterns for creating documentation pages in the Spry demo site.
+
+## Page Structure
+
+### PageComponent Pattern
+
+Documentation pages extend `PageComponent` (not `Component`). PageComponent is special because it acts as both a Component AND an Endpoint, meaning it handles its own route.
+
+```vala
+public class Demo.Pages.ComponentsOverviewPage : PageComponent {
+    public override string markup { get {
+        return """...""";
+    }}
+    
+    public override async void prepare() throws Error {
+        // Setup code
+    }
+}
+```
+
+### Template Wrapping
+
+**IMPORTANT**: Page markup should NOT include the full HTML structure (`<!DOCTYPE html>`, `<html>`, `<head>`, `<body>`). The `MainTemplate` class automatically wraps page content with the proper HTML structure, including:
+
+- DOCTYPE and html tags
+- `<head>` with stylesheets and scripts
+- `<body>` with the page container
+
+Your page markup should start with the content container:
+
+```vala
+public override string markup { get {
+    return """
+    <div class="page-container">
+        <spry-component name="NavSidebarComponent" sid="nav"/>
+        <main class="main-content">
+            <!-- Your content here -->
+        </main>
+    </div>
+    """;
+}}
+```
+
+### Navigation Sidebar
+
+Every documentation page should include the NavSidebarComponent and set its `current_path` property in `prepare()`:
+
+```vala
+public override async void prepare() throws Error {
+    var nav = get_component_child<NavSidebarComponent>("nav");
+    nav.current_path = "/components/overview";
+}
+```
+
+## Required Imports
+
+```vala
+using Spry;
+using Astralis;      // Required for HttpContext
+using Inversion;     // Required for inject<T>()
+using Invercargill.DataStructures;  // Required for Series<T>
+```
+
+## String Handling and Code Examples
+
+### Triple-Quote String Escaping
+
+**CRITICAL**: Vala's triple-quoted strings (`"""`) CANNOT contain another triple-quoted string sequence. This means you cannot embed code examples that show triple-quoted strings directly.
+
+**Wrong** (will fail to compile):
+```vala
+// This breaks because """ appears inside """
+return """
+    <pre><code>
+public override string markup { get {
+    return """<div>content</div>""";  // SYNTAX ERROR!
+}}
+    </code></pre>
+""";
+```
+
+**Solution**: Use regular strings with `\n` for line breaks and string concatenation:
+
+```vala
+private const string COMPONENT_CODE = 
+    "public class MyComponent : Component {\n" +
+    "    public override string markup { get {\n" +
+    "        return \"\"\"<div>content</div>\"\"\";\n" +
+    "    }}\n" +
+    "}";
+```
+
+### Escaping Quotes in Code Examples
+
+When code contains double quotes, you need to escape them:
+- In triple-quoted strings: Use `\"` for literal quotes
+- In regular strings: Use `\\\"` (escaped backslash + escaped quote)
+
+```vala
+// Inside a triple-quoted string:
+"this[\"element\"].text_content = \"Hello\";"
+
+// Inside a regular string (more escaping needed):
+"this[\\\"element\\\"].text_content = \\\"Hello\\\";"
+```
+
+### JSON in Code Examples
+
+JSON strings in code examples need heavy escaping:
+
+```vala
+// Showing hx-vals in code:
+"this[\"button\"].set_attribute(\"hx-vals\", \"{\\\"id\\\": 123}\");"
+```
+
+## CodeBlockComponent Usage
+
+Use `CodeBlockComponent` for displaying code examples with syntax highlighting:
+
+```vala
+// In markup:
+"<spry-component name=\"CodeBlockComponent\" sid=\"example-code\"/>"
+
+// In prepare():
+var code_block = get_component_child<CodeBlockComponent>("example-code");
+code_block.language = "vala";
+code_block.code = YOUR_CODE_STRING;
+```
+
+Supported languages: `vala`, `xml`, `css`, `javascript`
+
+## DemoHostComponent Usage
+
+Use `DemoHostComponent` for interactive demos that show source code alongside a working demo:
+
+```vala
+// In markup:
+"<spry-component name=\"DemoHostComponent\" sid=\"demo\"/>"
+
+// In prepare():
+var demo = get_component_child<DemoHostComponent>("demo");
+demo.source_file = "DemoComponents/SimpleCounterDemo.vala";
+
+// Create and set the actual demo component
+var counter = factory.create<SimpleCounterDemo>();
+demo.set_outlet_child("demo-outlet", counter);
+```
+
+The DemoHostComponent provides:
+- Tabbed interface (Source / Demo)
+- Syntax-highlighted source code display
+- Live demo outlet
+
+## Component Lifecycle and State
+
+### New Instance Per Request
+
+**IMPORTANT**: Components are instantiated fresh on each request. You cannot store state directly in component instance fields between requests.
+
+**Wrong** (state won't persist):
+```vala
+public class MyComponent : Component {
+    private int counter = 0;  // Reset to 0 on every request!
+}
+```
+
+**Correct** (use a singleton store):
+```vala
+public class MyComponent : Component {
+    private MyStore store = inject<MyStore>();  // Singleton, persists
+}
+
+public class MyStore : Object {
+    public int counter { get; set; default = 0; }  // Persists between requests
+}
+```
+
+### prepare() Must Be Async
+
+The `prepare()` method must be marked `async`:
+
+```vala
+public override async void prepare() throws Error {
+    // ...
+}
+```
+
+## Dependency Injection
+
+Use the `inject<T>()` pattern for dependency injection:
+
+```vala
+private ComponentFactory factory = inject<ComponentFactory>();
+private HttpContext http_context = inject<HttpContext>();
+private MyStore store = inject<MyStore>();  // Must be registered in Main.vala
+```
+
+Register singleton stores in `demo/Main.vala`:
+
+```vala
+// In Main.vala configure() method:
+container.register<MyStore>(new MyStore());
+```
+
+## Series<T> Collection
+
+The `Series<T>` type from Invercargill is used for collections:
+
+```vala
+public Series<TodoItem> items { get; set; default = new Series<TodoItem>(); }
+
+// Add items:
+items.add(new TodoItem(1, "Task"));
+
+// Iterate:
+foreach (var item in items) {
+    // ...
+}
+
+// Get count:
+int count = items.length;  // NOT .size!
+
+// Create new series:
+var new_items = new Series<TodoItem>();
+```
+
+## Common Patterns
+
+### Element Selection and Modification
+
+```vala
+// Select by sid:
+this["my-element"].text_content = "Hello";
+this["my-element"].add_class("active");
+this["my-element"].set_attribute("data-id", "123");
+
+// Set inner HTML:
+this["container"].inner_html = "<span>Dynamic content</span>";
+```
+
+### Handling Actions
+
+```vala
+public async override void handle_action(string action) throws Error {
+    switch (action) {
+        case "Save":
+            // Get form data
+            var value = http_context.request.query_params.get_any_or_default("field_name");
+            // Process...
+            break;
+        case "Delete":
+            // ...
+            break;
+    }
+}
+```
+
+### Using Outlets for Child Components
+
+```vala
+// In markup:
+"<ul><spry-outlet sid=\"items-outlet\"/></ul>"
+
+// In prepare():
+var children = new Series<Renderable>();
+foreach (var item in store.items) {
+    var component = factory.create<ChildComponent>();
+    component.item_id = item.id;
+    children.add(component);
+}
+set_outlet_children("items-outlet", children);
+```
+
+## Routes and Registration
+
+### Route Convention
+
+Routes follow the pattern `/section/page-name`:
+- `/components/overview`
+- `/components/actions`
+- `/components/outlets`
+
+### Registration in Main.vala
+
+Pages are registered with the router:
+
+```vala
+// In Main.vala:
+router.add_route("/components/overview", typeof(ComponentsOverviewPage));
+```
+
+Components used in demos must be registered with the factory:
+
+```vala
+factory.register<SimpleCounterDemo>();
+factory.register<TodoListDemo>();
+```
+
+## File Organization
+
+```
+demo/
+├── Pages/                      # PageComponent classes
+│   ├── ComponentsOverviewPage.vala
+│   ├── ComponentsActionsPage.vala
+│   └── ...
+├── Components/                 # Shared UI components
+│   ├── NavSidebarComponent.vala
+│   ├── CodeBlockComponent.vala
+│   └── DemoHostComponent.vala
+├── DemoComponents/             # Demo-specific components
+│   ├── SimpleCounterDemo.vala
+│   ├── TodoListDemo.vala
+│   └── ProgressDemo.vala
+├── Static/
+│   └── docs.css               # Documentation styles
+├── Main.vala                   # App configuration and routing
+├── MainTemplate.vala           # HTML wrapper template
+└── meson.build                 # Build configuration
+```
+
+## Build Configuration
+
+Add new source files to `demo/meson.build`:
+
+```vala
+demo_sources = files(
+    'Main.vala',
+    'MainTemplate.vala',
+    'Pages/ComponentsOverviewPage.vala',
+    'Pages/ComponentsActionsPage.vala',
+    'DemoComponents/SimpleCounterDemo.vala',
+    # ... etc
+)
+```
+
+## Styling Conventions
+
+Documentation pages use CSS classes from `docs.css`:
+
+- `.doc-hero` - Hero section with title
+- `.doc-section` - Content sections
+- `.doc-card` - Card containers
+- `.doc-table` - Tables for reference
+- `.doc-example` - Code example containers
+- `.demo-container` - Interactive demo containers
+
+## Debugging Tips
+
+1. **Build errors with strings**: Check for unescaped quotes or triple-quote conflicts
+2. **inject<T>() not found**: Add `using Inversion;`
+3. **HttpContext not found**: Add `using Astralis;`
+4. **Property read-only**: Add `set;` to property definition
+5. **Method signature mismatch**: Ensure `prepare()` is `async`
+
+## Quick Reference: Page Template
+
+```vala
+using Spry;
+using Astralis;
+using Inversion;
+
+public class Demo.Pages.YourPage : PageComponent {
+    
+    private ComponentFactory factory = inject<ComponentFactory>();
+    
+    public override string markup { get {
+        return """
+        <div class="page-container">
+            <spry-component name="NavSidebarComponent" sid="nav"/>
+            <main class="main-content">
+                <div class="doc-hero">
+                    <h1>Your Page Title</h1>
+                    <p class="doc-subtitle">Brief description</p>
+                </div>
+                
+                <div class="doc-section">
+                    <h2>Section Title</h2>
+                    <p>Content...</p>
+                    
+                    <spry-component name="CodeBlockComponent" sid="example"/>
+                </div>
+            </main>
+        </div>
+        """;
+    }}
+    
+    public override async void prepare() throws Error {
+        var nav = get_component_child<NavSidebarComponent>("nav");
+        nav.current_path = "/your/path";
+        
+        var code = get_component_child<CodeBlockComponent>("example");
+        code.language = "vala";
+        code.code = "your code here";
+    }
+}
+```

+ 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)"
+```

+ 10 - 1
meson.build

@@ -2,6 +2,8 @@ project('spry', ['c', 'vala'],
   version: '0.1',
 )
 
+vapi_dir = join_paths(meson.current_source_dir(), 'vapi')
+
 # Dependencies
 glib_dep = dependency('glib-2.0')
 gobject_dep = dependency('gobject-2.0')
@@ -11,12 +13,19 @@ 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')
+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)
 
 # VAPI Directory
-add_project_arguments(['--vapidir', join_paths(meson.current_source_dir(), 'vapi')], language: 'vala')
+add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 
 subdir('src')
+subdir('src/Authentication')
 subdir('examples')
 subdir('tools')
 subdir('website')
+subdir('demo')

+ 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

+ 406 - 0
plans/response-state-design.md

@@ -0,0 +1,406 @@
+# Response State Design for Spry Components
+
+## Problem Statement
+
+Authentication works correctly (user is found, password verified), but:
+1. **Cookie is not being set** - The `Set-Cookie` header is set on a discarded `HttpResult`
+2. **Redirect is not happening** - `hx-redirect` is set as an HTML attribute instead of an HTTP header
+
+### Root Cause Analysis
+
+#### Root Cause #1: Cookie Set on Discarded HttpResult
+
+```mermaid
+sequenceDiagram
+    participant CE as ComponentEndpoint
+    participant C as Component
+    participant SS as SessionService
+    
+    CE->>C: handle_action - login
+    C->>C: to_result - creates HttpResult A
+    C->>SS: set_session_cookie - Result A, token
+    SS->>SS: result.set_header - Set-Cookie on A
+    Note over C: Result A is discarded - async void!
+    C-->>CE: void return
+    CE->>C: to_result - creates NEW HttpResult B
+    C-->>CE: Result B - no cookie!
+```
+
+The problem: `handle_action()` is `async void` - it cannot return an `HttpResult`. The cookie is set on a temporary result that gets discarded.
+
+#### Root Cause #2: HTML Attribute vs HTTP Header
+
+```vala
+// WRONG - This sets an HTML attribute
+this[login-form].set_attribute(hx-redirect, redirect_url);
+
+// CORRECT - This sets an HTTP response header
+result.set_header(HX-Redirect, redirect_url);
+```
+
+HTMX requires `HX-Redirect` as an **HTTP response header**, not an HTML attribute.
+
+---
+
+## Proposed Solution: ResponseState
+
+### Design Overview
+
+Add a `ResponseState` class that components use to accumulate response modifications during action handling. The state is then applied when `to_result()` creates the final `HttpResult`.
+
+```mermaid
+sequenceDiagram
+    participant CE as ComponentEndpoint
+    participant C as Component
+    participant RS as ResponseState
+    participant SS as SessionService
+    
+    CE->>C: handle_action - login
+    C->>RS: add_header - Set-Cookie, token
+    C->>RS: redirect - /dashboard
+    C-->>CE: void return - state accumulated
+    CE->>C: to_result
+    C->>C: create HttpResult
+    C->>RS: apply_to - result
+    RS->>C: result.set_header - Set-Cookie
+    RS->>C: result.set_header - HX-Redirect
+    C-->>CE: Result with headers!
+```
+
+### Key Components
+
+#### 1. ResponseState Class
+
+A new class to hold response modifications:
+
+```vala
+namespace Spry {
+
+    /**
+     * Redirect type for component responses.
+     */
+    public enum RedirectType {
+        /** Client-side redirect using HX-Redirect header - default for HTMX */
+        CLIENT_SIDE,
+        /** HTTP 302 temporary redirect using Location header */
+        TEMPORARY,
+        /** HTTP 301 permanent redirect using Location header */
+        PERMANENT
+    }
+
+    /**
+     * Holds response state that will be applied to the final HttpResult.
+     * 
+     * Components can modify this during action handling to influence
+     * the HTTP response without needing to return an HttpResult directly.
+     */
+    public class ResponseState : GLib.Object {
+        
+        // Headers to add to the response
+        private HashTable<string, string> _headers;
+        
+        // HTTP status code override
+        private StatusCode? _status_code = null;
+        
+        // Redirect configuration
+        private string? _redirect_url = null;
+        private RedirectType _redirect_type = RedirectType.CLIENT_SIDE;
+        
+        /**
+         * Adds a header to the response.
+         * If the header already exists, it will be replaced.
+         */
+        public void set_header(string name, string value) { ... }
+        
+        /**
+         * Adds a header to the response.
+         * Multiple headers with the same name can be added.
+         */
+        public void add_header(string name, string value) { ... }
+        
+        /**
+         * Sets the HTTP status code for the response.
+         */
+        public void set_status(StatusCode status) { ... }
+        
+        /**
+         * Sets up a redirect.
+         * Default type is CLIENT_SIDE which uses HX-Redirect header.
+         */
+        public void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) { ... }
+        
+        /**
+         * Applies all accumulated state to an HttpResult.
+         * Called internally by Component.to_result().
+         */
+        internal void apply_to(HttpResult result) { ... }
+        
+        /**
+         * Clears all accumulated state.
+         * Called after to_result() to prepare for next request.
+         */
+        internal void reset() { ... }
+    }
+}
+```
+
+#### 2. Component Changes
+
+Add `ResponseState` property and convenience methods to the base `Component` class:
+
+```vala
+public abstract class Component : Object, Renderable {
+    
+    // Response state for action handlers
+    private ResponseState _response_state = new ResponseState();
+    
+    /**
+     * The response state for this component.
+     * Modify during handle_action to influence the HTTP response.
+     */
+    public ResponseState response { get { return _response_state; } }
+    
+    // Convenience methods that delegate to response_state:
+    
+    /**
+     * Adds a header to the response.
+     */
+    protected void set_header(string name, string value) {
+        _response_state.set_header(name, value);
+    }
+    
+    /**
+     * Sets the HTTP status code.
+     */
+    protected void set_status(StatusCode status) {
+        _response_state.set_status(status);
+    }
+    
+    /**
+     * Sets up a redirect.
+     * Default type is CLIENT_SIDE which uses HX-Redirect header.
+     */
+    protected void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) {
+        _response_state.redirect(url, type);
+    }
+    
+    // Modified to_result():
+    public async HttpResult to_result() throws Error {
+        var document = yield to_document();
+        var result = document.to_result(get_status());
+        
+        // Apply accumulated response state
+        _response_state.apply_to(result);
+        
+        // Reset state for next request
+        _response_state.reset();
+        
+        return result;
+    }
+}
+```
+
+---
+
+## Usage Examples
+
+### Example 1: Login with Cookie and Redirect
+
+```vala
+public class LoginFormComponent : Component {
+    
+    private SessionService _session_service = inject<SessionService>();
+    
+    public override async void handle_action(string action) throws Error {
+        if (action == "login") {
+            yield handle_login();
+        }
+    }
+    
+    private async void handle_login() throws Error {
+        // ... authenticate user ...
+        
+        // Create session
+        var session = yield _session_service.create_session_async(user.id);
+        var token = _session_service.generate_session_token(session);
+        
+        // Set cookie using convenience method
+        var cookie_value = @"spry_session=$token; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Strict";
+        set_header("Set-Cookie", cookie_value);
+        
+        // Set up HTMX redirect
+        redirect("/dashboard");
+    }
+}
+```
+
+### Example 2: Unauthorized Response
+
+```vala
+public override async void handle_action(string action) throws Error {
+    if (!user_is_admin) {
+        set_status(StatusCode.UNAUTHORIZED);
+        error_message = "Admin access required";
+        return;
+    }
+    // ... handle action ...
+}
+```
+
+### Example 3: HTTP 302 Redirect
+
+```vala
+public override async void handle_action(string action) throws Error {
+    if (action == "logout") {
+        // Clear session
+        yield _session_service.delete_session_async(session_id);
+        
+        // HTTP redirect to login page
+        redirect("/login", RedirectType.TEMPORARY);
+    }
+}
+```
+
+### Example 4: Custom Headers
+
+```vala
+public override async void handle_action(string action) throws Error {
+    // Set custom response headers
+    set_header("X-Custom-Header", "value");
+    set_header("Cache-Control", "no-cache");
+    
+    // Multiple Set-Cookie headers
+    add_header("Set-Cookie", "pref=dark; Path=/");
+    add_header("Set-Cookie", "lang=en; Path=/");
+}
+```
+
+---
+
+## Implementation Details
+
+### Header Accumulation Strategy
+
+Headers use a `HashTable<string, string>` with the following behavior:
+- `set_header()`: Replaces existing header with same name
+- `add_header()`: Appends to header list - for headers that can appear multiple times like `Set-Cookie`
+
+### Redirect Implementation
+
+The `apply_to()` method handles redirects:
+
+```vala
+internal void apply_to(HttpResult result) {
+    // Apply headers first
+    foreach (var header in _headers) {
+        result.set_header(header.key, header.value);
+    }
+    
+    // Handle redirect
+    if (_redirect_url != null) {
+        switch (_redirect_type) {
+            case RedirectType.CLIENT_SIDE:
+                result.set_header("HX-Redirect", _redirect_url);
+                break;
+            case RedirectType.TEMPORARY:
+                result.set_header("Location", _redirect_url);
+                result.set_status(StatusCode.FOUND); // 302
+                break;
+            case RedirectType.PERMANENT:
+                result.set_header("Location", _redirect_url);
+                result.set_status(StatusCode.MOVED_PERMANENTLY); // 301
+                break;
+        }
+    }
+    
+    // Apply status code if set - redirects may override this
+    if (_status_code != null && _redirect_url == null) {
+        result.set_status(_status_code);
+    }
+}
+```
+
+### Interaction with get_status()
+
+The existing `get_status()` virtual method continues to work for initial page renders. `ResponseState` status only applies when set during action handling.
+
+Priority order:
+1. Redirect status (301/302) if redirect is set
+2. ResponseState status if set
+3. `get_status()` return value
+
+---
+
+## Files to Modify
+
+| File | Changes |
+|------|---------|
+| `src/ResponseState.vala` | **NEW** - ResponseState class |
+| `src/Component.vala` | Add ResponseState property, convenience methods, modify to_result |
+| `src/Users/Components/LoginFormComponent.vala` | Use new API for cookie and redirect |
+| `src/Users/SessionService.vala` | May need minor updates for cookie helper |
+| `src/meson.build` | Add ResponseState.vala to build |
+
+---
+
+## API Summary
+
+### New Classes
+
+- `Spry.ResponseState` - Holds response modifications
+- `Spry.RedirectType` - Enum for redirect types
+
+### Component Additions
+
+| Method | Description |
+|--------|-------------|
+| `response` | Property to access ResponseState |
+| `set_header(name, value)` | Set a response header |
+| `add_header(name, value)` | Add a response header |
+| `set_status(status)` | Set HTTP status code |
+| `redirect(url, type)` | Redirect with optional type - default is CLIENT_SIDE |
+
+### ResponseState Methods
+
+| Method | Description |
+|--------|-------------|
+| `set_header(name, value)` | Set/replace a header |
+| `add_header(name, value)` | Add a header |
+| `set_status(status)` | Set status code |
+| `redirect(url, type)` | Redirect with optional type - default is CLIENT_SIDE |
+| `has_modifications()` | Check if any state is set |
+
+---
+
+## Questions for Consideration
+
+1. **Should headers be accumulated or replaced by default?**
+   - Current design: `set_header` replaces, `add_header` appends
+   - Alternative: Always accumulate, provide `replace_header` for explicit replacement
+
+2. **Should there be a distinction between HTMX redirects and HTTP redirects?**
+   - Current design: Yes, via `RedirectType` enum
+   - Default is HTMX redirect since Spry is HTMX-focused
+
+3. **How should this interact with prepare and to_result?**
+   - Current design: State is applied in `to_result()`, then reset
+   - Alternative: Apply in `ComponentEndpoint` after `to_result()`
+
+4. **Should there be convenience methods like set_cookie?**
+   - Could add `set_cookie(name, value, options)` helper
+   - Would encapsulate cookie formatting logic
+
+5. **What happens if both redirect and status are set?**
+   - Current design: Redirect takes precedence for status code
+   - Headers are still applied
+
+---
+
+## Next Steps
+
+1. Review and approve this design
+2. Implement `ResponseState.vala`
+3. Modify `Component.vala` to integrate ResponseState
+4. Update `LoginFormComponent.vala` to use new API
+5. Test login flow with cookie and redirect
+6. Update documentation

+ 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

BIN
spry_auth.db


+ 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]
+)

+ 445 - 39
src/Component.vala

@@ -1,14 +1,44 @@
 using Invercargill;
 using Invercargill.DataStructures;
+using Invercargill.Expressions;
 using Inversion;
 using Astralis;
 
 namespace Spry {
 
+    public errordomain ComponentError {
+        INVALID_TYPE,
+        ELEMENT_NOT_FOUND,
+        TYPE_NOT_FOUND,
+        CONFLICTING_ATTRIBUTES,
+        INVALID_TEMPLATE,
+        INVALID_CONTEXT;
+    }
+
     public abstract class Component : Object, Renderable {
         
         private static Dictionary<Type, ComponentTemplate> templates;
         private static Mutex templates_lock = Mutex();
+        public string context_key { get; internal set; default = Uuid.string_random(); }
+        
+        // Response state for action handlers
+        private ResponseState _response_state = new ResponseState();
+        
+        /**
+         * The response state for this component.
+         * Modify during handle_action to influence the HTTP response.
+         *
+         * Example:
+         * {{{
+         * public override async void handle_action(string action) throws Error {
+         *     if (action == "login") {
+         *         response.set_cookie("session", token, 86400);
+         *         response.redirect("/dashboard");
+         *     }
+         * }
+         * }}}
+         */
+        public ResponseState response { get { return _response_state; } }
         
         public abstract string markup { get; }
         public virtual StatusCode get_status() {
@@ -17,10 +47,13 @@ namespace Spry {
         public virtual async void prepare() throws Error {
             // No-op default
         }
+        public virtual async void prepare_once() throws Error {
+            // No-op default
+        }
         public virtual async void handle_action(string action) throws Error {
             // No-op default
         }
-        public virtual async void continuation(SseStream stream) throws Error {
+        public virtual async void continuation(ContinuationContext continuation_context) throws Error {
             // No-op default
         }
         public virtual async void continuation_canceled() throws Error {
@@ -29,9 +62,14 @@ namespace Spry {
         
         private PathProvider _path_provider = inject<PathProvider>();
         private ContinuationProvider _continuation_provider = inject<ContinuationProvider>();
+        private ComponentFactory _component_factory = inject<ComponentFactory>();
+        private CryptographyProvider _cryptography_provider = inject<CryptographyProvider>();
         private Catalogue<string, Renderable> _children = new Catalogue<string, Renderable>();
+        private Dictionary<string, Component> _child_components = new Dictionary<string, Component>();
         private HashSet<Component> _global_sources = new HashSet<Component>();
+        private HashSet<string> _context_properties = new HashSet<string>();
         private MarkupDocument _instance;
+        private bool _prepare_once_called;
 
         private MarkupDocument instance { get {
             if(_instance == null) {
@@ -60,15 +98,15 @@ namespace Spry {
             return _instance;
         }}
 
-        protected MarkupNodeList get_elements_by_class_name(string class_name) {
+        protected Enumerable<MarkupNode> get_elements_by_class_name(string class_name) {
             return instance.get_elements_by_class_name(class_name);
         }
 
-        protected MarkupNodeList get_elements_by_tag_name(string tag_name) {
+        protected Enumerable<MarkupNode> get_elements_by_tag_name(string tag_name) {
             return instance.get_elements_by_tag_name(tag_name);
         }
 
-        protected new MarkupNodeList query(string xpath) {
+        protected new Enumerable<MarkupNode> query(string xpath) {
             return instance.select(xpath);
         }
 
@@ -108,32 +146,148 @@ namespace Spry {
             _global_sources.add(component);
         }
 
+        protected T get_component_child<T>(string sid) throws Error {
+            var node = query_one(@"//spry-component[@sid='$sid']");
+            if(node == null) {
+                throw new ComponentError.ELEMENT_NOT_FOUND(@"No spry-component element with sid '$sid' found.");
+            }
+
+            var component = get_component_instance_from_component_node(node);
+            if(!component.get_type().is_a(typeof(T))) {
+                throw new ComponentError.INVALID_TYPE(@"Component type $(component.get_type().name()) is not a $(typeof(T).name())");
+            }
+            return component;
+        }
+
         public async MarkupDocument to_document() throws Error {
+            if(!_prepare_once_called) {
+                yield prepare_once();
+                _prepare_once_called = true;
+            }
             yield prepare();
+
             var final_instance = instance.copy();
+            yield transform_document(final_instance);
+            return final_instance;
+        }
+
+        internal async MarkupDocument get_dynamic_section(string name) throws Error {
+            if(!_prepare_once_called) {
+                yield prepare_once();
+                _prepare_once_called = true;
+            }
+            yield prepare();
+
+            // Extract the dynamic fragment
+            var final_instance = instance.copy();
+            var template_fragment = final_instance.select_one(@"//*[@spry-dynamic='$name']")?.outer_html;
+            if(template_fragment == null) {
+                throw new ComponentError.ELEMENT_NOT_FOUND(@"Could not find spry-dynamic section '$name'.");
+            }
+            final_instance.body.inner_html = template_fragment;
+
+            // Do regular transform
+            yield transform_document(final_instance, true);
+            return final_instance;
+        }
+
+        public async MarkupDocument get_globals_document() throws Error {
+            if(!_prepare_once_called) {
+                yield prepare_once();
+                _prepare_once_called = true;
+            }
+            yield prepare();
+
+            var final_instance = instance.copy();
+            yield transform_document(final_instance);
+
+            // Extract out globals
+            var globals = final_instance.select("//*[@spry-global]");
+            var globals_document = new MarkupDocument();
+            globals_document.body.append_nodes(globals);
+            return globals_document;
+        }
+
+
+        public async HttpResult to_result() throws Error {
+            var document = yield to_document();
+            var result = document.to_result(get_status());
+            
+            // Apply accumulated response state (headers, cookies, redirects)
+            _response_state.apply_headers(result);
+            
+            // Reset state for next request
+            _response_state.reset();
+            
+            return result;
+        }
+
+        private class ComponentTemplate : MarkupTemplate {
+            private string _markup;
+            protected override string markup { get { return _markup; } }
+
+            public ComponentTemplate(string markup) {
+                this._markup = markup;
+            }
+        }
+
+        private Component get_component_instance_from_component_node(MarkupNode node) throws Error {
+            Component component;
+            // If no SID, create one to keep track of the instance
+            var sid = node.get_attribute("sid");
+            if(sid == null) {
+                sid = Uuid.string_random();
+                node.set_attribute("sid", sid);
+            }
 
-            // Replace outlets
-            var outlets = final_instance.select("//spry-outlet");
+            if(!_child_components.try_get(sid, out component)) {
+                component = _component_factory.create_by_name(node.get_attribute("name"));
+                _child_components[sid] = component;
+            }
+            return component;
+        }
+
+        private async void transform_document(MarkupDocument doc, bool is_rendering_dynamic = false) throws Error {
+            transform_unique_attributes(doc); // Done first so as to ensure the tracking numbers don't change
+            transform_if_attributes(doc); // Outputs spry-hidden attributes
+            remove_hidden_blocks(doc); // Removes tags with spry-hidden attributes
+            yield transform_per_attributes(doc); // Executes spry-per-* loops, which handles nested expression attributes
+            yield transform_expression_attributes(doc); // Evaluares *-expr attributes
+            yield transform_outlets(doc);
+            yield transform_components(doc);
+            transform_context_nodes(doc);
+            transform_action_nodes(doc);
+            transform_target_nodes(doc);
+            transform_global_nodes(doc);
+            transform_resource_nodes(doc);
+            transform_dynamic_attributes(doc, is_rendering_dynamic);
+            transform_continuation_nodes(doc);
+            remove_internal_sids(doc);
+            yield append_globals(doc);
+        }
+
+        private async void transform_outlets(MarkupDocument doc) throws Error {
+            var outlets = doc.select("//spry-outlet");
             foreach (var outlet in outlets) {
                 var nodes = new Series<MarkupNode>();
+                if(!outlet.has_attribute("sid")) {
+                    throw new ComponentError.INVALID_TEMPLATE("Tag spry-outlet must either define a content-expr or an sid");
+                }
                 foreach(var renderable in _children.get_or_empty(outlet.get_attribute("sid"))) {
                     var document = yield renderable.to_document();
                     nodes.add_all(document.body.children);
                 }
-
                 outlet.replace_with_nodes(nodes);
             }
+        }
 
-            // Remove hidden blocks
-            final_instance.select("//*[@spry-hidden]")
+        private void remove_hidden_blocks(MarkupDocument doc) {
+            doc.select("//*[@spry-hidden]")
                 .iterate(n => n.remove());
+        }
 
-            // Replace control blocks with their children
-            final_instance.select("//spry-control")
-                .iterate(n => n.replace_with_nodes(n.children));
-
-
-            var action_nodes = final_instance.select("//*[@spry-action]");
+        private void transform_action_nodes(MarkupDocument doc) throws Error {
+            var action_nodes = doc.select("//*[@spry-action]");
             foreach(var node in action_nodes) {
                 var action = node.get_attribute("spry-action").split(":", 2);
                 var component_name = action[0].replace(".", "");
@@ -143,36 +297,75 @@ namespace Spry {
                 var component_action = action[1];
 
                 node.remove_attribute("spry-action");
-                node.set_attribute("hx-get", _path_provider.get_action_path(component_name, component_action));
+
+                if(component_name == this.get_type().name() && _context_properties.length > 0) {
+                    var data = new PropertyDictionary();
+                    var root = new PropertyDictionary();
+                    root["this"] = new NativeElement<Component>(this);
+                    var evaluation_context = new EvaluationContext(root);
+                    foreach(var prop_name in _context_properties) {
+                        data[prop_name] = ExpressionParser.parse(@"this.$(prop_name)").evaluate(evaluation_context);
+                    }
+                    var context = new ComponentContext() {
+                        type_name = this.get_type().name(),
+                        timestamp = new DateTime.now_utc(),
+                        context_key = context_key,
+                        data = data
+                    };
+                    var context_blob = _cryptography_provider.author_component_context_blob(context);
+                    node.set_attribute("hx-get", _path_provider.get_action_path_with_context(component_name, component_action, context_blob));
+                }
+                else {
+                    node.set_attribute("hx-get", _path_provider.get_action_path(component_name, component_action));
+                }
             }
+        }
 
-            var target_nodes = final_instance.select("//*[@spry-target]");
+        private void transform_target_nodes(MarkupDocument doc) {
+            var target_nodes = doc.select("//*[@spry-target]");
             foreach(var node in target_nodes) {
-                var target_node = final_instance.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']");
+                var target_node = doc.select_one(@"//*[@sid='$(node.get_attribute("spry-target"))']");
                 if(target_node.id == null) {
-                    target_node.id = "_spry-" + Uuid.string_random();
+                    target_node.id = "_spry-target-" + Uuid.string_random();
                 }
 
                 node.set_attribute("hx-target", @"#$(target_node.id)");
                 node.remove_attribute("spry-target");
             }
+        }
 
-            var global_nodes = final_instance.select("//*[@spry-global]");
+        private void transform_global_nodes(MarkupDocument doc) {
+            var global_nodes = doc.select("//*[@spry-global]");
             foreach(var node in global_nodes) {
                 var key = node.get_attribute("spry-global");
                 node.set_attribute("hx-swap-oob", @"[spry-global=\"$key\"]");
             }
+        }
 
-            var script_nodes = final_instance.select("//script[@spry-res]");
+        private void transform_resource_nodes(MarkupDocument doc) {
+            var script_nodes = doc.select("//*[@spry-res]");
             foreach(var node in script_nodes) {
                 var res = node.get_attribute("spry-res");
-                if(res != null) {
-                    node.set_attribute("src", "/_spry/res/" + res);
+                if(res == null) {
+                    throw new ComponentError.INVALID_TEMPLATE("Attribute spry-res must have a value");
                 }
+
                 node.remove_attribute("spry-res");
+                var path = "/_spry/res/" + res;
+                if(node.tag_name == "script" || node.tag_name == "img") {
+                    node.set_attribute("src", path);
+                    continue;
+                }
+
+                if(node.tag_name == "link" && node.get_attribute("rel") == "stylesheet") {
+                    node.set_attribute("href", path);
+                    continue;
+                }
             }
+        }
 
-            var continuation_nodes = final_instance.select("//*[@spry-continuation]");
+        private void transform_continuation_nodes(MarkupDocument doc) {
+            var continuation_nodes = doc.select("//*[@spry-continuation]");
             foreach(var node in continuation_nodes) {
                 var path = _continuation_provider.get_continuation_path(this);
                 node.set_attribute("hx-ext", "sse");
@@ -180,36 +373,249 @@ namespace Spry {
                 node.set_attribute("sse-close", "_spry-close");
                 node.remove_attribute("spry-continuation");
             }
+        }
 
-            // Remove all internal SIDs
-            final_instance.select("//*[@sid]")
+        private void remove_internal_sids(MarkupDocument doc) {
+            doc.select("//*[@sid]")
                 .iterate(n => n.remove_attribute("sid"));
+        }
 
-            // Add globals
+        private async void append_globals(MarkupDocument doc) throws Error {
             foreach(var source in _global_sources) {
-                var document = yield source.to_document();
-                var globals = document.select("//*[@spry-global]");
-                final_instance.body.append_nodes(globals);
+                var globals = yield source.get_globals_document();
+                doc.body.append_nodes(globals.body.children);
             }
+        }
 
-            return final_instance;
+        private async void transform_components(MarkupDocument doc) throws Error {
+            var components = doc.select("//spry-component");
+            foreach (var component_node in components) {
+                var component = get_component_instance_from_component_node(component_node);
+                var document = yield component.to_document();
+                component_node.replace_with_nodes(document.body.children);
+            }
         }
 
-        public async HttpResult to_result() throws Error {
-            var document = yield to_document();
-            return document.to_result(get_status());
+        private void transform_dynamic_attributes(MarkupDocument doc, bool is_rendering_dynamic) throws Error {
+            var nodes = doc.select("//*[@spry-dynamic]");
+            foreach (var node in nodes) {
+                var name = node.get_attribute("spry-dynamic");
+                
+                if(!is_rendering_dynamic && !has_any_parent_where(node, n => n.has_attribute("spry-continuation"))) {
+                    throw new ComponentError.INVALID_TEMPLATE("A tag with a spry-dynamic attribute must be the child of a tag with a spry-continuation attribute");
+                }
+
+                node.set_attribute("sse-swap", @"_spry-dynamic-$name");
+                node.set_attribute("hx-swap", "outerHTML");
+                if(!node.has_attribute("id")) {
+                    node.set_attribute("id", @"_spry-dynamic-$name-$context_key");
+                    node.set_attribute("id", @"_spry-dynamic-$name-$context_key");
+                }
+                node.remove_attribute("spry-dynamic");
+            }
         }
 
-        private class ComponentTemplate : MarkupTemplate {
-            private string _markup;
-            protected override string markup { get { return _markup; } }
+        private void transform_unique_attributes(MarkupDocument doc) throws Error {
+            var nodes = doc.select("//*[@spry-unique]");
+            var counter = 1000;
+            foreach (var node in nodes) {
+                if(node.has_attribute("id")) {
+                    throw new ComponentError.INVALID_TEMPLATE("Cannot specify id attribute for an element with a spry-unique attribute");
+                }
+                if(node.get_attributes().keys.any(a => a.has_prefix("spry-per-")) || has_any_parent_where(node, n => n.get_attributes().keys.any(a => a.has_prefix("spry-per-")))) {
+                    throw new ComponentError.INVALID_TEMPLATE("The spry-unique attribute is not valid on any element or child of any element with a spry-per attribute");
+                }
+                node.set_attribute("id", @"_spry-unique-$counter-$context_key");
+                node.set_attribute("id", @"_spry-unique-$counter-$context_key");
+                counter++;
+            }
+        }
 
-            public ComponentTemplate(string markup) {
-                this._markup = markup;
+        private void transform_if_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
+            var root = new PropertyDictionary();
+            root["this"] = new NativeElement<Component>(this);
+            var evaluation_context = context ?? new EvaluationContext(root);
+
+            MarkupNode node;
+            // Select one by one, so we don't have problems with nesting
+            while((node = doc.select_one("//*[@spry-if or @spry-else-if or @spry-else]")) != null) {
+                print(@"node $(node.tag_name)\n");
+                var expression_string = node.get_attribute("spry-if") ?? node.get_attribute("spry-else-if");
+                node.remove_attribute("spry-if");
+                node.remove_attribute("spry-else-if");
+                node.remove_attribute("spry-else");
+                if(expression_string == null) {
+                    print("null\n");
+                    // else case
+                    continue;
+                }
+
+                var result = evaluate_if_expression(evaluation_context, expression_string, node);
+                print(@"Result $(result)\n");
+                if(result) {
+                    // Hide any chained nodes
+                    MarkupNode chained_node;
+                    while((chained_node = node.next_element_sibling) != null) {
+                        if(chained_node.has_attribute("spry-else") || chained_node.get_attribute("spry-else-if") != null) {
+                            chained_node.remove();
+                        }
+                        else {
+                            // If a sibling has no spry-else or spry-else-if it breaks the chain
+                            break;
+                        }
+                    }
+                }
+                else {
+                    // Mark this node for removal when condition is false
+                    node.remove();
+                }
+            }
+        }
+
+        private bool evaluate_if_expression(EvaluationContext context, string expression_string, MarkupNode node) throws Error {
+            var expression = ExpressionParser.parse(expression_string);
+            var result = expression.evaluate(context);
+
+            bool boolean_result;
+            if(result.is<bool>()) {
+                boolean_result = result.as<bool>();
+            }
+            else if(result.is<int>()) {
+                boolean_result = result.as<int>() != 0;
+            }
+            else {
+                boolean_result = !result.is_null();
+            }
+
+            return boolean_result;
+        }
+
+        private async void transform_per_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
+            MarkupNode node;
+            // Select one by one, so we don't have problems with nesting
+            while((node = doc.nodes.first_or_default(n => n.get_attributes().any(a => a.key.has_prefix("spry-per-")))) != null) {
+                var attribute = node.get_attributes().first(s => s.key.has_prefix("spry-per-"));
+
+                var root = new PropertyDictionary();
+                root["this"] = new NativeElement<Component>(this);
+                var evaluation_context = context ?? new EvaluationContext(root);
+                var expression = ExpressionParser.parse(attribute.value);
+                var result = expression.evaluate(evaluation_context);
+
+                if(!result.assignable_to<Enumerable>()) {
+                    throw new ComponentError.INVALID_TYPE(@"The spry-per attribute must refer to a value of type Invercargill.Enumerable, '$(attribute.value)' evaluates to a $(result.type_name())");
+                }
+
+                node.remove_attribute(attribute.key);
+                var values = result.as<Enumerable>().to_elements();
+                var output_nodes = new Series<MarkupNode>();
+                foreach(var value in values) {
+                    evaluation_context.root_values[attribute.key[9:]] = value;
+                    var fragment = new MarkupDocument();
+                    fragment.body.append_node(node);
+                    
+                    // Basic transform pipeline for fragment, the rest will get processed as part
+                    // of the wider component document later.
+                    transform_if_attributes(fragment, evaluation_context);
+                    remove_hidden_blocks(fragment);
+                    yield transform_per_attributes(fragment, evaluation_context);
+                    yield transform_expression_attributes(fragment, evaluation_context);
+                    
+                    output_nodes.add_all(fragment.body.children);
+                }
+                node.replace_with_nodes(output_nodes);
+            }
+        }
+
+        private void transform_context_nodes(MarkupDocument doc) throws Error {
+
+            var nodes = doc.select("//spry-context"); // Can't check for suffixes with xpath so iterate all nodes with attributes
+            foreach (var node in nodes) {
+                var property_name = node.get_attribute("property");
+                if(property_name == null) {
+                    throw new ComponentError.INVALID_TEMPLATE("Tag spry-context must have a property attribute");
+                }
+                _context_properties.add(property_name);
+                node.remove();
             }
         }
 
+        private async void transform_expression_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
+            var nodes = doc.select("//*[@*]"); // Can't check for suffixes with xpath so iterate all nodes with attributes
+            foreach (var node in nodes) {
+                var attributes = node.get_attributes();
+                foreach (var attribute in attributes) {
+                    if(!attribute.key.has_suffix("-expr")) {
+                        continue;
+                    }
+
+                    var real_attribute = attribute.key.substring(0, attribute.key.length - 5);
+                    var root = new PropertyDictionary();
+                    root["this"] = new NativeElement<Component>(this);
+                    var evaluation_context = context ?? new EvaluationContext(root);
+                    var expression = ExpressionParser.parse(attribute.value);
+                    var result = expression.evaluate(evaluation_context);
+
+                    node.remove_attribute(attribute.key);
+                    
+                    // class.* can be boolean
+                    if(result.type().is_a(typeof(bool)) && real_attribute.has_prefix("class-")) {
+                        var class_name = real_attribute.split("-", 2)[1];
+                        if(result.as<bool>()) {
+                            if(!node.has_class(class_name)) {
+                                node.add_class(class_name);
+                            }
+                        }
+                        else {
+                            node.remove_class(class_name);
+                        }
+                        continue;
+                    }
 
+                    if(real_attribute == "content" && result.type().is_a(typeof(Renderable))) {
+                        var renderable = result.as<Renderable>();
+                        var document = yield renderable.to_document();
+                        if(node.tag_name == "spry-outlet") {
+                            if(node.get_attribute("sid") != null) {
+                                throw new ComponentError.CONFLICTING_ATTRIBUTES("Tag 'spry-outlet' cannot have both a 'content-expr' and 'sid' attribute");
+                            }
+                            node.replace_with_nodes(document.body.children);
+                        }
+                        else {
+                            node.clear_children();
+                            node.append_nodes(document.body.children);
+                        }
+                        continue;
+                    }
+
+                    // everything else read as string
+                    var str_value = result.as<string>();
+
+                    if(real_attribute == "content") {
+                        node.text_content = str_value;
+                        continue;
+                    }
+
+                    if(real_attribute.has_prefix("style-")) {
+                        var style_name = real_attribute.split("-", 2)[1];
+                        node.set_style(style_name, str_value);
+                        continue;
+                    }
+
+                    node.set_attribute(real_attribute, str_value);
+                }
+            }
+        }
+
+        private bool has_any_parent_where(MarkupNode node, PredicateDelegate<MarkupNode> predicate) {
+            var current = node;
+            while((current = current.parent) != null) {
+                if(predicate(current)) {
+                    return true;
+                }
+            }
+            return false;
+        }
 
     }
 

+ 26 - 0
src/ComponentEndpoint.vala

@@ -9,11 +9,13 @@ namespace Spry {
     public class ComponentEndpoint : Object, Endpoint {
 
         private PathProvider component_uri_provider = inject<PathProvider>();
+        private CryptographyProvider cryptograpy_provider = inject<CryptographyProvider>();
         private Scope scope = inject<Scope>();
 
         public async Astralis.HttpResult handle_request (HttpContext http_context, RouteContext route_context) throws Error {
             var component_id = route_context.mapped_parameters.get_or_default ("component-id");
             var action_name = route_context.mapped_parameters.get_or_default ("action");
+            var context_blob = route_context.mapped_parameters.get_or_default ("context");
             if(component_id == null) {
                 return new HttpStringResult ("Missing component ID", StatusCode.BAD_REQUEST);
             }
@@ -24,6 +26,30 @@ namespace Spry {
             if(component == null) {
                 return new HttpStringResult ("Invalid component ID", StatusCode.NOT_FOUND);
             }
+            if(context_blob != null) {
+                ComponentContext context;
+                try {
+                    context = cryptograpy_provider.read_component_context_blob (context_blob);
+                }
+                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()) {
+                    return new HttpStringResult ("Context mismatch", StatusCode.BAD_REQUEST);
+                }
+
+                component.context_key = context.context_key;
+                
+                foreach(var prop in context.data) {
+                    unowned var component_class = component.get_class ();
+                    var prop_spec = component_class.find_property (prop.key);
+                    if(prop_spec == null) {
+                        return new HttpStringResult (@"Invalid context property '$(prop.key)'", StatusCode.BAD_REQUEST);
+                    }
+                    component.set_property (prop.key, prop.value.to_value (prop_spec.value_type));
+                }
+            }
             
             yield component.handle_action(action_name);
             return yield component.to_result();

+ 14 - 1
src/ComponentFactory.vala

@@ -17,10 +17,23 @@ namespace Spry {
             return (Component)scope.resolve_registration(component_registration);
         }
 
-        public new T create<T>() throws Error {
+        public T create<T>() throws Error {
             return (T)create_type(typeof(T));
         } 
 
+        public Component create_by_name(string name) throws Error {
+            var type_name = name.replace(".", "");
+            var registration = scope.get_all_registrations()
+                .where(r => r.implementation_type.is_a(typeof(Component)))
+                .first_or_default(r => r.implementation_type.name() == type_name);
+
+            if(registration == null) {
+                throw new ComponentError.TYPE_NOT_FOUND(@"Could not find type $type_name");
+            }
+
+            return (Component)scope.resolve_registration(registration);
+        }
+
     }
 
 }

+ 49 - 0
src/ContinuationContext.vala

@@ -0,0 +1,49 @@
+using Astralis;
+
+namespace Spry {
+
+    public class ContinuationContext : Object {
+
+        public SseStream sse_stream { get; private set;}
+        private Component component;
+
+        public ContinuationContext(Component component, SseStream stream) {
+            this.sse_stream = stream;
+            this.component = component;
+        }
+
+        public async void send_dynamic(string dynamic_name) throws Error {
+            var fragment = yield this.component.get_dynamic_section(dynamic_name);
+            yield sse_stream.send_event(new SseEvent.with_type(@"_spry-dynamic-$dynamic_name", fragment.body.inner_html));
+        }
+
+        public async void send_json(string event_type, Json.Node node) throws Error {
+            var json = Json.to_string(node, false);
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, json));
+        }
+
+        public async void send_string(string event_type, string data) throws Error {
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, data));
+        }
+
+        public async void send_full_update(string event_type) throws Error {
+            var doc = yield component.to_document();
+            string data;
+            if(component.get_type().is_a(typeof(PageComponent))) {
+                data = doc.to_html();
+            }
+            else {
+                data = doc.body.inner_html;
+            }
+            yield sse_stream.send_event(new SseEvent.with_type(event_type, data));
+        }
+
+        public async void send_event(SseEvent event) throws Error {
+            yield sse_stream.send_event(event);
+        }
+
+        
+
+    }
+
+}

+ 2 - 1
src/ContinuationProvider.vala

@@ -21,7 +21,8 @@ namespace Spry {
                 }
 
                 try {
-                    yield component.continuation(stream);
+                    var context = new ContinuationContext(component, stream);
+                    yield component.continuation(context);
                 }
                 catch(Error e) {
                     warning(@"Component $(component.get_type().name()) threw exception in follow up: $(e.message)");

+ 205 - 0
src/CryptographyProvider.vala

@@ -0,0 +1,205 @@
+using Invercargill;
+using Invercargill.Mapping;
+using Invercargill.DataStructures;
+using InvercargillJson;
+namespace Spry {
+
+    /**
+     * Result of token validation containing the payload and status information.
+     */
+    public class TokenValidationResult : Object {
+        /**
+         * Whether the token was successfully decrypted and verified.
+         */
+        public bool is_valid { get; construct set; }
+
+        /**
+         * The decrypted payload string, or null if validation failed.
+         */
+        public string? payload { get; construct set; }
+
+        /**
+         * Error message describing why validation failed, or null if valid.
+         */
+        public string? error_message { get; construct set; }
+
+        /**
+         * Whether the token has expired (only meaningful when is_valid is true).
+         */
+        public bool is_expired { get; construct set; }
+
+        /**
+         * Creates a successful validation result.
+         */
+        public TokenValidationResult.success(string payload, bool expired = false) {
+            Object(
+                is_valid: true,
+                payload: payload,
+                error_message: null,
+                is_expired: expired
+            );
+        }
+
+        /**
+         * Creates a failed validation result.
+         */
+        public TokenValidationResult.failure(string error_message, bool expired = false) {
+            Object(
+                is_valid: false,
+                payload: null,
+                error_message: error_message,
+                is_expired: expired
+            );
+        }
+    }
+
+    public class CryptographyProvider : Object {
+
+        private uint8[] signing_secret_key;
+        private uint8[] signing_public_key;
+        private uint8[] sealing_secret_key;
+        private uint8[] sealing_public_key;
+
+        construct {
+
+            signing_secret_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+            signing_public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Signing.generate_keypair(signing_public_key, signing_secret_key);
+
+            sealing_secret_key = new uint8[Sodium.Asymmetric.Sealing.SECRET_KEY_BYTES];
+            sealing_public_key = new uint8[Sodium.Asymmetric.Sealing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Sealing.generate_keypair(sealing_public_key, sealing_secret_key);
+
+        }
+
+        public string author_component_context_blob(ComponentContext context) throws Error {
+
+            var mapper = ComponentContext.get_mapper();
+            var properties = mapper.map_from(context);
+            var json = new JsonElement.from_properties(properties);
+            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("/", "_");
+        }
+
+        public ComponentContext read_component_context_blob(string blob) throws Error {
+
+            var mapper = ComponentContext.get_mapper();
+            var signed = Sodium.Asymmetric.Sealing.unseal(Base64.decode(blob.replace("-", "+").replace("_", "/")), sealing_public_key, sealing_secret_key);
+            if(signed == null) {
+                throw new ComponentError.INVALID_CONTEXT("Could not unseal context");
+            }
+            var cleartext = Sodium.Asymmetric.Signing.verify(signed, signing_public_key);
+            if(signed == null) {
+                throw new ComponentError.INVALID_CONTEXT("Invalid context signature");
+            }
+            var json = new JsonElement.from_string(Wrap.byte_array(cleartext).to_raw_string());
+            var context = mapper.materialise(json.as<JsonObject>());
+            return context;
+        }
+
+        /**
+         * Creates a signed and encrypted session token.
+         *
+         * The token is created by first signing the payload with Ed25519,
+         * then encrypting the signed data with X25519-Seal. The result is
+         * URL-safe Base64 encoded.
+         *
+         * @param payload The JSON payload string containing session data
+         * @param expires_at Optional expiry timestamp to include in the token
+         * @return A URL-safe Base64 encoded token string
+         */
+        public string sign_then_seal_token(string payload, DateTime? expires_at = null) {
+            // Build the token JSON with optional expiry
+            string token_json;
+            if (expires_at != null) {
+                var exp_str = ((!)expires_at).format_iso8601();
+                // Escape the payload JSON for embedding in a JSON string
+                var escaped_payload = payload.replace("\\", "\\\\").replace("\"", "\\\"");
+                token_json = @"{\"payload\":\"$escaped_payload\",\"exp\":\"$exp_str\"}";
+            } else {
+                var escaped_payload = payload.replace("\\", "\\\\").replace("\"", "\\\"");
+                token_json = @"{\"payload\":\"$escaped_payload\"}";
+            }
+
+            // Sign then seal (same pattern as component context blobs)
+            var signed = Sodium.Asymmetric.Signing.sign(token_json.data, signing_secret_key);
+            var @sealed = Sodium.Asymmetric.Sealing.seal(signed, sealing_public_key);
+
+            // URL-safe Base64 encoding
+            return Base64.encode(@sealed).replace("+", "-").replace("/", "_");
+        }
+
+        /**
+         * Decrypts and verifies a session token.
+         *
+         * The token is decrypted by first unsealing with X25519-Seal,
+         * then verifying the signature with Ed25519. If an expiry is
+         * present in the token, it is checked against the current time.
+         *
+         * @param token The URL-safe Base64 encoded token string
+         * @return A TokenValidationResult containing the validation outcome
+         */
+        public TokenValidationResult unseal_then_verify_token(string token) {
+            // URL-safe Base64 decode
+            var decoded = Base64.decode(token.replace("-", "+").replace("_", "/"));
+
+            // Unseal the token
+            var signed = Sodium.Asymmetric.Sealing.unseal(decoded, sealing_public_key, sealing_secret_key);
+            if (signed == null) {
+                return new TokenValidationResult.failure("Could not unseal token");
+            }
+
+            // Verify the signature
+            var cleartext = Sodium.Asymmetric.Signing.verify(signed, signing_public_key);
+            if (cleartext == null) {
+                return new TokenValidationResult.failure("Invalid token signature");
+            }
+
+            // Parse the token payload
+            try {
+                var json_string = Wrap.byte_array(cleartext).to_raw_string();
+                var json = new JsonElement.from_string(json_string);
+                var obj = json.as<JsonObject>();
+
+                // Check expiry if present
+                if (obj.has("exp")) {
+                    var exp_str = obj.get("exp").as<string>();
+                    var expires_at = new DateTime.from_iso8601(exp_str, new TimeZone.utc());
+                    if (expires_at.compare(new DateTime.now_utc()) <= 0) {
+                        // Token has expired - we still return the payload but mark as expired
+                        var payload_str = obj.get("payload").as<string>();
+                        return new TokenValidationResult.success(payload_str, true);
+                    }
+                }
+
+                // Extract and return the payload
+                var payload_str = obj.get("payload").as<string>();
+                return new TokenValidationResult.success(payload_str);
+            } catch (Error e) {
+                return new TokenValidationResult.failure("Invalid token format: %s".printf(e.message));
+            }
+        }
+    }
+
+    public class ComponentContext {
+
+        public string type_name { get; set; }
+        public string context_key { get; set; }
+        public DateTime timestamp { get; set; }
+        public Properties data { get; set; }
+
+        public static PropertyMapper<ComponentContext> get_mapper() {
+            return PropertyMapper.build_for<ComponentContext>(cfg => {
+                cfg.map<string>("c", o => o.type_name, (o, v) => o.type_name = v);
+                cfg.map<string>("k", o => o.context_key, (o, v) => o.context_key = v);
+                cfg.map<string>("t", o => o.timestamp.format_iso8601(), (o, v) => o.timestamp = new DateTime.from_iso8601(v, new TimeZone.utc()));
+                cfg.map<Properties>("d", o => o.data, (o, v) => o.data = v);
+                cfg.set_constructor(() => new ComponentContext());
+            });
+        }
+
+    }
+
+}

+ 0 - 2
src/PageComponent.vala

@@ -13,9 +13,7 @@ namespace Spry {
             // Get templates as renderables in order of lowest to highest "rank"
             var renderables = scope.get_registrations(typeof(PageTemplate))
                 .select_many<Pair<Registration, TemplateRoutePrefix>>(r => r.get_metadata<TemplateRoutePrefix>().select_pairs<Registration, TemplateRoutePrefix>(p => r, p => p))
-                .debug_trace("Pre-filter")
                 .where(p => p.value2.matches_route(route_context))
-                .debug_trace("Post-filter")
                 .order_by<uint>(p => p.value2.rank)
                 .attempt_select<Object>(p => scope.resolve_registration (p.value1))
                 .cast<Renderable>()

+ 6 - 0
src/PathProvider.vala

@@ -1,4 +1,5 @@
 using Invercargill.DataStructures;
+using Invercargill;
 using Inversion;
 
 namespace Spry {
@@ -21,6 +22,11 @@ namespace Spry {
             return @"/_spry/com/$(component_id)/$action_name";
         }
 
+        public string get_action_path_with_context(string type_name, string action_name, string context_blob) throws Error {
+            var component_id = upsert(type_name);
+            return @"/_spry/com/$(component_id)/$action_name/$context_blob";
+        }
+
         private string upsert(string type_name) throws Error {
             if(name_mapping.has(type_name)) {
                 return name_mapping[type_name];

+ 209 - 0
src/ResponseState.vala

@@ -0,0 +1,209 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Astralis;
+
+namespace Spry {
+
+    /**
+     * Redirect type for component responses.
+     */
+    public enum RedirectType {
+        /** Client-side redirect using HX-Redirect header - default for HTMX */
+        CLIENT_SIDE,
+        /** HTTP 302 temporary redirect using Location header */
+        TEMPORARY,
+        /** HTTP 301 permanent redirect using Location header */
+        PERMANENT
+    }
+
+    /**
+     * Holds response state that will be applied to the final HttpResult.
+     * 
+     * Components can modify this during action handling to influence
+     * the HTTP response without needing to return an HttpResult directly.
+     * 
+     * This is particularly useful for async void action handlers that need
+     * to set cookies, headers, or redirects on the response.
+     * 
+     * Example usage:
+     * {{{
+     * public override async void handle_action(string action) throws Error {
+     *     if (action == "login") {
+     *         // Set a cookie
+     *         response.set_cookie("session", token, 86400);
+     *         
+     *         // Redirect after login
+     *         response.redirect("/dashboard");
+     *     }
+     * }
+     * }}}
+     */
+    public class ResponseState : GLib.Object {
+        
+        // Headers to add to the response (name -> value)
+        private HashTable<string, string> _headers;
+        
+        // Cookies to set (each as a complete Set-Cookie header value)
+        private List<string> _cookies;
+        
+        // HTTP status code override
+        private StatusCode? _status_code = null;
+        
+        // Redirect configuration
+        private string? _redirect_url = null;
+        private RedirectType _redirect_type = RedirectType.CLIENT_SIDE;
+        
+        public ResponseState() {
+            _headers = new HashTable<string, string>(str_hash, str_equal);
+            _cookies = new List<string>();
+        }
+        
+        /**
+         * Sets a header on the response.
+         * If a header with this name already exists, it will be replaced.
+         */
+        public void set_header(string name, string value) {
+            _headers.insert(name, value);
+        }
+        
+        /**
+         * Adds a header to the response.
+         * Multiple headers with the same name can be added.
+         * Use this for headers like Set-Cookie that can appear multiple times.
+         */
+        public void add_header(string name, string value) {
+            // For Set-Cookie, we store in the cookies list
+            // For other headers, we just replace (since HttpResult doesn't support true multi-headers)
+            if (name == "Set-Cookie") {
+                _cookies.append(value);
+            } else {
+                _headers.insert(name, value);
+            }
+        }
+        
+        /**
+         * Sets the HTTP status code for the response.
+         */
+        public void set_status(StatusCode status) {
+            _status_code = status;
+        }
+        
+        /**
+         * Sets up a redirect.
+         * Default type is CLIENT_SIDE which uses HX-Redirect header for HTMX.
+         * 
+         * @param url The URL to redirect to
+         * @param type The type of redirect (default: CLIENT_SIDE for HTMX)
+         */
+        public void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) {
+            _redirect_url = url;
+            _redirect_type = type;
+        }
+        
+        /**
+         * Convenience method to set a session cookie.
+         * 
+         * @param name Cookie name
+         * @param value Cookie value
+         * @param max_age Max age in seconds (-1 for session cookie)
+         * @param path Cookie path (default: "/")
+         * @param http_only Whether to set HttpOnly flag (default: true)
+         * @param secure Whether to set Secure flag (default: true)
+         * @param same_site SameSite value (default: "Strict")
+         */
+        public void set_cookie(
+            string name, 
+            string value, 
+            int max_age = -1,
+            string path = "/",
+            bool http_only = true,
+            bool secure = true,
+            string same_site = "Strict"
+        ) {
+            var cookie_parts = new StringBuilder();
+            cookie_parts.append_printf("%s=%s", name, value);
+            
+            if (path != null && path.length > 0) {
+                cookie_parts.append_printf("; Path=%s", path);
+            }
+            
+            if (max_age >= 0) {
+                cookie_parts.append_printf("; Max-Age=%d", max_age);
+            }
+            
+            if (http_only) {
+                cookie_parts.append("; HttpOnly");
+            }
+            
+            if (secure) {
+                cookie_parts.append("; Secure");
+            }
+            
+            if (same_site != null && same_site.length > 0) {
+                cookie_parts.append_printf("; SameSite=%s", same_site);
+            }
+            
+            _cookies.append(cookie_parts.str);
+        }
+        
+        /**
+         * Checks if any modifications have been made.
+         */
+        public bool has_modifications() {
+            uint headers_count = 0;
+            _headers.foreach((k, v) => { headers_count++; });
+            return headers_count > 0 ||
+                   _cookies.length() > 0 ||
+                   _status_code != null ||
+                   _redirect_url != null;
+        }
+        
+        /**
+         * Gets the status code if one was set.
+         */
+        public StatusCode? get_status_code() {
+            return _status_code;
+        }
+        
+        /**
+         * Checks if a redirect has been configured.
+         */
+        public bool has_redirect() {
+            return _redirect_url != null;
+        }
+        
+        /**
+         * Applies all accumulated headers to an HttpResult.
+         * Note: This only applies headers. Status code and redirect handling
+         * should be done by creating an appropriate HttpResult subclass.
+         */
+        internal void apply_headers(HttpResult result) {
+            // Apply single-value headers
+            _headers.foreach((key, value) => {
+                result.set_header(key, value);
+            });
+            
+            // Apply cookies as Set-Cookie headers
+            foreach (var cookie in _cookies) {
+                result.set_header("Set-Cookie", cookie);
+            }
+            
+            // Apply redirect header for HTMX
+            if (_redirect_url != null && _redirect_type == RedirectType.CLIENT_SIDE) {
+                result.set_header("HX-Redirect", _redirect_url);
+            }
+        }
+        
+        /**
+         * Clears all accumulated state.
+         * Called after to_result() to prepare for next request.
+         */
+        internal void reset() {
+            _headers.remove_all();
+            _cookies = new List<string>();
+            _status_code = null;
+            _redirect_url = null;
+            _redirect_type = RedirectType.CLIENT_SIDE;
+        }
+    }
+}

+ 14 - 1
src/Spry.vala

@@ -6,6 +6,7 @@ namespace Spry {
     public class SpryModule : Object, Module {
         public void register_components (Container container) throws Error {
             container.register_singleton<PathProvider>();
+            container.register_startup<CryptographyProvider>();
             container.register_scoped<ComponentFactory>();
 
             container.register_startup<ContinuationProvider>()
@@ -18,7 +19,8 @@ namespace Spry {
 
             container.register_scoped<ComponentEndpoint>()
                 .as<Endpoint>()
-                .with_metadata<EndpointRoute>(new EndpointRoute("/_spry/com/{component-id}/{action}"));
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_spry/com/{component-id}/{action}"))
+                .with_metadata<EndpointRoute>(new EndpointRoute("/_spry/com/{component-id}/{action}/{context}"));
 
             container.register_startup<Static.HtmxResource>()
                 .as<Spry.StaticResource>();
@@ -38,6 +40,17 @@ namespace Spry {
                 .with_metadata<TemplateRoutePrefix>(new TemplateRoutePrefix(prefix));
         }
 
+        public void add_page<T>(EndpointRoute route) {
+            container.register_scoped<T>()
+                .as<Endpoint>()
+                .with_metadata<EndpointRoute>(route);
+        }
+
+        public void add_component<T>() {
+            container.register_transient<T>();
+        }
+
+
     }
 
 }

+ 7 - 1
src/meson.build

@@ -1,5 +1,6 @@
 sources = files(
     'Spry.vala',
+    'ResponseState.vala',
     'Component.vala',
     'PageComponent.vala',
     'PageTemplate.vala',
@@ -9,6 +10,8 @@ sources = files(
     'Context.vala',
     'PathProvider.vala',
     'ContinuationProvider.vala',
+    'ContinuationContext.vala',
+    'CryptographyProvider.vala',
     'Static/StaticResource.vala',
     'Static/MemoryStaticResource.vala',
     'Static/FileStaticResource.vala',
@@ -22,7 +25,7 @@ sources = files(
 library_version = meson.project_version()
 libspry = shared_library('spry-@0@'.format(library_version),
     sources,
-    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, inversion_dep, libxml_dep, astralis_dep],
+    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, inversion_dep, libxml_dep, astralis_dep, sodium_deps],
     install: true,
     vala_gir: 'spry-@0@.gir'.format(library_version),
     install_dir: [true, true, true, true]
@@ -47,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')

+ 1 - 1
tools/spry-mkssr/meson.build

@@ -4,6 +4,6 @@ spry_mkssr_sources = files(
 
 spry_mkssr = executable('spry-mkssr',
     spry_mkssr_sources,
-    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, astralis_dep, inversion_dep, libxml_dep],
+    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, json_glib_dep, astralis_dep, inversion_dep, libxml_dep],
     install: true
 )

+ 0 - 135
vapi/libbrotlienc.vapi

@@ -1,135 +0,0 @@
-/* libbrotlienc Vala bindings
- * 
- * Bindings for the Brotli compression library (encoder only)
- * Based on brotli encode.h and types.h
- */
-
-[CCode (cprefix = "BROTLI_", lower_case_cprefix = "brotli_", cheader_filename = "brotli/encode.h,brotli/types.h")]
-namespace Brotli {
-
-    /* Boolean type */
-    [CCode (cname = "BROTLI_BOOL", has_type_id = false, default_value = "BROTLI_FALSE")]
-    public struct Bool : int {
-    }
-    
-    [CCode (cname = "BROTLI_TRUE")]
-    public const Bool TRUE;
-    [CCode (cname = "BROTLI_FALSE")]
-    public const Bool FALSE;
-    
-    [CCode (cname = "TO_BROTLI_BOOL")]
-    public Bool to_bool(int x);
-
-    /* Memory allocation callbacks */
-    [CCode (cname = "brotli_alloc_func", has_target = false)]
-    public delegate void* AllocFunc(void* opaque, size_t size);
-    
-    [CCode (cname = "brotli_free_func", has_target = false)]
-    public delegate void FreeFunc(void* opaque, void* address);
-
-    /* Constants from encode.h */
-    [CCode (cname = "BROTLI_MIN_WINDOW_BITS")]
-    public const int MIN_WINDOW_BITS;
-    [CCode (cname = "BROTLI_MAX_WINDOW_BITS")]
-    public const int MAX_WINDOW_BITS;
-    [CCode (cname = "BROTLI_LARGE_MAX_WINDOW_BITS")]
-    public const int LARGE_MAX_WINDOW_BITS;
-    [CCode (cname = "BROTLI_MIN_INPUT_BLOCK_BITS")]
-    public const int MIN_INPUT_BLOCK_BITS;
-    [CCode (cname = "BROTLI_MAX_INPUT_BLOCK_BITS")]
-    public const int MAX_INPUT_BLOCK_BITS;
-    [CCode (cname = "BROTLI_MIN_QUALITY")]
-    public const int MIN_QUALITY;
-    [CCode (cname = "BROTLI_MAX_QUALITY")]
-    public const int MAX_QUALITY;
-    [CCode (cname = "BROTLI_DEFAULT_QUALITY")]
-    public const int DEFAULT_QUALITY;
-    [CCode (cname = "BROTLI_DEFAULT_WINDOW")]
-    public const int DEFAULT_WINDOW;
-
-    /* Encoder mode */
-    [CCode (cname = "BrotliEncoderMode", has_type_id = false)]
-    public enum EncoderMode {
-        [CCode (cname = "BROTLI_MODE_GENERIC")]
-        GENERIC,
-        [CCode (cname = "BROTLI_MODE_TEXT")]
-        TEXT,
-        [CCode (cname = "BROTLI_MODE_FONT")]
-        FONT
-    }
-
-    /* Encoder operation for streaming */
-    [CCode (cname = "BrotliEncoderOperation", has_type_id = false)]
-    public enum EncoderOperation {
-        [CCode (cname = "BROTLI_OPERATION_PROCESS")]
-        PROCESS,
-        [CCode (cname = "BROTLI_OPERATION_FLUSH")]
-        FLUSH,
-        [CCode (cname = "BROTLI_OPERATION_FINISH")]
-        FINISH,
-        [CCode (cname = "BROTLI_OPERATION_EMIT_METADATA")]
-        EMIT_METADATA
-    }
-
-    /* Encoder parameters */
-    [CCode (cname = "BrotliEncoderParameter", has_type_id = false)]
-    public enum EncoderParameter {
-        [CCode (cname = "BROTLI_PARAM_MODE")]
-        MODE,
-        [CCode (cname = "BROTLI_PARAM_QUALITY")]
-        QUALITY,
-        [CCode (cname = "BROTLI_PARAM_LGWIN")]
-        LGWIN,
-        [CCode (cname = "BROTLI_PARAM_LGBLOCK")]
-        LGBLOCK,
-        [CCode (cname = "BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING")]
-        DISABLE_LITERAL_CONTEXT_MODELING,
-        [CCode (cname = "BROTLI_PARAM_SIZE_HINT")]
-        SIZE_HINT,
-        [CCode (cname = "BROTLI_PARAM_LARGE_WINDOW")]
-        LARGE_WINDOW,
-        [CCode (cname = "BROTLI_PARAM_NPOSTFIX")]
-        NPOSTFIX,
-        [CCode (cname = "BROTLI_PARAM_NDIRECT")]
-        NDIRECT,
-        [CCode (cname = "BROTLI_PARAM_STREAM_OFFSET")]
-        STREAM_OFFSET
-    }
-
-    /* Encoder state - opaque structure */
-    [CCode (cname = "BrotliEncoderState", free_function = "BrotliEncoderDestroyInstance")]
-    [Compact]
-    public class EncoderState {
-        [CCode (cname = "BrotliEncoderCreateInstance")]
-        public EncoderState(AllocFunc? alloc_func, FreeFunc? free_func, void* opaque);
-        
-        [CCode (cname = "BrotliEncoderSetParameter")]
-        public Bool set_parameter(EncoderParameter param, uint32 value);
-        
-        [CCode (cname = "BrotliEncoderCompressStream")]
-        public Bool compress_stream(EncoderOperation op, ref size_t available_in, 
-            ref uint8* next_in, ref size_t available_out, ref uint8* next_out, 
-            out size_t total_out);
-        
-        [CCode (cname = "BrotliEncoderIsFinished")]
-        public Bool is_finished();
-        
-        [CCode (cname = "BrotliEncoderHasMoreOutput")]
-        public Bool has_more_output();
-        
-        [CCode (cname = "BrotliEncoderTakeOutput")]
-        public unowned uint8* take_output(ref size_t size);
-    }
-
-    /* Encoder one-shot functions */
-    [CCode (cname = "BrotliEncoderMaxCompressedSize")]
-    public size_t encoder_max_compressed_size(size_t input_size);
-    
-    [CCode (cname = "BrotliEncoderCompress")]
-    public Bool encoder_compress(int quality, int lgwin, EncoderMode mode, 
-        size_t input_size, uint8* input_buffer, 
-        ref size_t encoded_size, uint8* encoded_buffer);
-    
-    [CCode (cname = "BrotliEncoderVersion")]
-    public uint32 encoder_version();
-}

+ 0 - 159
vapi/libmicrohttpd.vapi

@@ -1,159 +0,0 @@
-[CCode (cheader_filename = "microhttpd.h")]
-namespace MHD {
-    [CCode (cname = "enum MHD_ValueKind", cprefix = "MHD_")]
-    public enum ValueKind {
-        RESPONSE_HEADER_KIND,
-        HEADER_KIND,
-        COOKIE_KIND,
-        POSTDATA_KIND,
-        GET_ARGUMENT_KIND,
-        FOOTER_KIND
-    }
-
-    [CCode (cname = "enum MHD_RequestTerminationCode", cprefix = "MHD_REQUEST_TERMINATED_")]
-    public enum RequestTerminationCode {
-        COMPLETED_OK,
-        WITH_ERROR,
-        TIMEOUT_REACHED,
-        DAEMON_SHUTDOWN,
-        READ_ERROR,
-        CLIENT_ABORT
-    }
-
-    [CCode (cname = "enum MHD_ResponseMemoryMode", cprefix = "MHD_")]
-    public enum ResponseMemoryMode {
-        RESPMEM_PERSISTENT,
-        RESPMEM_MUST_FREE,
-        RESPMEM_MUST_COPY
-    }
-
-    [CCode (cname = "MHD_CONTENT_READER_END_OF_STREAM")]
-    public const ssize_t CONTENT_READER_END_OF_STREAM;
-    [CCode (cname = "MHD_CONTENT_READER_END_WITH_ERROR")]
-    public const ssize_t CONTENT_READER_END_WITH_ERROR;
-
-    [CCode (cname = "MHD_USE_SELECT_INTERNALLY")]
-    public const uint USE_SELECT_INTERNALLY;
-    [CCode (cname = "MHD_USE_THREAD_PER_CONNECTION")]
-    public const uint USE_THREAD_PER_CONNECTION;
-    [CCode (cname = "MHD_USE_DEBUG")]
-    public const uint USE_DEBUG;
-    [CCode (cname = "MHD_ALLOW_SUSPEND_RESUME")]
-    public const uint ALLOW_SUSPEND_RESUME;
-
-    [CCode (cname = "MHD_OPTION_END")]
-    public const int OPTION_END;
-
-    [SimpleType]
-    [CCode (cname = "struct MHD_Connection*")]
-    public struct Connection {}
-
-    [CCode (cname = "int", cprefix = "MHD_")]
-    public enum Result {
-        YES = 1,
-        NO = 0
-    }
-
-    [Compact]
-    [CCode (cname = "struct MHD_Response", free_function = "MHD_destroy_response")]
-    public class Response {
-        [CCode (cname = "MHD_create_response_from_buffer")]
-        public Response.from_buffer (size_t size, [CCode(array_length=false)] uint8[] buffer, ResponseMemoryMode mode);
-
-        [CCode (cname = "MHD_create_response_from_callback")]
-        public Response.from_callback (uint64 size, size_t block_size, ContentReaderCallback crc, void* crc_cls, ContentReaderFreeCallback? crfc);
-
-        [CCode (cname = "MHD_add_response_header")]
-        public int add_header (string header, string content);
-    }
-
-    [Compact]
-    [CCode (cname = "struct MHD_Daemon", free_function = "MHD_stop_daemon")]
-    public class Daemon {
-        [CCode (cname = "MHD_start_daemon")]
-        public static Daemon start (uint flags, uint16 port, 
-            AcceptPolicyCallback? apc, 
-            AccessHandlerCallback dh, 
-            ...);
-    }
-
-    [CCode (instance_pos = 0)]
-    public delegate int AcceptPolicyCallback (void* cls, void* addr, uint addrlen);
-
-    [CCode (instance_pos = 0)]
-    public delegate int AccessHandlerCallback (Connection connection, 
-        string? url, string? method, string? version, 
-        string? upload_data, [CCode(array_length=false)] size_t* upload_data_size, 
-        void** con_cls);
-
-    [CCode (has_target = false)]
-    public delegate ssize_t ContentReaderCallback (void* cls, uint64 pos, char* buf, size_t max);
-
-    [CCode (has_target = false, cname = "MHD_ContentReaderFreeCallback")]
-    public delegate void ContentReaderFreeCallback (void* cls);
-    
-    [CCode (cname = "MHD_queue_response")]
-    public int queue_response (Connection connection, uint status_code, Response response);
-
-    [CCode (cname = "MHD_suspend_connection")]
-    public int suspend_connection (Connection connection);
-
-    [CCode (cname = "MHD_resume_connection")]
-    public int resume_connection (Connection connection);
-
-    [CCode (cname = "MHD_lookup_connection_value")]
-    public unowned string? lookup_connection_value (Connection connection, ValueKind kind, string key);
-
-    [CCode (has_target = false)]
-    public delegate Result KeyValueIterator (void* cls, ValueKind kind, string key, string? value);
-
-    [CCode (cname = "MHD_get_connection_values")]
-    public int get_connection_values (Connection connection, ValueKind kind, KeyValueIterator? iterator, void* iterator_cls = null);
-
-    [CCode (cname = "enum MHD_ConnectionInfoType", cprefix = "MHD_CONNECTION_INFO_")]
-    public enum ConnectionInfoType {
-        CIPHER_ALGO,
-        PROTOCOL,
-        CLIENT_ADDRESS,
-        GNUTLS_SESSION,
-        GNUTLS_CLIENT_CERT,
-        DAEMON,
-        CONNECTION_FD,
-        SOCKET_CONTEXT,
-        CONNECTION_SUSPENDED,
-        CONNECTION_TIMEOUT,
-        REQUEST_HEADER_SIZE,
-        HTTP_STATUS
-    }
-
-    [CCode (cname = "union MHD_ConnectionInfo", has_type_id = false)]
-    public struct ConnectionInfo {
-        [CCode (cname = "cipher_algorithm")]
-        public int cipher_algorithm;
-        [CCode (cname = "protocol")]
-        public int protocol;
-        [CCode (cname = "client_cert")]
-        public void* client_cert;
-        [CCode (cname = "tls_session")]
-        public void* tls_session;
-        [CCode (cname = "daemon")]
-        public Daemon* daemon;
-        [CCode (cname = "connect_fd")]
-        public int connect_fd;
-        [CCode (cname = "socket_context")]
-        public void* socket_context;
-        [CCode (cname = "suspended")]
-        public int suspended;
-        [CCode (cname = "connection_timeout")]
-        public uint connection_timeout;
-        [CCode (cname = "client_addr")]
-        public void* client_addr;
-        [CCode (cname = "header_size")]
-        public size_t header_size;
-        [CCode (cname = "http_status")]
-        public uint http_status;
-    }
-
-    [CCode (cname = "MHD_get_connection_info")]
-    public unowned ConnectionInfo? get_connection_info (Connection connection, ConnectionInfoType info_type);
-}

+ 284 - 0
vapi/libsodium.vapi

@@ -0,0 +1,284 @@
+/* Vala Bindings for LibSodium
+ * Copyright (c) 2020 Billy Barrow <billyb@pcthingz.com>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+ [CCode (cheader_filename = "sodium.h", lower_case_cprefix = "sodium_")]
+ namespace Sodium {
+ 
+   namespace Random {
+     [CCode (cname = "randombytes_SEEDBYTES")]
+     public const size_t SEED_BYTES;
+   
+     [CCode (cname = "randombytes_random")]
+     public uint32 random();
+   
+     [CCode (cname = "randombytes_uniform")]
+     public uint32 random_uniform(uint32 upper_bound);
+   
+     [CCode (cname = "randombytes_buf")]
+     public void random_bytes(uint8[] buffer);
+   
+     [CCode (cname = "randombytes_buf_deterministic")]
+     public void random_bytes_deterministic(uint8[] buffer, uint8[] seed);
+   }
+ 
+   namespace Symmetric {
+     [CCode (cname = "crypto_secretbox_KEYBYTES")]
+     public const size_t KEY_BYTES;
+ 
+     [CCode (cname = "crypto_secretbox_NONCEBYTES")]
+     public const size_t NONCE_BYTES;
+ 
+     [CCode (cname = "crypto_secretbox_MACBYTES")]
+     public const size_t MAC_BYTES;
+ 
+     [CCode (cname = "crypto_secretbox_keygen")]
+     private void key_gen([CCode (array_length = false)]uint8[] key);
+ 
+     public uint8[] generate_key() {
+       uint8[] key = new uint8[KEY_BYTES];
+       key_gen(key);
+       return key;
+     }
+ 
+     [CCode (cname = "crypto_secretbox_easy")]
+     private void secretbox(
+       [CCode (array_length = false)]uint8[] ciphertext,
+       uint8[] message,
+       [CCode (array_length = false)]uint8[] nonce,
+       [CCode (array_length = false)]uint8[] key
+     );
+ 
+     public uint8[] encrypt(uint8[] message, uint8[] key, uint8[] nonce)
+       requires (key.length == KEY_BYTES) 
+       requires (nonce.length == NONCE_BYTES)
+     {
+       // Initialise array for ciphertext
+       size_t ciphertext_size = MAC_BYTES + message.length;
+       uint8[] ciphertext = new uint8[ciphertext_size];
+ 
+       // Encrypt
+       secretbox(ciphertext, message, nonce, key);
+ 
+       // Return ciphertext
+       return ciphertext;
+     }
+ 
+     [CCode (cname = "crypto_secretbox_open_easy")]
+     private int secretbox_open(
+       [CCode (array_length = false)]uint8[] message,
+       uint8[] ciphertext,
+       [CCode (array_length = false)]uint8[] nonce,
+       [CCode (array_length = false)]uint8[] key
+     );
+ 
+     public uint8[]? decrypt(uint8[] ciphertext, uint8[] key, uint8[] nonce)
+       requires (ciphertext.length > MAC_BYTES)
+       requires (key.length == KEY_BYTES) 
+       requires (nonce.length == NONCE_BYTES)
+     {
+       // Initialise array for message
+       size_t message_size = ciphertext.length - MAC_BYTES;
+       uint8[] message = new uint8[message_size];
+ 
+       // Decrypt
+       int status = secretbox_open(message, ciphertext, nonce, key);
+ 
+       // Did it work?
+       if(status != 0) {
+         // No, return null
+         return null;
+       }
+ 
+       return message;
+     }
+   }
+   
+   namespace Asymmetric {
+ 
+     namespace Signing {
+ 
+         [CCode (cname = "crypto_sign_PUBLICKEYBYTES")]
+         public const size_t PUBLIC_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_sign_SECRETKEYBYTES")]
+         public const size_t SECRET_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_sign_BYTES")]
+         public const size_t MAX_HEADER_BYTES;
+ 
+         [CCode (cname = "crypto_sign_keypair")]
+         public void generate_keypair(
+             [CCode (array_length = false)]uint8[] public_key,
+             [CCode (array_length = false)]uint8[] secret_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+             requires (secret_key.length == SECRET_KEY_BYTES);
+             
+         [CCode (cname = "crypto_sign")]
+         private void sign_message(
+             [CCode (array_length = false)] uint8[] signed_message,
+             out int signature_length,
+             uint8[] message,
+             [CCode (array_length = false)] uint8[] secret_key
+         );
+ 
+         public uint8[] sign(
+             uint8[] message,
+             uint8[] secret_key)
+             requires (secret_key.length == SECRET_KEY_BYTES)
+         {
+             int signature_length;
+             uint8[] signed_message = new uint8[MAX_HEADER_BYTES + message.length];
+             sign_message(signed_message, out signature_length, message, secret_key);
+             signed_message.resize(signature_length);
+ 
+             return signed_message;
+         }
+ 
+         [CCode (cname = "crypto_sign_open")]
+         private int sign_open(
+             [CCode (array_length = false)] uint8[] message,
+             out int message_length,
+             uint8[] signed_message,
+             [CCode (array_length = false)] uint8[] public_key
+         );
+ 
+         public uint8[]? verify(
+             uint8[] signed_message,
+             uint8[] public_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+         {
+             int message_length;
+             uint8[] message = new uint8[signed_message.length];
+             if(sign_open(message, out message_length, signed_message, public_key) != 0) {
+                 return null;
+             }
+             message.resize(message_length);
+ 
+             return message;
+         }
+ 
+     }
+ 
+     namespace Sealing {
+ 
+         [CCode (cname = "crypto_box_PUBLICKEYBYTES")]
+         public const size_t PUBLIC_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_box_SECRETKEYBYTES")]
+         public const size_t SECRET_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_box_SEALBYTES")]
+         public const size_t HEADER_BYTES;
+ 
+         [CCode (cname = "crypto_box_keypair")]
+         public void generate_keypair(
+             [CCode (array_length = false)]uint8[] public_key,
+             [CCode (array_length = false)]uint8[] secret_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+             requires (secret_key.length == SECRET_KEY_BYTES);
+ 
+         [CCode (cname = "crypto_box_seal")]
+         private void seal_message(
+             [CCode (array_length = false)] uint8[] ciphertext,
+             uint8[] message,
+             [CCode (array_length = false)] uint8[] public_key
+         );
+ 
+         public uint8[] seal(uint8[] message, uint8[] public_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+         {
+             uint8[] ciphertext = new uint8[HEADER_BYTES + message.length];
+             seal_message(ciphertext, message, public_key);
+             return ciphertext;
+         }
+ 
+         [CCode (cname = "crypto_box_seal_open")]
+         private int seal_open(
+             [CCode (array_length = false)] uint8[] message,
+             uint8[] ciphertext,
+             [CCode (array_length = false)] uint8[] public_key,
+             [CCode (array_length = false)] uint8[] secret_key
+         );
+ 
+         public uint8[]? unseal(
+             uint8[] ciphertext,
+             uint8[] public_key,
+             uint8[] secret_key) 
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+             requires (secret_key.length == SECRET_KEY_BYTES)
+             requires (ciphertext.length > HEADER_BYTES)
+         {
+             uint8[] message = new uint8[ciphertext.length - HEADER_BYTES];
+             if(seal_open(message, ciphertext, public_key, secret_key) != 0){
+                 return null;
+             }
+              return message;
+          }
+          
+      }
+  
+    }
+    
+    namespace PasswordHashing {
+      [CCode (cname = "crypto_pwhash_STRBYTES")]
+      public const size_t STR_BYTES;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_INTERACTIVE")]
+      public const size_t OPSLIMIT_INTERACTIVE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_INTERACTIVE")]
+      public const size_t MEMLIMIT_INTERACTIVE;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_MODERATE")]
+      public const size_t OPSLIMIT_MODERATE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_MODERATE")]
+      public const size_t MEMLIMIT_MODERATE;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_SENSITIVE")]
+      public const size_t OPSLIMIT_SENSITIVE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_SENSITIVE")]
+      public const size_t MEMLIMIT_SENSITIVE;
+    
+      [CCode (cname = "crypto_pwhash_str")]
+      private int pwhash_str(
+        [CCode (array_length = false)] uint8[] out,
+        string passwd,
+        size_t passwdlen,
+        ulong opslimit,
+        size_t memlimit
+      );
+    
+      public string? hash(string password, ulong opslimit = OPSLIMIT_MODERATE, size_t memlimit = MEMLIMIT_MODERATE) {
+        uint8[] out_buf = new uint8[STR_BYTES];
+        if (pwhash_str(out_buf, password, password.length, opslimit, memlimit) != 0) {
+          return null;
+        }
+        // Null-terminated string
+        return (string)out_buf;
+      }
+    
+      [CCode (cname = "crypto_pwhash_str_verify")]
+      private int pwhash_str_verify(string hash, string password, size_t passwdlen);
+    
+      public bool check(string hash, string password) {
+        return pwhash_str_verify(hash, password, password.length) == 0;
+      }
+    }
+  
+  }

+ 0 - 286
vapi/libzstd.vapi

@@ -1,286 +0,0 @@
-/* libzstd.vapi - Vala bindings for Zstandard compression library
- * 
- * Copyright (c) Meta Platforms, Inc. and affiliates.
- * Vala bindings generated for use with the spry framework.
- * 
- * Based on zstd.h from libzstd 1.5.7
- */
-
-[CCode (cprefix = "ZSTD_", lower_case_cprefix = "ZSTD_", cheader_filename = "zstd.h,zstd_errors.h")]
-namespace Zstd {
-    
-    /* Version */
-    [CCode (cname = "ZSTD_VERSION_MAJOR")]
-    public const int VERSION_MAJOR;
-    [CCode (cname = "ZSTD_VERSION_MINOR")]
-    public const int VERSION_MINOR;
-    [CCode (cname = "ZSTD_VERSION_RELEASE")]
-    public const int VERSION_RELEASE;
-    [CCode (cname = "ZSTD_VERSION_NUMBER")]
-    public const int VERSION_NUMBER;
-    
-    [CCode (cname = "ZSTD_versionNumber")]
-    public uint version_number();
-    [CCode (cname = "ZSTD_versionString")]
-    public unowned string version_string();
-    
-    /* Default compression level */
-    [CCode (cname = "ZSTD_CLEVEL_DEFAULT")]
-    public const int CLEVEL_DEFAULT;
-    
-    /* Block size constants */
-    [CCode (cname = "ZSTD_BLOCKSIZELOG_MAX")]
-    public const int BLOCKSIZELOG_MAX;
-    [CCode (cname = "ZSTD_BLOCKSIZE_MAX")]
-    public const int BLOCKSIZE_MAX;
-    
-    /* Content size constants */
-    [CCode (cname = "ZSTD_CONTENTSIZE_UNKNOWN")]
-    public const uint64 CONTENTSIZE_UNKNOWN;
-    [CCode (cname = "ZSTD_CONTENTSIZE_ERROR")]
-    public const uint64 CONTENTSIZE_ERROR;
-    
-    /* Compression strategies */
-    [CCode (cname = "ZSTD_strategy", cprefix = "ZSTD_", has_type_id = false)]
-    public enum Strategy {
-        fast = 1,
-        dfast = 2,
-        greedy = 3,
-        lazy = 4,
-        lazy2 = 5,
-        btlazy2 = 6,
-        btopt = 7,
-        btultra = 8,
-        btultra2 = 9
-    }
-    
-    /* End directive for streaming */
-    [CCode (cname = "ZSTD_EndDirective", cprefix = "ZSTD_e_", has_type_id = false)]
-    public enum EndDirective {
-        continue = 0,
-        flush = 1,
-        end = 2
-    }
-    
-    /* Reset directive */
-    [CCode (cname = "ZSTD_ResetDirective", cprefix = "ZSTD_reset_", has_type_id = false)]
-    public enum ResetDirective {
-        session_only = 1,
-        parameters = 2,
-        session_and_parameters = 3
-    }
-    
-    /* Error codes */
-    [CCode (cname = "ZSTD_ErrorCode", cprefix = "ZSTD_error_", has_type_id = false)]
-    public enum ErrorCode {
-        no_error = 0,
-        GENERIC = 1,
-        prefix_unknown = 10,
-        version_unsupported = 12,
-        frameParameter_unsupported = 14,
-        frameParameter_windowTooLarge = 16,
-        corruption_detected = 20,
-        checksum_wrong = 22,
-        literals_headerWrong = 24,
-        dictionary_corrupted = 30,
-        dictionary_wrong = 32,
-        dictionaryCreation_failed = 34,
-        parameter_unsupported = 40,
-        parameter_combination_unsupported = 41,
-        parameter_outOfBound = 42,
-        tableLog_tooLarge = 44,
-        maxSymbolValue_tooLarge = 46,
-        maxSymbolValue_tooSmall = 48,
-        cannotProduce_uncompressedBlock = 49,
-        stabilityCondition_notRespected = 50,
-        stage_wrong = 60,
-        init_missing = 62,
-        memory_allocation = 64,
-        workSpace_tooSmall = 66,
-        dstSize_tooSmall = 70,
-        srcSize_wrong = 72,
-        dstBuffer_null = 74,
-        noForwardProgress_destFull = 80,
-        noForwardProgress_inputEmpty = 82,
-        frameIndex_tooLarge = 100,
-        seekableIO = 102,
-        dstBuffer_wrong = 104,
-        srcBuffer_wrong = 105,
-        sequenceProducer_failed = 106,
-        externalSequences_invalid = 107,
-        maxCode = 120
-    }
-    
-    /* Bounds structure */
-    [CCode (cname = "ZSTD_bounds", has_type_id = false)]
-    public struct Bounds {
-        public size_t error;
-        public int lowerBound;
-        public int upperBound;
-    }
-    
-    /* Input buffer for streaming */
-    [CCode (cname = "ZSTD_inBuffer", has_type_id = false)]
-    public struct InBuffer {
-        public uint8* src;
-        public size_t size;
-        public size_t pos;
-    }
-    
-    /* Output buffer for streaming */
-    [CCode (cname = "ZSTD_outBuffer", has_type_id = false)]
-    public struct OutBuffer {
-        public uint8* dst;
-        public size_t size;
-        public size_t pos;
-    }
-    
-    /* Compression context */
-    [CCode (cname = "ZSTD_CCtx", free_function = "ZSTD_freeCCtx", has_type_id = false)]
-    [Compact]
-    public class CCtx {
-        [CCode (cname = "ZSTD_createCCtx")]
-        public CCtx();
-        
-        [CCode (cname = "ZSTD_compressCCtx")]
-        public size_t compress(uint8* dst, size_t dstCapacity, uint8* src, size_t srcSize, int compressionLevel);
-        
-        [CCode (cname = "ZSTD_compress2")]
-        public size_t compress2(uint8* dst, size_t dstCapacity, uint8* src, size_t srcSize);
-        
-        [CCode (cname = "ZSTD_CCtx_setParameter")]
-        public size_t set_parameter(CParameter param, int value);
-        
-        [CCode (cname = "ZSTD_CCtx_setPledgedSrcSize")]
-        public size_t set_pledged_src_size(uint64 pledgedSrcSize);
-        
-        [CCode (cname = "ZSTD_CCtx_reset")]
-        public size_t reset(ResetDirective reset);
-        
-        [CCode (cname = "ZSTD_compressStream2")]
-        public size_t compress_stream2(OutBuffer* output, InBuffer* input, EndDirective endOp);
-        
-        [CCode (cname = "ZSTD_initCStream")]
-        public size_t init_stream(int compressionLevel);
-        
-        [CCode (cname = "ZSTD_compressStream")]
-        public size_t compress_stream(OutBuffer* output, InBuffer* input);
-        
-        [CCode (cname = "ZSTD_flushStream")]
-        public size_t flush_stream(OutBuffer* output);
-        
-        [CCode (cname = "ZSTD_endStream")]
-        public size_t end_stream(OutBuffer* output);
-    }
-    
-    /* Decompression context */
-    [CCode (cname = "ZSTD_DCtx", free_function = "ZSTD_freeDCtx", has_type_id = false)]
-    [Compact]
-    public class DCtx {
-        [CCode (cname = "ZSTD_createDCtx")]
-        public DCtx();
-        
-        [CCode (cname = "ZSTD_decompressDCtx")]
-        public size_t decompress(uint8* dst, size_t dstCapacity, uint8* src, size_t srcSize);
-        
-        [CCode (cname = "ZSTD_DCtx_setParameter")]
-        public size_t set_parameter(DParameter param, int value);
-        
-        [CCode (cname = "ZSTD_DCtx_reset")]
-        public size_t reset(ResetDirective reset);
-    }
-    
-    /* Compression stream (alias for CCtx) */
-    [CCode (cname = "ZSTD_CStream", free_function = "ZSTD_freeCStream", has_type_id = false)]
-    [Compact]
-    public class CStream {
-        [CCode (cname = "ZSTD_createCStream")]
-        public CStream();
-    }
-    
-    /* Compression parameters */
-    [CCode (cname = "ZSTD_cParameter", cprefix = "ZSTD_c_", has_type_id = false)]
-    public enum CParameter {
-        compressionLevel = 100,
-        windowLog = 101,
-        hashLog = 102,
-        chainLog = 103,
-        searchLog = 104,
-        minMatch = 105,
-        targetLength = 106,
-        strategy = 107,
-        targetCBlockSize = 130,
-        enableLongDistanceMatching = 160,
-        ldmHashLog = 161,
-        ldmMinMatch = 162,
-        ldmBucketSizeLog = 163,
-        ldmHashRateLog = 164,
-        contentSizeFlag = 200,
-        checksumFlag = 201,
-        dictIDFlag = 202,
-        nbWorkers = 400,
-        jobSize = 401,
-        overlapLog = 402
-    }
-    
-    /* Decompression parameters */
-    [CCode (cname = "ZSTD_dParameter", cprefix = "ZSTD_d_", has_type_id = false)]
-    public enum DParameter {
-        windowLogMax = 100
-    }
-    
-    /* Simple API - One-shot compression */
-    [CCode (cname = "ZSTD_compress")]
-    public size_t compress(uint8* dst, size_t dstCapacity, uint8* src, size_t srcSize, int compressionLevel);
-    
-    /* Simple API - One-shot decompression */
-    [CCode (cname = "ZSTD_decompress")]
-    public size_t decompress(uint8* dst, size_t dstCapacity, uint8* src, size_t compressedSize);
-    
-    /* Helper functions */
-    [CCode (cname = "ZSTD_compressBound")]
-    public size_t compress_bound(size_t srcSize);
-    
-    [CCode (cname = "ZSTD_isError")]
-    public uint is_error(size_t result);
-    
-    [CCode (cname = "ZSTD_getErrorCode")]
-    public ErrorCode get_error_code(size_t functionResult);
-    
-    [CCode (cname = "ZSTD_getErrorName")]
-    public unowned string get_error_name(size_t result);
-    
-    [CCode (cname = "ZSTD_minCLevel")]
-    public int min_c_level();
-    
-    [CCode (cname = "ZSTD_maxCLevel")]
-    public int max_c_level();
-    
-    [CCode (cname = "ZSTD_defaultCLevel")]
-    public int default_c_level();
-    
-    /* Content size functions */
-    [CCode (cname = "ZSTD_getFrameContentSize")]
-    public uint64 get_frame_content_size(uint8* src, size_t srcSize);
-    
-    [CCode (cname = "ZSTD_findFrameCompressedSize")]
-    public size_t find_frame_compressed_size(uint8* src, size_t srcSize);
-    
-    /* Streaming buffer size recommendations */
-    [CCode (cname = "ZSTD_CStreamInSize")]
-    public size_t cstream_in_size();
-    
-    [CCode (cname = "ZSTD_CStreamOutSize")]
-    public size_t cstream_out_size();
-    
-    /* Error string from error code */
-    [CCode (cname = "ZSTD_getErrorString")]
-    public unowned string get_error_string(ErrorCode code);
-    
-    /* Parameter bounds */
-    [CCode (cname = "ZSTD_cParam_getBounds")]
-    public Bounds cparam_get_bounds(CParameter cParam);
-    
-    [CCode (cname = "ZSTD_dParam_getBounds")]
-    public Bounds dparam_get_bounds(DParameter dParam);
-}

+ 85 - 0
website/Endpoints/AuroraActionEndpoint.vala

@@ -0,0 +1,85 @@
+using Astralis;
+using Invercargill;
+using Inversion;
+using Spry;
+
+/**
+ * AuroraActionEndpoint - HTTP endpoint that triggers aurora actions
+ * 
+ * Handles POST requests to trigger aurora actions, which then broadcast
+ * updates via the AuroraSseEndpoint to all connected SSE clients.
+ */
+public class AuroraActionEndpoint : Object, Endpoint {
+    
+    private AuroraSseEndpoint aurora_sse;
+    
+    public AuroraActionEndpoint(AuroraSseEndpoint aurora_sse) {
+        this.aurora_sse = aurora_sse;
+    }
+    
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+        // Get action from route parameter
+        string? action = null;
+        route_context.mapped_parameters.try_get("action", out action);
+        
+        switch (action ?? "") {
+            case "boost":
+                aurora_sse.boost_solar_wind.begin();
+                break;
+            case "calm":
+                aurora_sse.calm.begin();
+                break;
+            case "shift":
+                aurora_sse.shift_colors.begin();
+                break;
+            case "wave":
+                aurora_sse.add_wave.begin();
+                break;
+            default:
+                return new HttpStringResult("{\"error\":\"Unknown action\"}", StatusCode.BAD_REQUEST)
+                    .set_header("Content-Type", "application/json");
+        }
+        
+        return new HttpStringResult("{\"status\":\"ok\"}")
+            .set_header("Content-Type", "application/json");
+    }
+}
+
+/**
+ * CounterActionEndpoint - HTTP endpoint that triggers counter actions
+ * 
+ * Handles POST requests to trigger counter actions, which then broadcast
+ * updates via the CounterSseEndpoint to all connected SSE clients.
+ */
+public class CounterActionEndpoint : Object, Endpoint {
+    
+    private CounterSseEndpoint counter_sse;
+    
+    public CounterActionEndpoint(CounterSseEndpoint counter_sse) {
+        this.counter_sse = counter_sse;
+    }
+    
+    public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+        // Get action from route parameter
+        string? action = null;
+        route_context.mapped_parameters.try_get("action", out action);
+        
+        switch (action ?? "") {
+            case "increment":
+                counter_sse.increment.begin();
+                break;
+            case "decrement":
+                counter_sse.decrement.begin();
+                break;
+            case "reset":
+                counter_sse.reset.begin();
+                break;
+            default:
+                return new HttpStringResult("{\"error\":\"Unknown action\"}", StatusCode.BAD_REQUEST)
+                    .set_header("Content-Type", "application/json");
+        }
+        
+        return new HttpStringResult("{\"status\":\"ok\"}")
+            .set_header("Content-Type", "application/json");
+    }
+}

+ 114 - 0
website/Endpoints/AuroraSseEndpoint.vala

@@ -0,0 +1,114 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Spry;
+
+/**
+ * AuroraSseEndpoint - Singleton SSE endpoint for aurora updates
+ * 
+ * Broadcasts aurora canvas and stats updates to all connected clients
+ * when aurora actions are triggered (BoostSolarWind, CalmAurora, etc.)
+ * 
+ * Uses the Astralis SseEndpoint pattern - NOT continuations, since
+ * continuations are for per-connection streaming, not broadcasting.
+ */
+public class AuroraSseEndpoint : SseEndpoint {
+    
+    private AuroraState aurora_state;
+    
+    public AuroraSseEndpoint(AuroraState aurora_state) {
+        this.aurora_state = aurora_state;
+    }
+    
+    /// Retry interval: clients should wait 2 seconds before reconnecting
+    public override uint retry_interval { get { return 2000; } }
+    
+    /// Called when a new client connects - send current state
+    public override async void new_connection(HttpContext http_context, RouteContext route_context, SseStream stream) {
+        print(@"Aurora SSE client connected (total: $(get_open_streams().length))\n");
+        
+        // Send current aurora state to new client
+        try {
+            yield send_aurora_update(stream);
+        } catch (Error e) {
+            print(@"Failed to send aurora update: $(e.message)\n");
+        }
+        
+        stream.disconnected.connect(() => {
+            print(@"Aurora SSE client disconnected\n");
+        });
+    }
+    
+    /// Boost solar wind and broadcast update
+    public async void boost_solar_wind() {
+        aurora_state.boost_solar_wind();
+        yield broadcast_aurora_update();
+    }
+    
+    /// Calm aurora and broadcast update
+    public async void calm() {
+        aurora_state.calm();
+        yield broadcast_aurora_update();
+    }
+    
+    /// Shift colors and broadcast update
+    public async void shift_colors() {
+        aurora_state.shift_colors();
+        yield broadcast_aurora_update();
+    }
+    
+    /// Add wave and broadcast update
+    public async void add_wave() {
+        aurora_state.add_wave();
+        yield broadcast_aurora_update();
+    }
+    
+    /// Broadcast aurora update to all connected clients
+    private async void broadcast_aurora_update() {
+        try {
+            // Create canvas HTML
+            string canvas_html = build_canvas_html();
+            var canvas_event = new SseEvent.with_type("aurora-canvas", canvas_html);
+            
+            // Create stats HTML
+            string stats_html = build_stats_html();
+            var stats_event = new SseEvent.with_type("aurora-stats", stats_html);
+            
+            // Broadcast both events
+            yield broadcast_event(canvas_event);
+            yield broadcast_event(stats_event);
+        } catch (Error e) {
+            printerr(@"Failed to broadcast aurora update: $(e.message)\n");
+        }
+    }
+    
+    /// Send aurora update to a single stream (for new connections)
+    private async void send_aurora_update(SseStream stream) throws Error {
+        string canvas_html = build_canvas_html();
+        yield stream.send_event(new SseEvent.with_type("aurora-canvas", canvas_html));
+        
+        string stats_html = build_stats_html();
+        yield stream.send_event(new SseEvent.with_type("aurora-stats", stats_html));
+    }
+    
+    /// Build the canvas HTML with all wave components
+    private string build_canvas_html() {
+        var builder = new StringBuilder();
+        int wave_num = 0;
+        foreach (var wave in aurora_state.get_waves()) {
+            // Build the wave div with inline styles
+            builder.append(@"<div class=\"aurora-wave\" style=\"--wave-y: $(wave.y_offset)%%; --wave-amplitude: $(wave.amplitude)px; --wave-freq: $(wave.frequency); --wave-color1: $(wave.color1); --wave-color2: $(wave.color2); --wave-opacity: $(wave.opacity); --wave-delay: $(wave.animation_delay)s; animation-delay: var(--wave-delay);\"></div>");
+            wave_num++;
+        }
+        return builder.str;
+    }
+    
+    /// Build the stats HTML
+    private string build_stats_html() {
+        return @"<div class=\"aurora-stat\"><div class=\"aurora-stat-value\">%.1f km/s</div><div class=\"aurora-stat-label\">Solar Wind</div></div><div class=\"aurora-stat\"><div class=\"aurora-stat-value\">$(aurora_state.wave_count)</div><div class=\"aurora-stat-label\">Waves</div></div><div class=\"aurora-stat\"><div class=\"aurora-stat-value\">%.0f%%</div><div class=\"aurora-stat-label\">Intensity</div></div><div class=\"aurora-stat\"><div class=\"aurora-stat-value\">$(aurora_state.color_mode)</div><div class=\"aurora-stat-label\">Color Mode</div></div>".printf(
+            aurora_state.solar_wind_speed,
+            aurora_state.intensity * 100
+        );
+    }
+}

+ 81 - 0
website/Endpoints/CounterSseEndpoint.vala

@@ -0,0 +1,81 @@
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Spry;
+
+/**
+ * CounterSseEndpoint - Singleton SSE endpoint for counter updates
+ * 
+ * Broadcasts counter value updates to all connected clients
+ * when counter actions are triggered (Increment, Decrement, Reset)
+ * 
+ * Uses the Astralis SseEndpoint pattern - NOT continuations.
+ */
+public class CounterSseEndpoint : SseEndpoint {
+    
+    private AuroraState aurora_state;
+    
+    public CounterSseEndpoint(AuroraState aurora_state) {
+        this.aurora_state = aurora_state;
+    }
+    
+    /// Retry interval: clients should wait 2 seconds before reconnecting
+    public override uint retry_interval { get { return 2000; } }
+    
+    /// Called when a new client connects - send current counter value
+    public override async void new_connection(HttpContext http_context, RouteContext route_context, SseStream stream) {
+        print(@"Counter SSE client connected (total: $(get_open_streams().length))\n");
+        
+        // Send current counter value to new client
+        try {
+            yield send_counter_update(stream);
+        } catch (Error e) {
+            print(@"Failed to send counter update: $(e.message)\n");
+        }
+        
+        stream.disconnected.connect(() => {
+            print(@"Counter SSE client disconnected\n");
+        });
+    }
+    
+    /// Increment counter and broadcast update
+    public async void increment() {
+        aurora_state.counter++;
+        yield broadcast_counter_update();
+    }
+    
+    /// Decrement counter and broadcast update
+    public async void decrement() {
+        aurora_state.counter--;
+        yield broadcast_counter_update();
+    }
+    
+    /// Reset counter and broadcast update
+    public async void reset() {
+        aurora_state.counter = 0;
+        yield broadcast_counter_update();
+    }
+    
+    /// Broadcast counter update to all connected clients
+    private async void broadcast_counter_update() {
+        try {
+            string counter_html = build_counter_html();
+            var counter_event = new SseEvent.with_type("counter-value", counter_html);
+            yield broadcast_event(counter_event);
+        } catch (Error e) {
+            printerr(@"Failed to broadcast counter update: $(e.message)\n");
+        }
+    }
+    
+    /// Send counter update to a single stream (for new connections)
+    private async void send_counter_update(SseStream stream) throws Error {
+        string counter_html = build_counter_html();
+        yield stream.send_event(new SseEvent.with_type("counter-value", counter_html));
+    }
+    
+    /// Build the counter value HTML
+    private string build_counter_html() {
+        return aurora_state.counter.to_string();
+    }
+}

+ 35 - 7
website/Main.vala

@@ -1,5 +1,6 @@
 using Astralis;
 using Invercargill;
+using Invercargill.DataStructures;
 using Inversion;
 using Spry;
 
@@ -13,7 +14,7 @@ using Spry;
  * 
  * Features:
  * - Clean, eye-catching design with purple/blue/green theme
- * - Interactive particle demo showcasing real-time HTMX updates
+ * - Interactive demos with real-time SSE updates
  * - Responsive layout with modern CSS
  * - Free/Libre open source spirit
  */
@@ -30,9 +31,13 @@ void main(string[] args) {
     print("    /              - Home\n");
     print("    /features      - Features\n");
     print("    /ecosystem     - Ecosystem (Astralis, Inversion, Spry)\n");
-    print("    /demo          - Interactive Demo\n");
+    print("    /demo          - Interactive Demo (SSE)\n");
     print("    /freedom       - Free/Libre Philosophy\n");
     print("═══════════════════════════════════════════════════════════════\n");
+    print("  SSE Endpoints:\n");
+    print("    /sse/aurora    - Aurora updates\n");
+    print("    /sse/counter   - Counter updates\n");
+    print("═══════════════════════════════════════════════════════════════\n");
     print("\nPress Ctrl+C to stop the server\n\n");
     
     try {
@@ -44,12 +49,13 @@ void main(string[] args) {
         // Add Spry module for component actions
         application.add_module<SpryModule>();
         
-        // Register stores as singletons
-        application.add_singleton<AuroraState>();
+        // Create shared state instance first, then register it as singleton
+        var aurora_state = new AuroraState();
+        application.container.register_singleton<AuroraState>(() => aurora_state);
         
         // Configure templates
         var spry_cfg = application.configure_with<SpryConfigurator>();
-        spry_cfg.add_template<SiteLayoutTemplate>("");
+        spry_cfg.add_template<MainTemplate>("");
         
         // Register page components
         application.add_transient<HomePage>();
@@ -69,12 +75,34 @@ void main(string[] args) {
         
         // Register interactive components (for spry-action)
         application.add_transient<AuroraWaveComponent>();
-        application.add_transient<AuroraCanvasEndpoint>();
-        application.add_transient<AuroraStatsEndpoint>();
         application.add_transient<FeatureCardComponent>();
         application.add_transient<CodeBlockComponent>();
         application.add_transient<StatCardComponent>();
         
+        // Create SSE endpoints (singletons to share state across connections)
+        var aurora_sse = new AuroraSseEndpoint(aurora_state);
+        var counter_sse = new CounterSseEndpoint(aurora_state);
+        
+        // Register SSE endpoints as singletons
+        application.add_singleton_endpoint<AuroraSseEndpoint>(
+            new EndpointRoute("/sse/aurora"),
+            () => aurora_sse
+        );
+        application.add_singleton_endpoint<CounterSseEndpoint>(
+            new EndpointRoute("/sse/counter"),
+            () => counter_sse
+        );
+        
+        // Register action endpoints (HTTP POST to trigger SSE broadcasts)
+        application.add_endpoint<AuroraActionEndpoint>(
+            new EndpointRoute("/aurora/action/{action}"),
+            () => new AuroraActionEndpoint(aurora_sse)
+        );
+        application.add_endpoint<CounterActionEndpoint>(
+            new EndpointRoute("/counter/action/{action}"),
+            () => new CounterActionEndpoint(counter_sse)
+        );
+        
         // Register CSS as FastResources
         application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/main.css"), () => {
             try {

+ 43 - 73
website/Pages/DemoPage.vala

@@ -7,8 +7,10 @@ using Spry;
 /**
  * DemoPage - Interactive demo page
  * 
- * Features a live Aurora Borealis simulation that polls the server
- * every 5 seconds for new wave/color parameters
+ * Features a live Aurora Borealis simulation and counter that use
+ * Server-Sent Events (SSE) for real-time updates.
+ * 
+ * Updates are pushed when users click action buttons - no polling!
  */
 public class DemoPage : PageComponent {
     
@@ -19,12 +21,12 @@ public class DemoPage : PageComponent {
     
     public override string markup { get {
         return """
-        <div sid="demo-page" class="section">
+        <div sid="demo-page" class="section" hx-ext="sse" sse-connect="/sse/aurora" sse-close="close">
             <div class="container">
                 <!-- Header -->
                 <div class="section-header">
                     <h1>Interactive Demo</h1>
-                    <p>Experience the power of Spry with a live, server-backed Aurora simulation</p>
+                    <p>Experience the power of Spry with real-time Server-Sent Events</p>
                 </div>
 
                 <!-- Aurora Demo -->
@@ -32,37 +34,31 @@ public class DemoPage : PageComponent {
                     <div class="demo-header">
                         <h2>🌌 Live Aurora Borealis</h2>
                         <p style="color: var(--color-text-muted); margin-bottom: var(--space-4);">
-                            This aurora is generated server-side! It polls for new parameters every 5 seconds.
+                            This aurora uses SSE for real-time updates! Click a button to update all connected clients.
                         </p>
                         <div class="demo-controls">
-                            <button class="demo-btn" spry-action=":BoostSolarWind" spry-target="demo-page" hx-swap="outerHTML">
+                            <button class="demo-btn" hx-post="/aurora/action/boost" hx-swap="none">
                                 🌞 Boost Solar Wind
                             </button>
-                            <button class="demo-btn" spry-action=":CalmAurora" spry-target="demo-page" hx-swap="outerHTML">
+                            <button class="demo-btn" hx-post="/aurora/action/calm" hx-swap="none">
                                 🌙 Calm Aurora
                             </button>
-                            <button class="demo-btn" spry-action=":ShiftColors" spry-target="demo-page" hx-swap="outerHTML">
+                            <button class="demo-btn" hx-post="/aurora/action/shift" hx-swap="none">
                                 🎨 Shift Colors
                             </button>
-                            <button class="demo-btn" spry-action=":AddWave" spry-target="demo-page" hx-swap="outerHTML">
+                            <button class="demo-btn" hx-post="/aurora/action/wave" hx-swap="none">
                                 🌊 Add Wave
                             </button>
                         </div>
                     </div>
                     
-                    <!-- Aurora Canvas with auto-refresh -->
-                    <div class="aurora-canvas" sid="aurora-canvas" 
-                         spry-action="AuroraCanvasEndpoint:Poll" 
-                         hx-trigger="every 5s" 
-                         hx-swap="innerHTML transition:true">
+                    <!-- Aurora Canvas - updated via SSE -->
+                    <div class="aurora-canvas" sse-swap="aurora-canvas" hx-swap="innerHTML transition:true">
                         <spry-outlet sid="aurora-waves"/>
                     </div>
                     
-                    <!-- Aurora Stats -->
-                    <div class="aurora-stats" sid="aurora-stats"
-                         spry-action="AuroraStatsEndpoint:Poll" 
-                         hx-trigger="every 5s" 
-                         hx-swap="outerHTML transition:true">
+                    <!-- Aurora Stats - updated via SSE -->
+                    <div class="aurora-stats" sse-swap="aurora-stats" hx-swap="innerHTML transition:true">
                         <div class="aurora-stat">
                             <div class="aurora-stat-value" sid="solar-wind"></div>
                             <div class="aurora-stat-label">Solar Wind</div>
@@ -82,30 +78,30 @@ public class DemoPage : PageComponent {
                     </div>
                     
                     <p style="margin-top: var(--space-4); text-align: center; color: var(--color-text-muted);">
-                        The aurora updates every 5 seconds via HTMX polling. Try the controls above!
+                        Updates are pushed via SSE when anyone clicks the buttons. Try it from multiple browsers!
                     </p>
                 </section>
 
                 <!-- Counter Demo -->
-                <section class="demo-section">
-                    <h2>🔢 Simple Counter</h2>
-                    <p>A minimal example of Spry component state and actions.</p>
+                <section class="demo-section" hx-ext="sse" sse-connect="/sse/counter" sse-close="close">
+                    <h2>🔢 Shared Counter</h2>
+                    <p>A counter that syncs across all connected clients using SSE.</p>
                     
                     <div style="display: flex; align-items: center; gap: var(--space-4); justify-content: center; margin: var(--space-6) 0;">
                         <div class="stat-card" style="text-align: center; min-width: 200px;">
-                            <div class="stat-value" sid="counter-value"></div>
+                            <div class="stat-value" sse-swap="counter-value" hx-swap="innerHTML"></div>
                             <div class="stat-label">Current Count</div>
                         </div>
                     </div>
                     
                     <div style="display: flex; gap: var(--space-3); justify-content: center;">
-                        <button class="demo-btn" spry-action=":Decrement" spry-target="demo-page" hx-swap="outerHTML">
+                        <button class="demo-btn" hx-post="/counter/action/decrement" hx-swap="none">
                             − Decrement
                         </button>
-                        <button class="demo-btn" spry-action=":Reset" spry-target="demo-page" hx-swap="outerHTML">
+                        <button class="demo-btn" hx-post="/counter/action/reset" hx-swap="none">
                             ↺ Reset
                         </button>
-                        <button class="demo-btn" spry-action=":Increment" spry-target="demo-page" hx-swap="outerHTML">
+                        <button class="demo-btn" hx-post="/counter/action/increment" hx-swap="none">
                             + Increment
                         </button>
                     </div>
@@ -114,23 +110,23 @@ public class DemoPage : PageComponent {
                 <!-- How It Works -->
                 <section class="demo-section">
                     <h2>⚙ How It Works</h2>
-                    <p>This demo showcases several Spry features working together:</p>
+                    <p>This demo showcases Server-Sent Events (SSE) for real-time updates:</p>
                     
                     <div class="features-grid" style="margin-top: var(--space-6);">
                         <div class="feature-card">
-                            <div class="feature-icon purple">🔄</div>
-                            <h3>Server Polling</h3>
-                            <p>hx-trigger="every 5s" fetches fresh aurora data from the server periodically.</p>
+                            <div class="feature-icon purple">📡</div>
+                            <h3>Server-Sent Events</h3>
+                            <p>SSE pushes updates to all connected clients instantly when actions occur.</p>
                         </div>
                         <div class="feature-card">
-                            <div class="feature-icon blue"></div>
+                            <div class="feature-icon blue">🔗</div>
                             <h3>Shared State</h3>
-                            <p>AuroraState is a singleton - all visitors see the same aurora!</p>
+                            <p>AuroraState is a singleton - all visitors see the same aurora and counter!</p>
                         </div>
                         <div class="feature-card">
-                            <div class="feature-icon green">🎨</div>
-                            <h3>CSS Gradients</h3>
-                            <p>Server generates CSS gradient strips for beautiful aurora waves.</p>
+                            <div class="feature-icon green"></div>
+                            <h3>Event-Driven</h3>
+                            <p>No polling! Updates only happen when someone clicks a button.</p>
                         </div>
                     </div>
                     
@@ -143,17 +139,16 @@ public class DemoPage : PageComponent {
                             </div>
                             <span class="code-lang">HTML</span>
                         </div>
-                        <pre><code>&lt;!-- Auto-refreshing aurora canvas --&gt;
-&lt;div hx-get="/demo/aurora" 
-     hx-trigger="every 5s" 
-     hx-swap="innerHTML"&gt;
-    &lt;!-- Aurora waves render here --&gt;
+                        <pre><code>&lt;!-- SSE connection on parent --&gt;
+&lt;div hx-ext="sse" sse-connect="/sse/aurora"&gt;
+    &lt;!-- Auto-updates when "aurora-canvas" event received --&gt;
+    &lt;div sse-swap="aurora-canvas"&gt;
+        Aurora waves render here
+    &lt;/div&gt;
 &lt;/div&gt;
 
-&lt;!-- Control buttons --&gt;
-&lt;button spry-action=":BoostSolarWind"
-        spry-target="demo-page"
-        hx-swap="outerHTML"&gt;
+&lt;!-- Button triggers action endpoint --&gt;
+&lt;button hx-post="/aurora/action/boost" hx-swap="none"&gt;
     Boost Solar Wind
 &lt;/button&gt;</code></pre>
                     </div>
@@ -174,7 +169,7 @@ public class DemoPage : PageComponent {
     }}
     
     public override async void prepare() throws Error {
-        // Render aurora waves
+        // Render initial aurora waves
         var waves = new Series<Renderable>();
         int wave_num = 0;
         foreach (var wave in aurora_state.get_waves()) {
@@ -198,35 +193,10 @@ public class DemoPage : PageComponent {
         this["intensity"].text_content = "%.0f%%".printf(aurora_state.intensity * 100);
         this["color-mode"].text_content = aurora_state.color_mode;
         
-        // Update counter
+        // Update counter (initial value - will be updated via SSE)
+        // Note: We need to set the stat-value div's content
         this["counter-value"].text_content = aurora_state.counter.to_string();
     }
-    
-    public async override void handle_action(string action) throws Error {
-        switch (action) {
-            case "BoostSolarWind":
-                aurora_state.boost_solar_wind();
-                break;
-            case "CalmAurora":
-                aurora_state.calm();
-                break;
-            case "ShiftColors":
-                aurora_state.shift_colors();
-                break;
-            case "AddWave":
-                aurora_state.add_wave();
-                break;
-            case "Increment":
-                aurora_state.counter++;
-                break;
-            case "Decrement":
-                aurora_state.counter--;
-                break;
-            case "Reset":
-                aurora_state.counter = 0;
-                break;
-        }
-    }
 }
 
 /**

+ 2 - 1
website/Templates/SiteLayoutTemplate.vala

@@ -10,7 +10,7 @@ using Spry;
  * - Site-wide header with navigation
  * - Site-wide footer
  */
-public class SiteLayoutTemplate : PageTemplate {
+public class MainTemplate : PageTemplate {
     
     public override string markup { get {
         return """
@@ -23,6 +23,7 @@ public class SiteLayoutTemplate : PageTemplate {
             <meta name="description" content="Spry is a component-based web framework for Vala, featuring HTMX integration, dependency injection, and reactive templates.">
             <link rel="stylesheet" href="/styles/main.css">
             <script spry-res="htmx.js"></script>
+            <script spry-res="htmx-sse.js"></script>
         </head>
         <body>
             <header class="site-header">

+ 3 - 2
website/meson.build

@@ -14,8 +14,9 @@ website_sources = files(
     'Components/AuroraWaveComponent.vala',
     'Components/CodeBlockComponent.vala',
     'Components/StatCardComponent.vala',
-    'Endpoints/AuroraCanvasEndpoint.vala',
-    'Endpoints/AuroraStatsEndpoint.vala',
+    'Endpoints/AuroraSseEndpoint.vala',
+    'Endpoints/CounterSseEndpoint.vala',
+    'Endpoints/AuroraActionEndpoint.vala',
 )
 
 # Math library for particle physics (sin/cos in explode function)