Skip to main content

what_core/server/
mod.rs

1//! HTTP Server for What framework
2//!
3//! File-based routing with automatic page rendering and form handling.
4
5use axum::{
6    Router,
7    body::Body,
8    extract::{
9        Form, Multipart, Path, Query, State,
10        ws::{Message, WebSocket, WebSocketUpgrade},
11    },
12    http::{HeaderMap, HeaderValue, Request, StatusCode, header},
13    middleware::Next,
14    response::{Html, IntoResponse, Redirect, Response},
15    routing::{get, post},
16};
17use futures_util::{SinkExt, StreamExt};
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20use std::collections::{HashMap, HashSet, VecDeque};
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24use tokio::sync::broadcast;
25use tower::ServiceBuilder;
26use tower_http::services::ServeDir;
27use tower_http::set_header::SetResponseHeaderLayer;
28
29use regex::Regex;
30use std::sync::LazyLock;
31
32use crate::auth::{AuthHandler, UserContext};
33use crate::cache::{CacheKey, CachedValue, WhatCache};
34use crate::components::ComponentRegistry;
35use crate::config::DataSource;
36use crate::database::DatabaseAdapter;
37use crate::parser::{
38    PageDirectives, SessionMutation, WhatConfig, WiredScope, parse_page_directives, parse_what_file,
39};
40use crate::sessions::{self, AtomicMutation, KvSessionStore, SessionBackend, SqliteSessionStore};
41use crate::validation;
42use crate::{Config, Result};
43
44// ---------------------------------------------------------------------------
45// Flash Messages
46// ---------------------------------------------------------------------------
47
48/// Reserved session key for flash data (consumed on next page load)
49const FLASH_SESSION_KEY: &str = "_flash";
50
51/// Flash data stored in session — consumed on read
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53struct FlashData {
54    /// Flash messages: flash.success, flash.error, flash.info
55    #[serde(default)]
56    flash: HashMap<String, String>,
57    /// Validation errors: errors.field_name
58    #[serde(default)]
59    errors: HashMap<String, String>,
60    /// Previous form values: old.field_name
61    #[serde(default)]
62    old: HashMap<String, String>,
63}
64
65/// Store flash data into session
66fn set_flash_data(session: &mut sessions::Session, flash: &FlashData) {
67    if let Ok(json) = serde_json::to_value(flash) {
68        session.data.insert(FLASH_SESSION_KEY.to_string(), json);
69    }
70}
71
72/// Consume flash data from session (read and remove)
73fn consume_flash_data(session: &mut sessions::Session) -> Option<FlashData> {
74    let value = session.data.remove(FLASH_SESSION_KEY)?;
75    serde_json::from_value(value).ok()
76}
77
78/// Inject flash data into the template rendering context
79fn inject_flash_into_context(flash: &FlashData, context: &mut HashMap<String, Value>) {
80    // flash.success, flash.error, flash.info etc.
81    if !flash.flash.is_empty() {
82        let flash_obj: serde_json::Map<String, Value> = flash
83            .flash
84            .iter()
85            .map(|(k, v)| (k.clone(), json!(v)))
86            .collect();
87        context.insert("flash".to_string(), Value::Object(flash_obj));
88    }
89
90    // errors.field_name
91    if !flash.errors.is_empty() {
92        let errors_obj: serde_json::Map<String, Value> = flash
93            .errors
94            .iter()
95            .map(|(k, v)| (k.clone(), json!(v)))
96            .collect();
97        context.insert("errors".to_string(), Value::Object(errors_obj));
98        context.insert("has_errors".to_string(), json!(true));
99    } else {
100        context.insert("has_errors".to_string(), json!(false));
101    }
102
103    // old.field_name
104    if !flash.old.is_empty() {
105        let old_obj: serde_json::Map<String, Value> = flash
106            .old
107            .iter()
108            .map(|(k, v)| (k.clone(), json!(v)))
109            .collect();
110        context.insert("old".to_string(), Value::Object(old_obj));
111    }
112}
113
114mod actions;
115mod engine;
116
117pub use actions::ActionHandler;
118pub use engine::RenderEngine;
119
120// ---------------------------------------------------------------------------
121// Content Root Abstraction
122// ---------------------------------------------------------------------------
123
124/// Determine the content directory name for a project root.
125///
126/// Prefers `site/` if it exists, falls back to `pages/` for backward compatibility.
127/// If neither exists, returns `"site"` (the new default for new projects).
128///
129/// Emits a one-time deprecation warning via `tracing::warn!` when `pages/` is used.
130pub fn content_dir_name(root: &std::path::Path) -> &'static str {
131    use std::sync::Once;
132    static DEPRECATION_WARNED: Once = Once::new();
133    static BOTH_WARNED: Once = Once::new();
134
135    let has_site = root.join("site").is_dir();
136    let has_pages = root.join("pages").is_dir();
137
138    match (has_site, has_pages) {
139        (true, true) => {
140            BOTH_WARNED.call_once(|| {
141                tracing::warn!(
142                    "Both site/ and pages/ directories found. Using site/. \
143                     Remove pages/ to silence this warning."
144                );
145            });
146            "site"
147        }
148        (true, false) => "site",
149        (false, true) => {
150            DEPRECATION_WARNED.call_once(|| {
151                tracing::warn!("pages/ is deprecated — rename to site/: mv pages site");
152            });
153            "pages"
154        }
155        (false, false) => "site",
156    }
157}
158
159/// Resolve the full content directory path for a project root.
160///
161/// Returns `root.join(content_dir_name(root))`.
162pub fn content_dir(root: &std::path::Path) -> PathBuf {
163    root.join(content_dir_name(root))
164}
165
166/// Live reload message type
167#[derive(Clone, Debug)]
168pub enum LiveReloadMessage {
169    /// File changed, trigger reload
170    Reload,
171    /// Cache cleared
172    CacheCleared,
173}
174
175/// A wired message with its scope for filtering
176#[derive(Clone, Debug)]
177pub struct WiredMessage {
178    pub json: String,
179    pub scope: WiredScope,
180}
181
182/// Application state shared across handlers
183#[derive(Clone)]
184pub struct AppState {
185    /// Configuration
186    pub config: Arc<Config>,
187    /// Data store (SQLite, D1, or Supabase — configured via [database] in what.toml)
188    pub store: DatabaseAdapter,
189    /// Cache
190    pub cache: WhatCache,
191    /// Component registry
192    pub components: Arc<ComponentRegistry>,
193    /// Render engine
194    pub engine: Arc<RenderEngine>,
195    /// Project root directory
196    pub root: PathBuf,
197    /// Resolved content directory path (root + "site" or "pages")
198    pub content_dir: PathBuf,
199    /// Session store (pluggable: SQLite or Cloudflare KV)
200    pub sessions: Option<SessionBackend>,
201    /// Authentication handler
202    pub auth: AuthHandler,
203    /// Development mode flag
204    pub dev_mode: bool,
205    /// Framework stylesheet mode ([server] css in what.toml), validated at startup
206    pub css_mode: CssMode,
207    /// Live reload broadcast sender (only active in dev mode)
208    pub live_reload_tx: Option<broadcast::Sender<LiveReloadMessage>>,
209    /// Wired state broadcast sender (real-time push with scope filtering)
210    pub wired_tx: broadcast::Sender<WiredMessage>,
211    /// Scope registry for wired variables (rebuilt on application.what reload)
212    pub wired_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
213    /// Scope registry for application variables — write-gates `app.*` mutations
214    /// declared with `[role]` brackets (rebuilt on application.what reload)
215    pub app_scopes: Arc<tokio::sync::RwLock<HashMap<String, WiredScope>>>,
216    /// Compiled collection authorization policies (from `[collections.*]`)
217    pub policies: Arc<crate::policy::PolicyRegistry>,
218    /// Last refresh timestamps for configured data sources
219    pub data_source_loaded: Arc<tokio::sync::RwLock<HashMap<String, Instant>>>,
220    /// Rate limiters for login, upload, and action endpoints
221    pub rate_limiters: Option<RateLimiters>,
222    /// Log level string for debug meta injection (dev mode only)
223    pub log_level: String,
224    /// Background job queue for async operations (session cleanup, email, etc.)
225    pub jobs: crate::jobs::JobQueue,
226    /// Shared HTTP client with configurable timeout for external fetch requests
227    pub http_client: reqwest::Client,
228    /// Upload storage backend (local filesystem or Cloudflare R2)
229    pub upload_backend: Option<crate::uploads::UploadBackend>,
230    /// Named datasources — multiple backends accessible via `dsn:name` in fetch directives
231    pub datasources: HashMap<String, crate::datasource::Datasource>,
232    /// Number of currently connected wired WebSocket clients
233    pub wired_client_count: Arc<std::sync::atomic::AtomicUsize>,
234    /// Registry of form action URLs that require validation (populated at render time).
235    /// When a form with `w-validate` is rendered, its action is recorded here.
236    /// On submission, if the action is registered but `w-rules` is missing, the request is rejected.
237    pub validated_actions: Arc<std::sync::RwLock<HashSet<String>>>,
238    /// Ring buffer of recent activity (requests, policy denials, fetches) for the
239    /// dev inspector's Activity panel. Only written to in dev mode.
240    pub activity_log: Arc<std::sync::Mutex<VecDeque<ActivityEvent>>>,
241}
242
243/// Maximum number of events retained in the inspector activity ring buffer.
244const ACTIVITY_LOG_CAPACITY: usize = 200;
245
246/// One entry in the dev inspector's activity feed.
247#[derive(Clone)]
248pub enum ActivityEvent {
249    Request {
250        time: chrono::DateTime<chrono::Local>,
251        method: String,
252        path: String,
253        status: u16,
254        duration_ms: u64,
255    },
256    PolicyDenial {
257        time: chrono::DateTime<chrono::Local>,
258        detail: String,
259    },
260    Fetch {
261        time: chrono::DateTime<chrono::Local>,
262        key: String,
263        url: String,
264        elapsed_ms: u64,
265        result: String,
266    },
267}
268
269impl AppState {
270    /// Record an event in the inspector activity feed. No-op outside dev mode,
271    /// so production requests never touch the lock.
272    pub fn record_activity(&self, event: ActivityEvent) {
273        if !self.dev_mode {
274            return;
275        }
276        let mut log = self.activity_log.lock().unwrap();
277        if log.len() >= ACTIVITY_LOG_CAPACITY {
278            log.pop_front();
279        }
280        log.push_back(event);
281    }
282}
283
284/// Per-IP rate limiters using moka caches with TTL-based sliding windows.
285/// Each cache maps IP address string to request count.
286#[derive(Clone)]
287pub struct RateLimiters {
288    /// Login endpoint: /w-auth/login
289    pub login: moka::future::Cache<String, u32>,
290    pub login_max: u32,
291    /// Upload endpoint: /w-upload/*
292    pub upload: moka::future::Cache<String, u32>,
293    pub upload_max: u32,
294    /// Action endpoint: /w-action/*
295    pub action: moka::future::Cache<String, u32>,
296    pub action_max: u32,
297}
298
299impl AppState {
300    pub fn new(config: Config, root: PathBuf) -> Result<Self> {
301        Self::with_dev_mode(config, root, false)
302    }
303
304    /// Create AppState with development mode enabled (includes live reload)
305    pub fn with_dev_mode(mut config: Config, root: PathBuf, dev_mode: bool) -> Result<Self> {
306        // Validate [server] css before anything else so typos fail loud at startup
307        let css_mode = CssMode::from_config(&config.server.css)?;
308
309        // Compile collection authorization policies — fail loud on reserved-word
310        // misuse or invalid combinations so config errors surface at startup.
311        let policies = Arc::new(crate::policy::PolicyRegistry::from_config(&config.collections)?);
312        // Warn if identity-based rules exist but no identity source does.
313        if !config.session.enabled && !config.auth.enabled {
314            for (name, policy) in policies.configured() {
315                if policy.is_read_scoped() || policy.owner_mode == crate::policy::OwnerMode::Auto {
316                    tracing::warn!(
317                        target: "what::policy",
318                        "collection '{}' has an identity-based policy but both sessions and auth are disabled — ownership/read scoping will deny everything",
319                        name
320                    );
321                    break;
322                }
323            }
324        }
325
326        // In dev mode, auto-disable Secure flag on cookies (localhost has no TLS)
327        if dev_mode && config.session.secure {
328            config.session.secure = false;
329            tracing::info!("Dev mode: disabled Secure flag on cookies (no TLS on localhost)");
330        }
331
332        // Load .env file if present (for #env.VAR# in fetch directives)
333        let env_path = root.join(".env");
334        if env_path.exists() {
335            let _ = dotenvy::from_path(&env_path);
336            tracing::info!("Loaded .env from {}", env_path.display());
337        }
338
339        let mut components = ComponentRegistry::new();
340        components.register_builtins();
341
342        // Load components from project (prefixed with what-)
343        let components_dir = root.join("components");
344        if components_dir.exists() {
345            components.load_from_directory(&components_dir)?;
346        }
347
348        // Initialize data store based on [database.type] config
349        let store = if let Some(ref db_config) = config.database {
350            match db_config.r#type.as_str() {
351                "d1" => {
352                    // Cloudflare D1: requires [cloudflare] config
353                    let cf = config.cloudflare.as_ref()
354                        .ok_or_else(|| crate::Error::Config("[database] type = \"d1\" requires [cloudflare] section with account_id and api_token".to_string()))?;
355                    let db_id = cf.d1_database_id.as_ref().ok_or_else(|| {
356                        crate::Error::Config(
357                            "[cloudflare] d1_database_id is required for type = \"d1\"".to_string(),
358                        )
359                    })?;
360                    let account_id = resolve_env_value(&cf.account_id)?;
361                    let api_token = resolve_env_value(&cf.api_token)?;
362                    let database_id = resolve_env_value(db_id)?;
363                    let db =
364                        crate::database::D1Database::new(&account_id, &database_id, &api_token);
365                    tracing::info!("Database: Cloudflare D1 ({})", database_id);
366                    DatabaseAdapter::D1(db)
367                }
368                "supabase" => {
369                    // Supabase: requires [supabase] config
370                    let sb = config.supabase.as_ref()
371                        .ok_or_else(|| crate::Error::Config("[database] type = \"supabase\" requires [supabase] section with project_url and api_key".to_string()))?;
372                    let project_url = resolve_env_value(&sb.project_url)?;
373                    let api_key = resolve_env_value(&sb.api_key)?;
374                    let db = crate::database::SupabaseDatabase::new(&project_url, &api_key);
375                    tracing::info!("Database: Supabase ({})", project_url);
376                    DatabaseAdapter::Supabase(db)
377                }
378                "sqlite" | _ => {
379                    let db_path = root.join(&db_config.path);
380                    if let Some(parent) = db_path.parent() {
381                        std::fs::create_dir_all(parent).ok();
382                    }
383                    let db = crate::database::SqliteDatabase::open(&db_path)?;
384                    if db_config.r#type != "sqlite"
385                        && db_config.r#type != "d1"
386                        && db_config.r#type != "supabase"
387                    {
388                        tracing::warn!(
389                            "Unknown database type '{}', falling back to sqlite",
390                            db_config.r#type
391                        );
392                    }
393                    tracing::info!("Database: SQLite ({})", db_path.display());
394                    DatabaseAdapter::Sqlite(db)
395                }
396            }
397        } else {
398            // No [database] config — auto-create SQLite at data/app.db
399            let db_path = root.join("data").join("app.db");
400            if let Some(parent) = db_path.parent() {
401                std::fs::create_dir_all(parent).ok();
402            }
403            let db = crate::database::SqliteDatabase::open(&db_path)?;
404
405            // Auto-import legacy data/store.json if it exists (one-time migration)
406            let store_json_path = root.join("data").join("store.json");
407            if store_json_path.exists() {
408                if let Ok(content) = std::fs::read_to_string(&store_json_path) {
409                    if let Ok(store_data) = serde_json::from_str::<serde_json::Value>(&content) {
410                        if let Some(collections) =
411                            store_data.get("collections").and_then(|c| c.as_object())
412                        {
413                            for (name, items) in collections {
414                                if let Some(items_arr) = items.as_array() {
415                                    db.import_json_collection(name, items_arr);
416                                }
417                            }
418                        }
419                    }
420                }
421            }
422
423            tracing::info!("Database: SQLite auto-default ({})", db_path.display());
424            DatabaseAdapter::Sqlite(db)
425        };
426
427        // Initialize session store if enabled
428        let sessions: Option<SessionBackend> = if config.session.enabled {
429            match config.session.store.as_str() {
430                "cloudflare-kv" => {
431                    if let Some(ref cf_config) = config.session.cloudflare {
432                        // Resolve env vars in cloudflare config
433                        let resolved = crate::config::CloudflareKvConfig {
434                            account_id: resolve_env_value(&cf_config.account_id)?,
435                            namespace_id: resolve_env_value(&cf_config.namespace_id)?,
436                            api_token: resolve_env_value(&cf_config.api_token)?,
437                        };
438                        let store = KvSessionStore::new(&resolved, config.session.max_age);
439                        tracing::info!(
440                            "Session store initialized: Cloudflare KV (namespace: {})",
441                            resolved.namespace_id
442                        );
443                        Some(SessionBackend::CloudflareKv(store))
444                    } else {
445                        tracing::warn!(
446                            "Session store set to cloudflare-kv but [session.cloudflare] config is missing"
447                        );
448                        None
449                    }
450                }
451                _ => {
452                    // Default: SQLite
453                    let db_path = root.join(&config.session.database);
454                    match SqliteSessionStore::new(&db_path, config.session.max_age) {
455                        Ok(store) => {
456                            tracing::info!(
457                                "Session store initialized: SQLite ({})",
458                                db_path.display()
459                            );
460                            Some(SessionBackend::Sqlite(store))
461                        }
462                        Err(e) => {
463                            tracing::warn!("Failed to initialize session store: {}", e);
464                            None
465                        }
466                    }
467                }
468            }
469        } else {
470            None
471        };
472
473        // Initialize upload backend
474        let upload_backend = if config.uploads.enabled {
475            match config.uploads.provider.as_str() {
476                "r2" => {
477                    let cf = config.cloudflare.as_ref().ok_or_else(|| {
478                        crate::Error::Config(
479                            "[uploads] provider = \"r2\" requires [cloudflare] section".to_string(),
480                        )
481                    })?;
482                    let bucket = cf.r2_bucket.as_ref().ok_or_else(|| {
483                        crate::Error::Config(
484                            "[cloudflare] r2_bucket is required for provider = \"r2\"".to_string(),
485                        )
486                    })?;
487                    let public_url = cf.r2_public_url.as_ref().ok_or_else(|| {
488                        crate::Error::Config(
489                            "[cloudflare] r2_public_url is required for provider = \"r2\""
490                                .to_string(),
491                        )
492                    })?;
493                    tracing::info!("Uploads: Cloudflare R2 (bucket: {})", bucket);
494                    Some(crate::uploads::UploadBackend::R2 {
495                        client: crate::http_client::build_http_client(None).map_err(|e| {
496                            crate::Error::Upload(format!("Failed to build R2 HTTP client: {}", e))
497                        })?,
498                        account_id: resolve_env_value(&cf.account_id)?,
499                        bucket: resolve_env_value(bucket)?,
500                        api_token: resolve_env_value(&cf.api_token)?,
501                        public_url: resolve_env_value(public_url)?,
502                    })
503                }
504                _ => {
505                    let uploads_dir = root.join(&config.uploads.directory);
506                    if !uploads_dir.exists() {
507                        std::fs::create_dir_all(&uploads_dir).map_err(|e| {
508                            crate::Error::Upload(format!(
509                                "Failed to create uploads directory: {}",
510                                e
511                            ))
512                        })?;
513                        tracing::info!("Created uploads directory: {}", uploads_dir.display());
514                    }
515                    Some(crate::uploads::UploadBackend::Local {
516                        directory: uploads_dir,
517                    })
518                }
519            }
520        } else {
521            None
522        };
523
524        let engine = RenderEngine::new(components.clone());
525
526        // Initialize auth handler with env var overrides
527        let auth = AuthHandler::from_config_with_env(config.auth.clone());
528        if auth.is_enabled() {
529            tracing::info!("Authentication enabled");
530            if let Some(endpoint) = auth.login_endpoint() {
531                tracing::info!("Login endpoint: {}", endpoint);
532            }
533        }
534
535        // Create live reload channel if in dev mode
536        let live_reload_tx = if dev_mode {
537            let (tx, _) = broadcast::channel(16);
538            Some(tx)
539        } else {
540            None
541        };
542
543        // Create wired state broadcast channel (always active, with scope filtering)
544        let (wired_tx, _) = broadcast::channel::<WiredMessage>(256);
545
546        // Initialize rate limiters if enabled
547        let rate_limiters = if config.rate_limit.enabled {
548            use crate::config::RateLimitConfig;
549            let (login_max, login_window) = RateLimitConfig::parse_limit(&config.rate_limit.login);
550            let (upload_max, upload_window) =
551                RateLimitConfig::parse_limit(&config.rate_limit.upload);
552            let (action_max, action_window) =
553                RateLimitConfig::parse_limit(&config.rate_limit.action);
554            Some(RateLimiters {
555                login: moka::future::Cache::builder()
556                    .time_to_live(Duration::from_secs(login_window))
557                    .build(),
558                login_max,
559                upload: moka::future::Cache::builder()
560                    .time_to_live(Duration::from_secs(upload_window))
561                    .build(),
562                upload_max,
563                action: moka::future::Cache::builder()
564                    .time_to_live(Duration::from_secs(action_window))
565                    .build(),
566                action_max,
567            })
568        } else {
569            None
570        };
571
572        // Start background job queue (session cleanup runs every hour)
573        let jobs = crate::jobs::start(sessions.clone(), config.email.clone());
574
575        // Build HTTP client with configurable fetch timeout
576        let http_client = crate::http_client::build_http_client(Some(Duration::from_secs(
577            config.server.fetch_timeout,
578        )))
579        .map_err(|e| crate::Error::Server(format!("Failed to build HTTP client: {}", e)))?;
580
581        let resolved_content_dir = content_dir(&root);
582
583        // Build named datasources from [datasources.*] config
584        let mut datasources = HashMap::new();
585        for (name, ds_config) in &config.datasources {
586            let datasource = match ds_config.r#type {
587                crate::config::DatasourceType::D1 => {
588                    let account_id = ds_config.account_id.as_deref().ok_or_else(|| {
589                        crate::Error::Config(format!(
590                            "[datasources.{}] type = \"d1\" requires account_id",
591                            name
592                        ))
593                    })?;
594                    let api_token = ds_config.api_token.as_deref().ok_or_else(|| {
595                        crate::Error::Config(format!(
596                            "[datasources.{}] type = \"d1\" requires api_token",
597                            name
598                        ))
599                    })?;
600                    let db_id = ds_config.d1_database_id.as_deref().ok_or_else(|| {
601                        crate::Error::Config(format!(
602                            "[datasources.{}] type = \"d1\" requires d1_database_id",
603                            name
604                        ))
605                    })?;
606                    let db = crate::database::D1Database::new(
607                        &resolve_env_value(account_id)?,
608                        &resolve_env_value(db_id)?,
609                        &resolve_env_value(api_token)?,
610                    );
611                    tracing::info!("Datasource '{}': Cloudflare D1", name);
612                    crate::datasource::Datasource::Database(DatabaseAdapter::D1(db))
613                }
614                crate::config::DatasourceType::Supabase => {
615                    let project_url = ds_config.project_url.as_deref().ok_or_else(|| {
616                        crate::Error::Config(format!(
617                            "[datasources.{}] type = \"supabase\" requires project_url",
618                            name
619                        ))
620                    })?;
621                    let api_key = ds_config.api_key.as_deref().ok_or_else(|| {
622                        crate::Error::Config(format!(
623                            "[datasources.{}] type = \"supabase\" requires api_key",
624                            name
625                        ))
626                    })?;
627                    let db = crate::database::SupabaseDatabase::new(
628                        &resolve_env_value(project_url)?,
629                        &resolve_env_value(api_key)?,
630                    );
631                    tracing::info!("Datasource '{}': Supabase", name);
632                    crate::datasource::Datasource::Database(DatabaseAdapter::Supabase(db))
633                }
634                crate::config::DatasourceType::Sqlite => {
635                    let path = ds_config.path.as_deref().unwrap_or("data/app.db");
636                    let db_path = root.join(path);
637                    if let Some(parent) = db_path.parent() {
638                        std::fs::create_dir_all(parent).ok();
639                    }
640                    let db = crate::database::SqliteDatabase::open(&db_path)?;
641                    tracing::info!("Datasource '{}': SQLite ({})", name, db_path.display());
642                    crate::datasource::Datasource::Database(DatabaseAdapter::Sqlite(db))
643                }
644                crate::config::DatasourceType::Api => {
645                    let url = ds_config.url.as_deref().ok_or_else(|| {
646                        crate::Error::Config(format!(
647                            "[datasources.{}] type = \"api\" requires url",
648                            name
649                        ))
650                    })?;
651                    let base_url = resolve_env_value(url)?;
652                    if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
653                        return Err(crate::Error::Config(format!(
654                            "[datasources.{}] url must start with http:// or https://, got: {}",
655                            name, base_url
656                        )));
657                    }
658                    let mut headers: Vec<(String, String)> = Vec::new();
659                    if let Some(ref h) = ds_config.headers {
660                        for (k, v) in h {
661                            headers.push((k.clone(), resolve_env_value(v)?));
662                        }
663                    }
664                    tracing::info!("Datasource '{}': API ({})", name, base_url);
665                    crate::datasource::Datasource::Api { base_url, headers }
666                }
667            };
668            datasources.insert(name.clone(), datasource);
669        }
670
671        Ok(Self {
672            config: Arc::new(config),
673            store,
674            cache: WhatCache::new(),
675            components: Arc::new(components),
676            engine: Arc::new(engine),
677            content_dir: resolved_content_dir,
678            root,
679            sessions,
680            auth,
681            dev_mode,
682            css_mode,
683            live_reload_tx,
684            wired_tx,
685            wired_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
686            app_scopes: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
687            policies,
688            data_source_loaded: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
689            rate_limiters,
690            log_level: "info".to_string(),
691            jobs,
692            http_client,
693            upload_backend,
694            datasources,
695            wired_client_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
696            validated_actions: Arc::new(std::sync::RwLock::new(HashSet::new())),
697            activity_log: Arc::new(std::sync::Mutex::new(VecDeque::new())),
698        })
699    }
700
701    /// Run async initialization for datasources that need it (e.g., D1 system tables).
702    /// Call this after construction from an async context. Fails if any D1 init fails.
703    pub async fn init_datasources(&self) -> crate::Result<()> {
704        // Init primary store if D1
705        if let DatabaseAdapter::D1(ref db) = self.store {
706            db.init().await.map_err(|e| {
707                crate::Error::Config(format!("D1 primary database init failed: {}", e))
708            })?;
709        }
710        // Init each D1 datasource
711        for (name, ds) in &self.datasources {
712            if let crate::datasource::Datasource::Database(DatabaseAdapter::D1(db)) = ds {
713                db.init().await.map_err(|e| {
714                    crate::Error::Config(format!("D1 datasource '{}' init failed: {}", name, e))
715                })?;
716            }
717        }
718
719        // Build initial wired scope registry from application.what files
720        self.rebuild_wired_scopes().await;
721
722        Ok(())
723    }
724
725    /// Trigger a live reload notification (clears cache and notifies clients)
726    pub fn trigger_reload(&self) {
727        // Clear the cache
728        // Note: WhatCache uses async methods, but we can spawn a task
729        let cache = self.cache.clone();
730        tokio::spawn(async move {
731            cache.clear_all().await;
732        });
733
734        // Notify all connected WebSocket clients
735        if let Some(ref tx) = self.live_reload_tx {
736            let _ = tx.send(LiveReloadMessage::Reload);
737            tracing::debug!("Live reload triggered");
738        }
739    }
740
741    /// Trigger live reload — clears cache first, then notifies clients
742    /// Use this from async contexts to guarantee cache is cleared before reload
743    pub async fn trigger_reload_async(&self) {
744        // Clear cache BEFORE broadcasting so the browser fetches fresh content
745        self.cache.clear_all().await;
746
747        // Rebuild wired scope registry (application.what may have changed)
748        self.rebuild_wired_scopes().await;
749
750        // Now notify all WebSocket clients
751        if let Some(ref tx) = self.live_reload_tx {
752            let _ = tx.send(LiveReloadMessage::Reload);
753            tracing::debug!("Live reload triggered");
754        }
755    }
756
757    /// Get a receiver for live reload messages
758    pub fn live_reload_receiver(&self) -> Option<broadcast::Receiver<LiveReloadMessage>> {
759        self.live_reload_tx.as_ref().map(|tx| tx.subscribe())
760    }
761
762    /// Rebuild the wired + app scope registries by scanning all
763    /// application.what files. Wired scopes filter WebSocket delivery; app
764    /// scopes gate who may write `app.*` keys via `w-set`.
765    pub async fn rebuild_wired_scopes(&self) {
766        let mut wired = HashMap::new();
767        let mut app = HashMap::new();
768        self.scan_wired_scopes_dir(&self.content_dir, &mut wired, &mut app);
769        let count = wired.len();
770        let app_count = app.len();
771        *self.wired_scopes.write().await = wired;
772        *self.app_scopes.write().await = app;
773        if count > 0 || app_count > 0 {
774            tracing::info!(
775                "Scopes rebuilt: {} wired, {} application variable(s)",
776                count,
777                app_count
778            );
779        }
780    }
781
782    /// Recursively scan a directory for application.what files and extract
783    /// wired (delivery) and application (write-gate) scopes.
784    fn scan_wired_scopes_dir(
785        &self,
786        dir: &std::path::Path,
787        wired: &mut HashMap<String, WiredScope>,
788        app: &mut HashMap<String, WiredScope>,
789    ) {
790        let config_path = dir.join("application.what");
791        if config_path.exists() {
792            if let Ok(content) = std::fs::read_to_string(&config_path) {
793                let config = parse_what_file(&content);
794                for decl in &config.data_wired {
795                    wired.insert(decl.name.clone(), decl.scope.clone());
796                }
797                for decl in &config.data_application {
798                    // Only scoped app vars need a write gate.
799                    if !matches!(decl.scope, WiredScope::Public) {
800                        app.insert(decl.name.clone(), decl.scope.clone());
801                    }
802                }
803            }
804        }
805        // Recurse into subdirectories
806        if let Ok(entries) = std::fs::read_dir(dir) {
807            for entry in entries.flatten() {
808                if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
809                    self.scan_wired_scopes_dir(&entry.path(), wired, app);
810                }
811            }
812        }
813    }
814
815    /// Look up the scope for a wired key from the registry (defaults to Public)
816    pub async fn get_wired_scope(&self, key: &str) -> WiredScope {
817        self.wired_scopes
818            .read()
819            .await
820            .get(key)
821            .cloned()
822            .unwrap_or_default()
823    }
824
825    /// Look up the write-gate scope for an `app.*` key (defaults to Public).
826    pub async fn get_app_scope(&self, key: &str) -> WiredScope {
827        self.app_scopes
828            .read()
829            .await
830            .get(key)
831            .cloned()
832            .unwrap_or_default()
833    }
834}
835
836// ---------------------------------------------------------------------------
837// Security Headers Middleware
838// ---------------------------------------------------------------------------
839
840/// Middleware that checks global redirect rules from [redirects] in what.toml.
841/// Supports exact matches and wildcard prefix patterns ("/old/*" → "/new").
842async fn redirect_middleware(
843    State(state): State<AppState>,
844    request: Request<Body>,
845    next: Next,
846) -> Response {
847    if !state.config.redirects.is_empty() {
848        let path = request.uri().path();
849
850        // Check exact match first
851        if let Some(target) = state.config.redirects.get(path) {
852            tracing::debug!("Redirect: {} -> {}", path, target);
853            return Redirect::permanent(target).into_response();
854        }
855
856        // Check wildcard patterns: "/old/*" matches "/old/anything"
857        for (pattern, target) in &state.config.redirects {
858            if let Some(prefix) = pattern.strip_suffix("/*") {
859                if path.starts_with(prefix)
860                    && (path.len() == prefix.len()
861                        || path.as_bytes().get(prefix.len()) == Some(&b'/'))
862                {
863                    tracing::debug!(
864                        "Redirect (wildcard): {} -> {} (pattern: {})",
865                        path,
866                        target,
867                        pattern
868                    );
869                    return Redirect::permanent(target).into_response();
870                }
871            }
872        }
873    }
874
875    next.run(request).await
876}
877
878async fn security_headers_middleware(request: Request<Body>, next: Next) -> Response {
879    let mut response = next.run(request).await;
880    let headers = response.headers_mut();
881    headers.insert(
882        header::HeaderName::from_static("x-content-type-options"),
883        HeaderValue::from_static("nosniff"),
884    );
885    headers.insert(
886        header::HeaderName::from_static("x-frame-options"),
887        HeaderValue::from_static("SAMEORIGIN"),
888    );
889    headers.insert(
890        header::HeaderName::from_static("referrer-policy"),
891        HeaderValue::from_static("strict-origin-when-cross-origin"),
892    );
893    headers.insert(
894        header::HeaderName::from_static("x-xss-protection"),
895        HeaderValue::from_static("1; mode=block"),
896    );
897    headers.insert(
898        header::HeaderName::from_static("content-security-policy"),
899        HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' wss: ws:"),
900    );
901    response
902}
903
904// ---------------------------------------------------------------------------
905// Rate Limiting Middleware
906// ---------------------------------------------------------------------------
907
908/// Middleware that enforces per-IP rate limits on login, upload, and action endpoints.
909/// Uses moka caches with TTL-based sliding windows.
910async fn rate_limit_middleware(
911    State(state): State<AppState>,
912    request: Request<Body>,
913    next: Next,
914) -> Response {
915    if let Some(ref limiters) = state.rate_limiters {
916        let path = request.uri().path().to_string();
917
918        // Determine which limiter applies
919        let (cache, max) = if path == "/w-auth/login" {
920            Some((&limiters.login, limiters.login_max))
921        } else if path.starts_with("/w-upload/") {
922            Some((&limiters.upload, limiters.upload_max))
923        } else if path.starts_with("/w-action/") {
924            Some((&limiters.action, limiters.action_max))
925        } else {
926            None
927        }
928        .unzip();
929
930        if let (Some(cache), Some(max)) = (cache, max) {
931            // Extract client IP from X-Forwarded-For or peer address
932            let ip = request
933                .headers()
934                .get("x-forwarded-for")
935                .and_then(|v| v.to_str().ok())
936                .and_then(|s| s.split(',').next())
937                .map(|s| s.trim().to_string())
938                .unwrap_or_else(|| "unknown".to_string());
939
940            let key = format!(
941                "{}:{}",
942                ip,
943                path.split('/').take(3).collect::<Vec<_>>().join("/")
944            );
945            let count = cache.get(&key).await.unwrap_or(0) + 1;
946            cache.insert(key.clone(), count).await;
947
948            if count > max {
949                return (
950                    StatusCode::TOO_MANY_REQUESTS,
951                    "Rate limit exceeded. Try again later.",
952                )
953                    .into_response();
954            }
955        }
956    }
957
958    next.run(request).await
959}
960
961// ---------------------------------------------------------------------------
962// Request Logging Middleware
963// ---------------------------------------------------------------------------
964
965/// Middleware that logs every HTTP request with method, path, status, and duration.
966/// Log level varies by status code: INFO for 2xx/3xx, WARN for 4xx, ERROR for 5xx.
967/// In dev mode, also records the request into the inspector activity feed.
968async fn request_logging_middleware(
969    State(state): State<AppState>,
970    request: Request<Body>,
971    next: Next,
972) -> Response {
973    let method = request.method().clone();
974    let path = request.uri().path().to_string();
975    let start = Instant::now();
976
977    let response = next.run(request).await;
978
979    let status = response.status().as_u16();
980    let duration = start.elapsed();
981    let duration_ms = duration.as_millis();
982
983    // Skip logging for internal framework routes and WebSocket upgrades
984    if !path.starts_with("/w-livereload") && !path.starts_with("/w-wire") {
985        if status >= 500 {
986            tracing::error!("{method}  {path} → {status} ({duration_ms}ms)");
987        } else if status >= 400 {
988            tracing::warn!("{method}  {path} → {status} ({duration_ms}ms)");
989        } else {
990            tracing::info!("{method}  {path} → {status} ({duration_ms}ms)");
991        }
992
993        // Don't let viewing the activity feed fill the activity feed
994        if !path.starts_with("/w-inspector") {
995            state.record_activity(ActivityEvent::Request {
996                time: chrono::Local::now(),
997                method: method.to_string(),
998                path,
999                status,
1000                duration_ms: duration_ms as u64,
1001            });
1002        }
1003    }
1004
1005    response
1006}
1007
1008// ---------------------------------------------------------------------------
1009// CSRF Validation Middleware
1010// ---------------------------------------------------------------------------
1011
1012/// Middleware that validates CSRF tokens on all POST requests.
1013/// Checks for the token in the `_csrf` form field or `X-CSRF-Token` header.
1014/// Exempt paths (WebSocket endpoints) are skipped.
1015async fn csrf_middleware(
1016    State(state): State<AppState>,
1017    request: Request<Body>,
1018    next: Next,
1019) -> Response {
1020    // Only validate POST requests
1021    if request.method() != axum::http::Method::POST {
1022        return next.run(request).await;
1023    }
1024
1025    let path = request.uri().path().to_string();
1026
1027    // Skip exempt paths
1028    if is_csrf_exempt(&path) {
1029        return next.run(request).await;
1030    }
1031
1032    // Skip CSRF validation if sessions are disabled
1033    if state.sessions.is_none() {
1034        return next.run(request).await;
1035    }
1036
1037    // Extract CSRF token from header (for AJAX requests)
1038    let header_token = request
1039        .headers()
1040        .get("X-CSRF-Token")
1041        .and_then(|v| v.to_str().ok())
1042        .map(|s| s.to_string());
1043
1044    // Get session from cookie
1045    let cookie_header = request
1046        .headers()
1047        .get(header::COOKIE)
1048        .and_then(|v| v.to_str().ok())
1049        .map(|s| s.to_string());
1050
1051    let session_id =
1052        sessions::parse_session_cookie(cookie_header.as_deref(), &state.config.session.cookie_name);
1053
1054    let session = if let (Some(sessions), Some(id)) = (&state.sessions, &session_id) {
1055        sessions.get(id).await.ok().flatten()
1056    } else {
1057        None
1058    };
1059
1060    // If no session exists, or the session doesn't have a CSRF token,
1061    // skip validation. This handles first-time visitors, pre-existing sessions
1062    // created before CSRF was enabled, and test scenarios.
1063    let session_has_csrf = session
1064        .as_ref()
1065        .and_then(|s| s.data.get(CSRF_SESSION_KEY))
1066        .and_then(|v| v.as_str())
1067        .is_some();
1068
1069    if !session_has_csrf {
1070        return next.run(request).await;
1071    }
1072
1073    // For AJAX requests with X-CSRF-Token header, validate immediately
1074    if let Some(ref token) = header_token {
1075        if validate_csrf_token(session.as_ref(), None, Some(token)) {
1076            return next.run(request).await;
1077        }
1078        return (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response();
1079    }
1080
1081    // For form submissions, we need to read the body to get _csrf field.
1082    // Instead of consuming the body here, we'll pass the session info
1083    // through request extensions and let handlers validate.
1084    // However, for simplicity and robustness, let's peek at the content type.
1085    let content_type = request
1086        .headers()
1087        .get(header::CONTENT_TYPE)
1088        .and_then(|v| v.to_str().ok())
1089        .unwrap_or("")
1090        .to_string();
1091
1092    if content_type.starts_with("application/x-www-form-urlencoded")
1093        || content_type.starts_with("multipart/form-data")
1094    {
1095        // For form submissions without an X-CSRF-Token header,
1096        // we need to extract _csrf from the form body.
1097        // Since axum middleware can't easily peek at form bodies without consuming them,
1098        // we'll use a different strategy: extract the body, parse it, validate, then reconstruct.
1099        let (parts, body) = request.into_parts();
1100        let max_body = crate::config::parse_size_string(&state.config.server.max_body_size);
1101        let bytes = match axum::body::to_bytes(body, max_body).await {
1102            Ok(b) => b,
1103            Err(_) => {
1104                return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
1105            }
1106        };
1107
1108        // Parse form data to get _csrf field
1109        let body_str = String::from_utf8_lossy(&bytes);
1110        let form_csrf = serde_urlencoded::from_str::<Vec<(String, String)>>(&body_str)
1111            .ok()
1112            .and_then(|pairs| {
1113                pairs
1114                    .into_iter()
1115                    .find(|(k, _)| k == "_csrf")
1116                    .map(|(_, v)| v)
1117            });
1118
1119        if validate_csrf_token(session.as_ref(), form_csrf.as_deref(), None) {
1120            // Reconstruct the request with the original body
1121            let request = Request::from_parts(parts, Body::from(bytes));
1122            return next.run(request).await;
1123        }
1124
1125        return (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response();
1126    }
1127
1128    // For other content types (JSON, etc.), require X-CSRF-Token header
1129    // which was already checked above. If we reach here, there's no token.
1130    (StatusCode::FORBIDDEN, "CSRF token mismatch").into_response()
1131}
1132
1133/// Create the application router
1134pub fn create_router(state: AppState) -> Router {
1135    let static_dir = state.root.join("static");
1136    let dev_mode = state.dev_mode;
1137
1138    // Choose cache control header based on mode
1139    let cache_control = if dev_mode {
1140        HeaderValue::from_static("no-cache, no-store, must-revalidate")
1141    } else {
1142        HeaderValue::from_static("public, max-age=3600")
1143    };
1144
1145    // Create static file service with cache headers
1146    let static_service = ServiceBuilder::new()
1147        .layer(SetResponseHeaderLayer::overriding(
1148            header::CACHE_CONTROL,
1149            cache_control,
1150        ))
1151        .service(ServeDir::new(static_dir));
1152
1153    // Health check route — defined outside the main router so it bypasses all middleware
1154    let health_router = Router::new().route("/health", get(handle_health));
1155
1156    let mut router = Router::new()
1157        // Framework assets served from embedded bytes (take priority over static/)
1158        .route("/static/what.css", get(handle_embedded_css))
1159        .route("/static/what.js", get(handle_embedded_js))
1160        // User static files (with appropriate cache headers)
1161        .nest_service("/static", static_service);
1162
1163    // Serve uploaded files if uploads are enabled
1164    if state.config.uploads.enabled {
1165        let uploads_dir = state.root.join(&state.config.uploads.directory);
1166        let uploads_service = ServeDir::new(uploads_dir);
1167        router = router.nest_service("/uploads", uploads_service);
1168    }
1169
1170    router = router
1171        // Session management
1172        .route("/w-session/reset", post(handle_session_reset))
1173        // Authentication endpoints
1174        .route("/w-auth/login", post(handle_login))
1175        .route("/w-auth/logout", post(handle_logout))
1176        // Form actions (POST/PUT/DELETE)
1177        .route("/w-action/:collection", post(handle_action))
1178        .route("/w-action/:collection/:id", post(handle_action_with_id))
1179        // File upload actions (multipart/form-data)
1180        .route("/w-upload/:collection", post(handle_upload))
1181        // Declarative state mutations (w-set attribute)
1182        .route("/w-set", post(handle_w_set))
1183        // Wired state WebSocket (real-time push to all connected clients)
1184        .route("/w-wire", get(handle_wire_ws))
1185        .route("/w-session/clear-data", post(handle_session_clear_data))
1186        // Partial rendering (explicit route for AJAX fragments)
1187        .route("/w-partial/*path", get(handle_partial));
1188
1189    // Add dev-only endpoints (source viewer, live reload, debug tools, demo endpoints)
1190    if dev_mode {
1191        router = router
1192            .route("/w-source/*path", get(handle_page_source))
1193            .route("/w-inject/notification", get(handle_inject_notification))
1194            .route("/w-livereload", get(handle_livereload_ws))
1195            .route("/w-cache/clear-all", post(handle_cache_clear_all))
1196            .route("/w-sessions/list", get(handle_sessions_list))
1197            .route("/w-data/info", get(handle_data_info))
1198            .route("/w-inspector", get(handle_inspector));
1199    } else if state.config.server.source_viewer {
1200        // Source viewer enabled in production via config
1201        router = router.route("/w-source/*path", get(handle_page_source));
1202    }
1203
1204    let app = router
1205        // Dynamic page routing
1206        .fallback(handle_page)
1207        .with_state(state.clone())
1208        // Request body size limit — applied globally
1209        .layer(axum::extract::DefaultBodyLimit::max(
1210            crate::config::parse_size_string(&state.config.server.max_body_size),
1211        ))
1212        // Rate limiting — per-IP limits on login, upload, action endpoints
1213        .layer(axum::middleware::from_fn_with_state(
1214            state.clone(),
1215            rate_limit_middleware,
1216        ))
1217        // CSRF validation — runs before route handlers
1218        .layer(axum::middleware::from_fn_with_state(
1219            state.clone(),
1220            csrf_middleware,
1221        ))
1222        // Global redirects — check [redirects] table from what.toml
1223        .layer(axum::middleware::from_fn_with_state(
1224            state.clone(),
1225            redirect_middleware,
1226        ))
1227        // Security headers — applied to every response
1228        .layer(axum::middleware::from_fn(security_headers_middleware))
1229        // Request logging — outermost layer so it captures all requests
1230        .layer(axum::middleware::from_fn_with_state(
1231            state.clone(),
1232            request_logging_middleware,
1233        ));
1234
1235    // Merge health check AFTER middleware layers — /health bypasses all middleware
1236    app.merge(health_router)
1237}
1238
1239/// Health check endpoint — returns 200 OK with status and version.
1240/// Bypasses all middleware (CSRF, rate limiting, auth, etc.)
1241async fn handle_health() -> impl IntoResponse {
1242    axum::Json(json!({
1243        "status": "ok",
1244        "version": env!("CARGO_PKG_VERSION")
1245    }))
1246}
1247
1248/// Handle page requests with file-based routing
1249async fn handle_page(State(state): State<AppState>, request: Request<Body>) -> impl IntoResponse {
1250    let path = request.uri().path().to_string();
1251
1252    // Extract query parameters
1253    let query_params = decode_query_params(request.uri().query());
1254
1255    // Check if this is a partial request (AJAX from What.js)
1256    let is_partial_request = request
1257        .headers()
1258        .get("X-Requested-With")
1259        .and_then(|v| v.to_str().ok())
1260        .map(|v| v == "What")
1261        .unwrap_or(false);
1262
1263    // Extract session from cookie
1264    let cookie_header = request
1265        .headers()
1266        .get(header::COOKIE)
1267        .and_then(|v| v.to_str().ok());
1268
1269    let (mut session, is_new_session) = if let Some(ref sessions) = state.sessions {
1270        let session_id =
1271            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
1272
1273        match sessions.get_or_create(session_id.as_deref()).await {
1274            Ok(session) => {
1275                let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
1276                (Some(session), is_new)
1277            }
1278            Err(e) => {
1279                tracing::warn!("Session error: {}", e);
1280                (None, false)
1281            }
1282        }
1283    } else {
1284        (None, false)
1285    };
1286
1287    // Extract user context from JWT
1288    let user_context = if state.auth.is_enabled() {
1289        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
1290            match state.auth.decode_jwt(&token) {
1291                Ok(claims) => {
1292                    if claims.is_expired() {
1293                        tracing::debug!("JWT token expired");
1294                        UserContext::unauthenticated()
1295                    } else {
1296                        let user_claims = claims.to_context(state.auth.jwt_claims());
1297                        UserContext::from_claims(user_claims)
1298                    }
1299                }
1300                Err(e) => {
1301                    tracing::debug!("Failed to decode JWT: {}", e);
1302                    UserContext::unauthenticated()
1303                }
1304            }
1305        } else {
1306            UserContext::unauthenticated()
1307        }
1308    } else {
1309        UserContext::unauthenticated()
1310    };
1311
1312    // Try to find matching page file
1313    let resolved = resolve_page_path(&state.root, &path);
1314    let route_params = resolved
1315        .as_ref()
1316        .map(|r| r.params.clone())
1317        .unwrap_or_default();
1318    let page_path = resolved.map(|r| r.path);
1319
1320    // Load the application.what config chain, read the page, and parse
1321    // directives — off the async worker: this is the per-request filesystem
1322    // walk + read, and blocking a tokio worker here stalls unrelated requests.
1323    let (app_config, page_content, mut directives) = {
1324        let root = state.root.clone();
1325        let url_path = path.clone();
1326        let file_path = page_path.clone();
1327        let dev_mode = state.dev_mode;
1328        let load = tokio::task::spawn_blocking(move || {
1329            let app_config = load_application_config(&root, &url_path);
1330            let (content, dirs) = match file_path {
1331                Some(file_path) => match std::fs::read_to_string(&file_path) {
1332                    Ok(content) => {
1333                        if dev_mode {
1334                            engine::warn_template_lints_once(&file_path, &content);
1335                        }
1336                        let (dirs, cleaned) = parse_page_directives(&content);
1337                        (Some(cleaned), dirs)
1338                    }
1339                    Err(_) => (None, PageDirectives::default()),
1340                },
1341                None => (None, PageDirectives::default()),
1342            };
1343            (app_config, content, dirs)
1344        })
1345        .await;
1346        match load {
1347            Ok(loaded) => loaded,
1348            Err(e) => {
1349                tracing::error!("Page load task failed: {}", e);
1350                (WhatConfig::default(), None, PageDirectives::default())
1351            }
1352        }
1353    };
1354
1355    // Merge app config directives into page directives (app config as base, page overrides)
1356    // Only apply app config auth if page doesn't specify its own
1357    if !directives.requires_auth() && app_config.directives.requires_auth() {
1358        directives.auth = app_config.directives.auth.clone();
1359        directives.protected = app_config.directives.protected;
1360        directives.roles = app_config.directives.roles.clone();
1361    }
1362    // Apply other directives from app config if not set on page
1363    if directives.title.is_none() {
1364        directives.title = app_config.directives.title.clone();
1365    }
1366    if directives.redirect.is_none() {
1367        directives.redirect = app_config.directives.redirect.clone();
1368    }
1369    if directives.cache_ttl.is_none() {
1370        directives.cache_ttl = app_config.directives.cache_ttl;
1371    }
1372
1373    // Handle page-level redirect directive
1374    if let Some(ref redirect_to) = directives.redirect {
1375        return Redirect::to(redirect_to).into_response();
1376    }
1377
1378    // Handle excluded pages (404)
1379    if directives.exclude {
1380        let html = render_custom_error_page(
1381            &state,
1382            StatusCode::NOT_FOUND,
1383            &path,
1384            "Page not found",
1385            session.as_ref(),
1386            &user_context,
1387        )
1388        .await;
1389        return build_response(
1390            StatusCode::NOT_FOUND,
1391            vec![(header::CONTENT_TYPE, "text/html".to_string())],
1392            html,
1393        );
1394    }
1395
1396    // Extract user roles from JWT claims
1397    let user_roles: Vec<String> = user_context.roles();
1398
1399    // Check if page requires authentication (new auth directive or legacy protected)
1400    let requires_auth = directives.requires_auth() || state.auth.is_protected(&path);
1401
1402    if requires_auth && !user_context.authenticated {
1403        // Redirect to login page with return URL
1404        let login_path = state.auth.login_path();
1405        let redirect_url = format!("{}?redirect={}", login_path, urlencoding::encode(&path));
1406        return Redirect::to(&redirect_url).into_response();
1407    }
1408
1409    // Check access (handles auth: all | user | roles and legacy roles)
1410    if !directives.check_access(user_context.authenticated, &user_roles) {
1411        let html = render_custom_error_page(
1412            &state,
1413            StatusCode::FORBIDDEN,
1414            &path,
1415            "You don't have permission to access this page.",
1416            session.as_ref(),
1417            &user_context,
1418        )
1419        .await;
1420        return build_response(
1421            StatusCode::FORBIDDEN,
1422            vec![(header::CONTENT_TYPE, "text/html".to_string())],
1423            html,
1424        );
1425    }
1426
1427    // Build response headers
1428    let mut headers = vec![(header::CONTENT_TYPE, "text/html".to_string())];
1429
1430    // Add Set-Cookie header for new sessions
1431    if is_new_session {
1432        if let Some(ref s) = session {
1433            let cookie = sessions::build_session_cookie(
1434                &s.id,
1435                &state.config.session.cookie_name,
1436                state.config.session.max_age,
1437                state.config.session.secure,
1438            );
1439            headers.push((header::SET_COOKIE, cookie));
1440        }
1441    }
1442
1443    // Apply custom headers from application.what (header.Name = "value")
1444    for (name, value) in &app_config.directives.headers {
1445        match header::HeaderName::from_bytes(name.as_bytes()) {
1446            Ok(header_name) => match HeaderValue::from_str(value) {
1447                Ok(header_value) => headers.push((
1448                    header_name,
1449                    header_value.to_str().unwrap_or(value).to_string(),
1450                )),
1451                Err(_) => tracing::warn!(
1452                    "Invalid custom header value for '{}': could not parse value",
1453                    name
1454                ),
1455            },
1456            Err(_) => tracing::warn!(
1457                "Invalid custom header name '{}': could not parse as HTTP header",
1458                name
1459            ),
1460        }
1461    }
1462    // Apply custom headers from page-level directives (overrides app-level)
1463    for (name, value) in &directives.headers {
1464        match header::HeaderName::from_bytes(name.as_bytes()) {
1465            Ok(header_name) => match HeaderValue::from_str(value) {
1466                Ok(header_value) => headers.push((
1467                    header_name,
1468                    header_value.to_str().unwrap_or(value).to_string(),
1469                )),
1470                Err(_) => tracing::warn!(
1471                    "Invalid custom header value for '{}': could not parse value",
1472                    name
1473                ),
1474            },
1475            Err(_) => tracing::warn!(
1476                "Invalid custom header name '{}': could not parse as HTTP header",
1477                name
1478            ),
1479        }
1480    }
1481
1482    // Consume flash messages from session (before rendering)
1483    let flash_data = if let Some(ref mut sess) = session {
1484        let flash = consume_flash_data(sess);
1485        // Persist the removal
1486        if flash.is_some() {
1487            if let Some(ref sessions) = state.sessions {
1488                let _ = sessions.update(&sess.id, sess.data.clone()).await;
1489            }
1490        }
1491        flash
1492    } else {
1493        None
1494    };
1495
1496    match (page_path, page_content) {
1497        (Some(_file_path), Some(content)) => {
1498            // Apply session mutations atomically (SQL-level for SQLite)
1499            if !directives.session_mutations.is_empty() {
1500                if let (Some(sess), Some(sessions)) = (&mut session, &state.sessions) {
1501                    for mutation in &directives.session_mutations {
1502                        let atomic = to_atomic_mutation(mutation, &sess.data);
1503                        match sessions.apply_mutation(&sess.id, &atomic).await {
1504                            Ok(updated_data) => sess.data = updated_data,
1505                            Err(e) => tracing::error!("Failed to apply session mutation: {}", e),
1506                        }
1507                    }
1508                }
1509            }
1510
1511            // Skip cache if we have a session or user (session data is per-user)
1512            let use_cache = session.is_none() && !user_context.authenticated;
1513
1514            if use_cache {
1515                let cache_key = CacheKey::page(&path);
1516                if let Some(cached) = state.cache.get(&cache_key).await {
1517                    return build_response(StatusCode::OK, headers, cached.content);
1518                }
1519            }
1520
1521            // Render the page (content already has <what> directives removed)
1522            // For partial requests, use reactive rendering and append OOB updates
1523            match render_content_internal(
1524                &state,
1525                &content,
1526                session.as_ref(),
1527                &user_context,
1528                Some(&app_config),
1529                Some(&directives),
1530                Some(&query_params),
1531                &route_params,
1532                is_partial_request,
1533                flash_data.as_ref(),
1534            )
1535            .await
1536            {
1537                Ok(render_result) => {
1538                    let mut html = render_result.html;
1539
1540                    // For partial requests, append OOB template with session values
1541                    if is_partial_request && !render_result.session_keys.is_empty() {
1542                        if let Some(ref s) = session {
1543                            // Build JSON object with current session values for used keys
1544                            let mut updates = serde_json::Map::new();
1545                            for key in &render_result.session_keys {
1546                                if let Some(value) = s.data.get(key) {
1547                                    updates.insert(format!("session.{}", key), value.clone());
1548                                }
1549                            }
1550                            if !updates.is_empty() {
1551                                let json_str = serde_json::to_string(&updates).unwrap_or_default();
1552                                html.push_str(&format!(
1553                                    r#"<template data-what-updates>{}</template>"#,
1554                                    json_str
1555                                ));
1556                            }
1557                        }
1558                    }
1559
1560                    // Add X-What-Debug header for partial requests in dev mode
1561                    if state.dev_mode && is_partial_request && !render_result.fetch_debug.is_empty()
1562                    {
1563                        if let Ok(debug_json) = serde_json::to_string(&render_result.fetch_debug) {
1564                            headers.push((
1565                                header::HeaderName::from_static("x-what-debug"),
1566                                debug_json,
1567                            ));
1568                        }
1569                    }
1570
1571                    // Inject CSRF tokens into forms and <head>
1572                    if let Some(ref s) = session {
1573                        if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
1574                            html = inject_csrf_tokens(&html, csrf);
1575                        }
1576                    }
1577
1578                    // Inject SEO meta tags from page directives
1579                    if !is_partial_request {
1580                        html = inject_seo_meta(&html, &directives.custom, &directives.vars);
1581                    }
1582
1583                    // Inject debug meta tag in dev mode
1584                    if state.dev_mode && !is_partial_request {
1585                        html = inject_debug_meta(&html, &state.log_level);
1586                    }
1587
1588                    // Register validated form actions for server-side enforcement
1589                    register_validated_actions(&html, &state);
1590
1591                    // Always inject framework CSS (needed for SPA stylesheet sync)
1592                    html = inject_what_css(&html, state.css_mode);
1593                    // Only inject framework JS on full-page loads (already running for SPA)
1594                    if !is_partial_request {
1595                        html = inject_what_js(&html);
1596                        html = inject_theme_restore(&html);
1597                    }
1598
1599                    // Cache the rendered page only if no session/user (non-partial only)
1600                    if use_cache && !is_partial_request {
1601                        let cache_key = CacheKey::page(&path);
1602                        state
1603                            .cache
1604                            .set(&cache_key, CachedValue::html(html.clone()))
1605                            .await;
1606                    }
1607                    build_response(StatusCode::OK, headers, html)
1608                }
1609                Err(e) => {
1610                    tracing::error!("Render error: {}", e);
1611                    let html = render_custom_error_page(
1612                        &state,
1613                        StatusCode::INTERNAL_SERVER_ERROR,
1614                        &path,
1615                        &e.to_string(),
1616                        session.as_ref(),
1617                        &user_context,
1618                    )
1619                    .await;
1620                    build_response(StatusCode::INTERNAL_SERVER_ERROR, headers, html)
1621                }
1622            }
1623        }
1624        _ => {
1625            // 404 - try custom error page
1626            let html = render_custom_error_page(
1627                &state,
1628                StatusCode::NOT_FOUND,
1629                &path,
1630                "Page not found",
1631                session.as_ref(),
1632                &user_context,
1633            )
1634            .await;
1635            build_response(StatusCode::NOT_FOUND, headers, html)
1636        }
1637    }
1638}
1639
1640/// Convert a parser SessionMutation to an AtomicMutation, resolving session variable references.
1641fn to_atomic_mutation(
1642    mutation: &SessionMutation,
1643    session_data: &HashMap<String, Value>,
1644) -> AtomicMutation {
1645    match mutation {
1646        SessionMutation::Increment { key, value } => AtomicMutation::Increment {
1647            key: key.clone(),
1648            value: *value,
1649        },
1650        SessionMutation::Set { key, value } => {
1651            let resolved = resolve_session_value(value, session_data);
1652            AtomicMutation::Set {
1653                key: key.clone(),
1654                value: resolved,
1655            }
1656        }
1657        SessionMutation::Push { key, value } => {
1658            let resolved = resolve_session_value(value, session_data);
1659            AtomicMutation::Push {
1660                key: key.clone(),
1661                value: resolved,
1662            }
1663        }
1664        SessionMutation::PushMax { key, max, value } => {
1665            let resolved = resolve_session_value(value, session_data);
1666            AtomicMutation::PushMax {
1667                key: key.clone(),
1668                max: *max,
1669                value: resolved,
1670            }
1671        }
1672        SessionMutation::Unshift { key, value } => {
1673            let resolved = resolve_session_value(value, session_data);
1674            AtomicMutation::Unshift {
1675                key: key.clone(),
1676                value: resolved,
1677            }
1678        }
1679        SessionMutation::Clear { key } => AtomicMutation::Clear { key: key.clone() },
1680    }
1681}
1682
1683/// Resolve variable references in a session mutation value.
1684/// Supports full interpolation: `"My friend #session.age# is here"` resolves
1685/// `#session.*#` references from current session data.
1686fn resolve_session_value(value: &Value, session_data: &HashMap<String, Value>) -> Value {
1687    if let Some(s) = value.as_str() {
1688        if s.contains('#') {
1689            // Build a context with session data so #session.key# resolves
1690            let mut context = HashMap::new();
1691            let session_obj: serde_json::Map<String, Value> = session_data
1692                .iter()
1693                .map(|(k, v)| (k.clone(), v.clone()))
1694                .collect();
1695            context.insert("session".to_string(), Value::Object(session_obj));
1696            let resolved = crate::parser::replace_variables(s, &context);
1697            // Try arithmetic evaluation on the resolved string
1698            if let Some(n) = crate::parser::evaluate_arithmetic(&resolved) {
1699                return if n == n.trunc() && n.abs() < i64::MAX as f64 {
1700                    json!(n as i64)
1701                } else {
1702                    json!(n)
1703                };
1704            }
1705            // If result looks like a number, preserve the type
1706            if let Ok(n) = resolved.parse::<i64>() {
1707                return json!(n);
1708            }
1709            if let Ok(n) = resolved.parse::<f64>() {
1710                return json!(n);
1711            }
1712            return json!(resolved);
1713        }
1714        // Try arithmetic on non-interpolated strings (e.g., "10 + 1")
1715        if let Some(n) = crate::parser::evaluate_arithmetic(s) {
1716            return if n == n.trunc() && n.abs() < i64::MAX as f64 {
1717                json!(n as i64)
1718            } else {
1719                json!(n)
1720            };
1721        }
1722    }
1723    value.clone()
1724}
1725
1726/// Build HTTP response with custom headers
1727fn build_response(
1728    status: StatusCode,
1729    headers: Vec<(header::HeaderName, String)>,
1730    body: String,
1731) -> axum::response::Response {
1732    let mut response = axum::response::Response::builder().status(status);
1733
1734    for (name, value) in headers {
1735        response = response.header(name, value);
1736    }
1737
1738    response.body(Body::from(body)).unwrap_or_else(|_| {
1739        axum::response::Response::builder()
1740            .status(StatusCode::INTERNAL_SERVER_ERROR)
1741            .body(Body::from("Internal Server Error"))
1742            .expect("fallback response must build")
1743    })
1744}
1745
1746/// Build an error page that hides details in production mode.
1747/// In dev mode, shows the full error for debugging.
1748/// In production, returns a generic message.
1749/// Render a custom error page (site/404.html, site/500.html, site/403.html)
1750/// with error context variables, falling back to a generic error page.
1751async fn render_custom_error_page(
1752    state: &AppState,
1753    status: StatusCode,
1754    request_path: &str,
1755    detail: &str,
1756    session: Option<&sessions::Session>,
1757    user: &UserContext,
1758) -> String {
1759    let error_file = state.content_dir.join(format!("{}.html", status.as_u16()));
1760    if error_file.exists() {
1761        if let Ok(raw_content) = tokio::fs::read_to_string(&error_file).await {
1762            if state.dev_mode {
1763                engine::warn_template_lints_once(&error_file, &raw_content);
1764            }
1765            let (_directives, content) = parse_page_directives(&raw_content);
1766            // Inject error context variables before rendering
1767            let mut content_with_error = content.clone();
1768            content_with_error =
1769                content_with_error.replace("#error.status#", &status.as_u16().to_string());
1770            content_with_error = content_with_error.replace("#error.path#", request_path);
1771            // Only expose error message in dev mode
1772            if state.dev_mode {
1773                content_with_error = content_with_error.replace("#error.message#", detail);
1774            } else {
1775                content_with_error = content_with_error.replace("#error.message#", "");
1776            }
1777
1778            match render_content(
1779                state,
1780                &content_with_error,
1781                session,
1782                user,
1783                None,
1784                Some(&_directives),
1785                None,
1786            )
1787            .await
1788            {
1789                Ok(html) => return html,
1790                Err(e) => tracing::warn!("Failed to render custom {} page: {}", status.as_u16(), e),
1791            }
1792        }
1793    }
1794
1795    // Fallback to generic error page
1796    error_page_fallback(state.dev_mode, status, detail)
1797}
1798
1799fn error_page_fallback(dev_mode: bool, status: StatusCode, detail: &str) -> String {
1800    if dev_mode {
1801        format!("<h1>Error {}</h1><pre>{}</pre>", status.as_u16(), detail)
1802    } else {
1803        match status {
1804            StatusCode::NOT_FOUND => "<h1>404 - Page Not Found</h1>".to_string(),
1805            StatusCode::FORBIDDEN => "<h1>403 - Forbidden</h1>".to_string(),
1806            _ => "<h1>Something went wrong</h1><p>An internal error occurred.</p>".to_string(),
1807        }
1808    }
1809}
1810
1811// ---------------------------------------------------------------------------
1812// CSRF Protection
1813// ---------------------------------------------------------------------------
1814
1815/// Reserved session key for the CSRF token
1816const CSRF_SESSION_KEY: &str = "_csrf_token";
1817
1818/// Inject CSRF protection into rendered HTML:
1819/// 1. Adds `<input type="hidden" name="_csrf" value="TOKEN">` to all `<form method="post">` tags
1820/// 2. Adds `<meta name="csrf-token" content="TOKEN">` to the `<head>` section
1821fn inject_csrf_tokens(html: &str, csrf_token: &str) -> String {
1822    static FORM_POST_RE: LazyLock<Regex> = LazyLock::new(|| {
1823        Regex::new(r#"(?i)(<form\b[^>]*\bmethod\s*=\s*["']?post["']?[^>]*>)"#).unwrap()
1824    });
1825
1826    let hidden_input = format!(
1827        r#"<input type="hidden" name="_csrf" value="{}">"#,
1828        csrf_token
1829    );
1830
1831    // Inject hidden input after each POST form opening tag
1832    let output = FORM_POST_RE.replace_all(html, |caps: &regex::Captures| {
1833        format!("{}{}", &caps[0], hidden_input)
1834    });
1835
1836    // Inject meta tag in <head>
1837    let meta_tag = format!(r#"<meta name="csrf-token" content="{}">"#, csrf_token);
1838
1839    let output = if let Some(pos) = output.find("</head>") {
1840        format!("{}{}\n{}", &output[..pos], meta_tag, &output[pos..])
1841    } else if let Some(pos) = output.find("</HEAD>") {
1842        format!("{}{}\n{}", &output[..pos], meta_tag, &output[pos..])
1843    } else {
1844        // No <head> section — prepend the meta tag so JS can still find it
1845        format!("{}\n{}", meta_tag, output)
1846    };
1847
1848    output
1849}
1850
1851/// Inject `<script src="/static/what.js"></script>` before `</body>` on full-page responses.
1852/// Skips injection if the script tag already exists in the HTML.
1853fn inject_what_js(html: &str) -> String {
1854    let script_tag = format!(r#"<script src="{}"></script>"#, WHAT_JS_ASSET_PATH.as_str());
1855
1856    // Skip if any what.js tag is already present, versioned or not.
1857    if html.contains(r#"/static/what.js"#) {
1858        return html.to_string();
1859    }
1860
1861    if let Some(pos) = html.find("</body>") {
1862        format!("{}{}\n{}", &html[..pos], script_tag, &html[pos..])
1863    } else if let Some(pos) = html.find("</BODY>") {
1864        format!("{}{}\n{}", &html[..pos], script_tag, &html[pos..])
1865    } else {
1866        html.to_string()
1867    }
1868}
1869
1870/// Inject a tiny inline script right after `<head>` that re-applies the saved
1871/// w-theme localStorage class to `<html>` before first paint (no FOUC). Runs on
1872/// full-page responses only — the head persists across SPA navigations.
1873fn inject_theme_restore(html: &str) -> String {
1874    const THEME_SCRIPT: &str = r#"<script data-w-theme>(function(){try{var t=localStorage.getItem('w-theme');if(t==='dark'||t==='light'){var c=document.documentElement.classList;c.remove('dark','light');c.add(t);}}catch(e){}})()</script>"#;
1875
1876    if html.contains("data-w-theme") {
1877        return html.to_string();
1878    }
1879
1880    // Insert right after the opening <head ...> tag so it runs before any
1881    // stylesheet is applied
1882    for open in ["<head>", "<HEAD>"] {
1883        if let Some(pos) = html.find(open) {
1884            let insert_at = pos + open.len();
1885            return format!("{}{}{}", &html[..insert_at], THEME_SCRIPT, &html[insert_at..]);
1886        }
1887    }
1888    if let Some(pos) = html.find("<head ") {
1889        if let Some(end) = html[pos..].find('>') {
1890            let insert_at = pos + end + 1;
1891            return format!("{}{}{}", &html[..insert_at], THEME_SCRIPT, &html[insert_at..]);
1892        }
1893    }
1894    html.to_string()
1895}
1896
1897/// Scan rendered HTML for `<form w-validate ... action="...">` tags and register
1898/// their action URLs so the server can enforce validation even if w-rules is stripped.
1899fn register_validated_actions(html: &str, state: &AppState) {
1900    static FORM_RE: LazyLock<Regex> = LazyLock::new(|| {
1901        Regex::new(r#"(?si)<form\b[^>]*\bw-validate\b[^>]*>"#).unwrap()
1902    });
1903    static ACTION_RE: LazyLock<Regex> = LazyLock::new(|| {
1904        Regex::new(r#"(?i)action="([^"]+)""#).unwrap()
1905    });
1906
1907    let mut found = Vec::new();
1908    for mat in FORM_RE.find_iter(html) {
1909        if let Some(cap) = ACTION_RE.captures(mat.as_str()) {
1910            if let Some(action) = cap.get(1) {
1911                found.push(action.as_str().to_string());
1912            }
1913        }
1914    }
1915    if !found.is_empty() {
1916        if let Ok(mut registry) = state.validated_actions.write() {
1917            for url in found {
1918                registry.insert(url);
1919            }
1920        }
1921    }
1922}
1923
1924/// Inject `<link rel="stylesheet" href="/static/what.css">` into `<head>` on full-page responses.
1925/// Skips injection if the link tag already exists in the HTML, or entirely in CssMode::None.
1926fn inject_what_css(html: &str, mode: CssMode) -> String {
1927    if mode == CssMode::None {
1928        return html.to_string();
1929    }
1930    let asset_path = match mode {
1931        CssMode::Minimal => WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
1932        _ => WHAT_CSS_ASSET_PATH.as_str(),
1933    };
1934    let link_tag = format!(r#"<link rel="stylesheet" href="{}">"#, asset_path);
1935
1936    // Insert before the first <link in <head> so CSS variables are defined
1937    // before any dependent stylesheet. Fall back to after <head> if no <link> found.
1938    let head_start = html.find("<head>").or_else(|| html.find("<HEAD>"));
1939
1940    // Skip if what.css is already present in <head> (not in body/code examples).
1941    if let Some(hp) = head_start {
1942        let head_section_end = html[hp..].find("</head>").map(|p| p + hp).unwrap_or(html.len());
1943        if html[hp..head_section_end].contains("/static/what.css") {
1944            return html.to_string();
1945        }
1946    }
1947    if let Some(head_pos) = head_start {
1948        let after_head = head_pos + 6; // length of "<head>"
1949        let head_end = html[after_head..]
1950            .find("</head>")
1951            .or_else(|| html[after_head..].find("</HEAD>"))
1952            .map(|p| p + after_head)
1953            .unwrap_or(html.len());
1954        // Look for first <link within <head>
1955        if let Some(rel) = html[after_head..head_end].find("<link ").or_else(|| html[after_head..head_end].find("<link\n")) {
1956            let insert_at = after_head + rel;
1957            format!(
1958                "{}{}\n  {}",
1959                &html[..insert_at],
1960                link_tag,
1961                &html[insert_at..]
1962            )
1963        } else {
1964            format!(
1965                "{}\n  {}{}",
1966                &html[..after_head],
1967                link_tag,
1968                &html[after_head..]
1969            )
1970        }
1971    } else {
1972        html.to_string()
1973    }
1974}
1975
1976/// Inject SEO meta tags from page directives into the `<head>` section.
1977/// Supports: description, og.title, og.description, og.image, og.url, og.type,
1978/// twitter.card, twitter.title, twitter.description, twitter.image, canonical, robots.
1979fn inject_seo_meta(
1980    html: &str,
1981    custom: &HashMap<String, String>,
1982    vars: &HashMap<String, Value>,
1983) -> String {
1984    // Helper: look up a string value from custom (legacy) or vars (inline)
1985    let get = |key: &str| -> Option<String> {
1986        custom
1987            .get(key)
1988            .cloned()
1989            .or_else(|| vars.get(key).and_then(|v| v.as_str().map(String::from)))
1990    };
1991
1992    let mut tags = Vec::new();
1993
1994    // Standard meta tags
1995    if let Some(desc) = get("description") {
1996        tags.push(format!(
1997            r#"<meta name="description" content="{}">"#,
1998            html_escape(&desc)
1999        ));
2000    }
2001    if let Some(robots) = get("robots") {
2002        tags.push(format!(
2003            r#"<meta name="robots" content="{}">"#,
2004            html_escape(&robots)
2005        ));
2006    }
2007
2008    // Open Graph tags
2009    for (key, prop) in [
2010        ("og.title", "og:title"),
2011        ("og.description", "og:description"),
2012        ("og.image", "og:image"),
2013        ("og.url", "og:url"),
2014        ("og.type", "og:type"),
2015    ] {
2016        if let Some(val) = get(key) {
2017            tags.push(format!(
2018                r#"<meta property="{}" content="{}">"#,
2019                prop,
2020                html_escape(&val)
2021            ));
2022        }
2023    }
2024
2025    // Twitter Card tags
2026    for (key, name) in [
2027        ("twitter.card", "twitter:card"),
2028        ("twitter.title", "twitter:title"),
2029        ("twitter.description", "twitter:description"),
2030        ("twitter.image", "twitter:image"),
2031        ("twitter.creator", "twitter:creator"),
2032    ] {
2033        if let Some(val) = get(key) {
2034            tags.push(format!(
2035                r#"<meta name="{}" content="{}">"#,
2036                name,
2037                html_escape(&val)
2038            ));
2039        }
2040    }
2041
2042    // Canonical link
2043    if let Some(canonical) = get("canonical") {
2044        tags.push(format!(
2045            r#"<link rel="canonical" href="{}">"#,
2046            html_escape(&canonical)
2047        ));
2048    }
2049
2050    if tags.is_empty() {
2051        return html.to_string();
2052    }
2053
2054    let meta_block = tags.join("\n");
2055    inject_before_head_close(html, &meta_block)
2056}
2057
2058/// Inject `<meta name="what-debug">` in dev mode for client-side debug levels.
2059fn inject_debug_meta(html: &str, log_level: &str) -> String {
2060    let meta = format!(r#"<meta name="what-debug" content="{}">"#, log_level);
2061    inject_before_head_close(html, &meta)
2062}
2063
2064/// Helper: inject content before `</head>` (case-insensitive).
2065fn inject_before_head_close(html: &str, content: &str) -> String {
2066    if let Some(pos) = html.find("</head>") {
2067        format!("{}{}\n{}", &html[..pos], content, &html[pos..])
2068    } else if let Some(pos) = html.find("</HEAD>") {
2069        format!("{}{}\n{}", &html[..pos], content, &html[pos..])
2070    } else {
2071        // No <head> section — prepend
2072        format!("{}\n{}", content, html)
2073    }
2074}
2075
2076/// Escape HTML attribute values.
2077fn html_escape(s: &str) -> String {
2078    s.replace('&', "&amp;")
2079        .replace('"', "&quot;")
2080        .replace('<', "&lt;")
2081        .replace('>', "&gt;")
2082}
2083
2084/// Validate a CSRF token from form data or header against the session token.
2085/// Returns true if the token matches.
2086fn validate_csrf_token(
2087    session: Option<&sessions::Session>,
2088    form_token: Option<&str>,
2089    header_token: Option<&str>,
2090) -> bool {
2091    let session_token = session
2092        .and_then(|s| s.data.get(CSRF_SESSION_KEY))
2093        .and_then(|v| v.as_str());
2094
2095    let session_token = match session_token {
2096        Some(t) => t,
2097        None => return false, // No session token — deny
2098    };
2099
2100    // Check form field first, then header
2101    let submitted_token = form_token.or(header_token);
2102
2103    match submitted_token {
2104        Some(t) => t == session_token,
2105        None => false,
2106    }
2107}
2108
2109/// Paths that are excluded from CSRF validation
2110fn is_csrf_exempt(path: &str) -> bool {
2111    // WebSocket upgrade paths and dev-only endpoints
2112    path.starts_with("/w-livereload") || path.starts_with("/w-wire")
2113}
2114
2115/// Result of resolving a URL path to a page file
2116struct ResolvedPage {
2117    path: PathBuf,
2118    /// Dynamic route parameters extracted from the URL (e.g., "id" -> "123")
2119    params: HashMap<String, String>,
2120}
2121
2122/// Resolve a URL path to a page file
2123fn resolve_page_path(root: &PathBuf, url_path: &str) -> Option<ResolvedPage> {
2124    let pages_dir = content_dir(root);
2125    let clean_path = url_path.trim_start_matches('/');
2126
2127    // Try exact match: /about -> site/about.html
2128    let exact = pages_dir.join(format!("{}.html", clean_path));
2129    if exact.exists() {
2130        return Some(ResolvedPage {
2131            path: exact,
2132            params: HashMap::new(),
2133        });
2134    }
2135
2136    // Try index: /about -> site/about/index.html
2137    let index = pages_dir.join(clean_path).join("index.html");
2138    if index.exists() {
2139        return Some(ResolvedPage {
2140            path: index,
2141            params: HashMap::new(),
2142        });
2143    }
2144
2145    // Try root index: / -> site/index.html
2146    if clean_path.is_empty() || clean_path == "/" {
2147        let root_index = pages_dir.join("index.html");
2148        if root_index.exists() {
2149            return Some(ResolvedPage {
2150                path: root_index,
2151                params: HashMap::new(),
2152            });
2153        }
2154    }
2155
2156    // Try dynamic route: /post/123 -> site/post/[id].html
2157    let parts: Vec<&str> = clean_path.split('/').collect();
2158    if parts.len() >= 2 {
2159        let parent = parts[..parts.len() - 1].join("/");
2160        let dynamic_value = parts[parts.len() - 1];
2161        let dynamic = pages_dir.join(&parent).join("[id].html");
2162        if dynamic.exists() {
2163            let mut params = HashMap::new();
2164            params.insert("id".to_string(), dynamic_value.to_string());
2165            return Some(ResolvedPage {
2166                path: dynamic,
2167                params,
2168            });
2169        }
2170    }
2171
2172    None
2173}
2174
2175/// Load all application.what files from root to the page's directory
2176/// Returns merged config with child directories overriding parent
2177fn collect_application_config_paths(root: &PathBuf, url_path: &str) -> Vec<PathBuf> {
2178    let pages_dir = content_dir(root);
2179    let clean_path = url_path.trim_start_matches('/');
2180
2181    // Collect all directories from root to the page's directory
2182    let mut dirs_to_check = vec![pages_dir.clone()];
2183
2184    if !clean_path.is_empty() {
2185        let parts: Vec<&str> = clean_path.split('/').collect();
2186        let mut current = pages_dir.clone();
2187
2188        // For "/admin/users", check site/, site/admin/
2189        // (not pages/admin/users/ since that's a file, not directory)
2190        for part in &parts[..parts.len().saturating_sub(1)] {
2191            current = current.join(part);
2192            if current.is_dir() {
2193                dirs_to_check.push(current.clone());
2194            }
2195        }
2196
2197        // Also check if the last part is a directory (for index.html case)
2198        let last_dir = pages_dir.join(clean_path);
2199        if last_dir.is_dir() {
2200            dirs_to_check.push(last_dir);
2201        }
2202    }
2203
2204    dirs_to_check
2205        .into_iter()
2206        .map(|dir| dir.join("application.what"))
2207        .filter(|path| path.exists())
2208        .collect()
2209}
2210
2211fn load_application_config(root: &PathBuf, url_path: &str) -> WhatConfig {
2212    let mut merged = WhatConfig::default();
2213
2214    for config_path in collect_application_config_paths(root, url_path) {
2215        if let Ok(content) = std::fs::read_to_string(&config_path) {
2216            let config = parse_what_file(&content);
2217            merged.merge(&config);
2218            tracing::debug!("Loaded application.what from {:?}", config_path);
2219        }
2220    }
2221
2222    merged
2223}
2224
2225fn push_source_file(
2226    path: PathBuf,
2227    project_root_canonical: &std::path::Path,
2228    seen_paths: &mut std::collections::HashSet<PathBuf>,
2229    files: &mut Vec<Value>,
2230    scan_queue: &mut Vec<(PathBuf, String)>,
2231) -> bool {
2232    let canonical_path = match path.canonicalize() {
2233        Ok(p) => p,
2234        Err(_) => return false,
2235    };
2236    if !canonical_path.starts_with(project_root_canonical)
2237        || !seen_paths.insert(canonical_path.clone())
2238    {
2239        return false;
2240    }
2241    let content = match std::fs::read_to_string(&canonical_path) {
2242        Ok(c) => c,
2243        Err(_) => return false,
2244    };
2245    let label = canonical_path
2246        .strip_prefix(project_root_canonical)
2247        .unwrap_or(&canonical_path)
2248        .display()
2249        .to_string();
2250    files.push(json!({ "label": label, "content": content }));
2251    scan_queue.push((canonical_path, content));
2252    true
2253}
2254
2255// ---------------------------------------------------------------------------
2256// Enhanced Fetch Directives
2257// ---------------------------------------------------------------------------
2258
2259/// Structured fetch directive with method, headers, body, and path extraction
2260#[derive(Clone, Debug)]
2261#[allow(dead_code)]
2262struct FetchDirective {
2263    key: String,
2264    url: String,
2265    method: String,
2266    headers: Vec<(String, String)>,
2267    body: Option<String>,
2268    /// Dot-notation path to extract from response (e.g., "data.orders")
2269    path: Option<String>,
2270    /// Sort expression for local queries: "field:asc" or "field:desc"
2271    sort: Option<String>,
2272    /// Filter expression for local queries: "field=value", "field>N"
2273    filter: Option<String>,
2274    /// Full-text search term for local queries
2275    search: Option<String>,
2276    /// Fields to search in (comma-separated)
2277    search_fields: Option<String>,
2278    /// Max items to return
2279    limit: Option<usize>,
2280    /// Items to skip
2281    offset: Option<usize>,
2282}
2283
2284impl FetchDirective {
2285    #[allow(dead_code)]
2286    fn simple(key: String, url: String) -> Self {
2287        Self {
2288            key,
2289            url,
2290            method: "GET".to_string(),
2291            headers: Vec::new(),
2292            body: None,
2293            path: None,
2294            sort: None,
2295            filter: None,
2296            search: None,
2297            search_fields: None,
2298            limit: None,
2299            offset: None,
2300        }
2301    }
2302
2303    /// Whether this is a local database query (url starts with "local:")
2304    fn is_local(&self) -> bool {
2305        self.url.starts_with("local:")
2306    }
2307
2308    /// Extract the collection name from a local: URL, without any `?query` string.
2309    /// `local:posts?sort=created_at:desc` -> `posts`
2310    fn local_collection(&self) -> Option<&str> {
2311        self.url
2312            .strip_prefix("local:")
2313            .map(|rest| rest.split('?').next().unwrap_or(rest))
2314    }
2315
2316    /// Extract the raw query string from a local: URL (the part after `?`), if any.
2317    /// `local:posts?sort=created_at:desc&limit=10` -> `sort=created_at:desc&limit=10`
2318    fn local_query(&self) -> Option<&str> {
2319        self.url
2320            .strip_prefix("local:")
2321            .and_then(|rest| rest.split_once('?').map(|(_, q)| q))
2322    }
2323}
2324
2325/// Extract a nested value from JSON using dot-notation path (e.g., "data.items")
2326fn extract_json_path(value: &Value, path: &str) -> Value {
2327    let mut current = value;
2328    for part in path.split('.') {
2329        match current {
2330            Value::Object(obj) => {
2331                if let Some(v) = obj.get(part) {
2332                    current = v;
2333                } else {
2334                    return Value::Null;
2335                }
2336            }
2337            Value::Array(arr) => {
2338                if let Ok(idx) = part.parse::<usize>() {
2339                    if let Some(v) = arr.get(idx) {
2340                        current = v;
2341                    } else {
2342                        return Value::Null;
2343                    }
2344                } else {
2345                    return Value::Null;
2346                }
2347            }
2348            _ => return Value::Null,
2349        }
2350    }
2351    current.clone()
2352}
2353
2354/// Parse a comma-separated header string into key-value pairs
2355/// Format: "Authorization: Bearer token, Accept: application/json"
2356fn parse_header_string(s: &str) -> Vec<(String, String)> {
2357    let mut result = Vec::new();
2358    for part in s.split(',') {
2359        let part = part.trim();
2360        if let Some(colon) = part.find(':') {
2361            let key = part[..colon].trim().to_string();
2362            let value = part[colon + 1..].trim().to_string();
2363            result.push((key, value));
2364        }
2365    }
2366    result
2367}
2368
2369/// Debug info for a single remote fetch (used in X-What-Debug header)
2370#[derive(Clone, Serialize)]
2371pub struct FetchDebugEntry {
2372    pub key: String,
2373    pub url: String,
2374    pub elapsed_ms: u64,
2375    pub result: String,
2376}
2377
2378/// Result of rendering content
2379pub struct RenderResult {
2380    /// The rendered HTML
2381    pub html: String,
2382    /// Session keys that were used (for OOB updates)
2383    pub session_keys: std::collections::HashSet<String>,
2384    /// Debug info for remote fetches (populated in dev mode)
2385    pub fetch_debug: Vec<FetchDebugEntry>,
2386}
2387
2388/// Resolve environment variable references in config values.
2389/// Supports `${VAR_NAME}` syntax for environment variable substitution.
2390/// Returns an error if an env var reference is used but the variable is not set.
2391fn resolve_env_value(value: &str) -> crate::Result<String> {
2392    if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
2393        std::env::var(var_name).map_err(|_| {
2394            crate::Error::Config(format!("Environment variable {} is not set", var_name))
2395        })
2396    } else {
2397        Ok(value.to_string())
2398    }
2399}
2400
2401/// Email trigger data extracted from form fields.
2402struct EmailTrigger {
2403    to: String,
2404    subject: String,
2405    template: Option<String>,
2406}
2407
2408/// Extract `w-email-*` fields from form data.
2409/// Returns `Some(EmailTrigger)` if `w-email-to` is present.
2410fn extract_email_trigger(form_data: &HashMap<String, String>) -> Option<EmailTrigger> {
2411    let to = form_data.get("w-email-to")?;
2412    if to.is_empty() {
2413        return None;
2414    }
2415    Some(EmailTrigger {
2416        to: to.clone(),
2417        subject: form_data
2418            .get("w-email-subject")
2419            .cloned()
2420            .unwrap_or_else(|| "Notification".to_string()),
2421        template: form_data.get("w-email-template").cloned(),
2422    })
2423}
2424
2425/// Build and enqueue a SendEmail job if an email trigger was present.
2426async fn maybe_enqueue_email(state: &AppState, trigger: Option<EmailTrigger>) {
2427    let trigger = match trigger {
2428        Some(t) => t,
2429        None => return,
2430    };
2431
2432    if state.config.email.is_none() {
2433        tracing::warn!("Form has w-email-to but no [email] config in what.toml — skipping");
2434        return;
2435    }
2436
2437    // Build context from form data (all non w- fields are available in the template)
2438    let html_body = if let Some(ref tpl_name) = trigger.template {
2439        let email_config = state.config.email.as_ref().unwrap();
2440        // For template rendering, pass an empty context — the template itself uses #variable# syntax
2441        // In a future version, the form data could be injected here.
2442        match crate::email::render_email_template(
2443            &state.root,
2444            &email_config.template_dir,
2445            tpl_name,
2446            &HashMap::new(),
2447        ) {
2448            Ok(html) => html,
2449            Err(e) => {
2450                tracing::error!("Failed to render email template '{}': {}", tpl_name, e);
2451                return;
2452            }
2453        }
2454    } else {
2455        // No template — use a simple subject-only email
2456        format!("<p>{}</p>", trigger.subject)
2457    };
2458
2459    let message = crate::email::EmailMessage {
2460        to: trigger.to,
2461        subject: trigger.subject,
2462        html_body,
2463        text_body: None,
2464    };
2465
2466    let _ = state
2467        .jobs
2468        .enqueue(crate::jobs::Job::SendEmail { message })
2469        .await;
2470}
2471
2472const DEFAULT_DATA_CACHE_TTL_SECS: u64 = 300;
2473
2474fn data_source_cache_ttl(source: &DataSource) -> u64 {
2475    match source {
2476        DataSource::Url { cache, .. } => *cache,
2477        DataSource::File { cache, .. } => *cache,
2478        DataSource::SimplePath(_) => DEFAULT_DATA_CACHE_TTL_SECS,
2479    }
2480}
2481
2482fn resolve_data_source_path(root: &PathBuf, path: &str) -> PathBuf {
2483    let candidate = PathBuf::from(path);
2484    if candidate.is_absolute() {
2485        candidate
2486    } else {
2487        root.join(candidate)
2488    }
2489}
2490
2491async fn store_data_value(state: &AppState, name: &str, value: Value) -> Result<()> {
2492    match value {
2493        Value::Array(items) => state.store.set_collection(name, items).await,
2494        other => state.store.set(name, other).await,
2495    }
2496}
2497
2498/// Sanitize a file extension: strip path separators, null bytes, and non-ASCII.
2499/// Returns the extension with a leading dot, or empty string if nothing remains.
2500/// Verify a Cloudflare Turnstile token server-side.
2501/// Returns Ok(()) if verification passes or Turnstile is not configured.
2502/// Returns Err(message) if verification fails.
2503async fn verify_turnstile(
2504    state: &AppState,
2505    token: Option<&str>,
2506) -> std::result::Result<(), String> {
2507    let secret = match state
2508        .config
2509        .cloudflare
2510        .as_ref()
2511        .and_then(|cf| cf.turnstile_secret_key.as_ref())
2512    {
2513        Some(s) => resolve_env_value(s).map_err(|e| e.to_string())?,
2514        None => return Ok(()), // Turnstile not configured — skip verification
2515    };
2516
2517    let token = match token {
2518        Some(t) if !t.is_empty() => t,
2519        _ => return Err("Turnstile verification required".to_string()),
2520    };
2521
2522    let resp = state
2523        .http_client
2524        .post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
2525        .form(&[("secret", secret.as_str()), ("response", token)])
2526        .send()
2527        .await
2528        .map_err(|_| "Turnstile verification failed: network error".to_string())?;
2529
2530    let body: serde_json::Value = resp
2531        .json()
2532        .await
2533        .map_err(|_| "Turnstile verification failed: invalid response".to_string())?;
2534
2535    if body
2536        .get("success")
2537        .and_then(|v| v.as_bool())
2538        .unwrap_or(false)
2539    {
2540        Ok(())
2541    } else {
2542        let codes = body
2543            .get("error-codes")
2544            .and_then(|v| v.as_array())
2545            .map(|arr| {
2546                arr.iter()
2547                    .filter_map(|v| v.as_str())
2548                    .collect::<Vec<_>>()
2549                    .join(", ")
2550            })
2551            .unwrap_or_else(|| "unknown".to_string());
2552        Err(format!("Turnstile verification failed: {}", codes))
2553    }
2554}
2555
2556fn sanitize_extension(ext: &str) -> String {
2557    let clean: String = ext
2558        .chars()
2559        .filter(|c| c.is_ascii_alphanumeric() || *c == '.')
2560        .collect();
2561    if clean.is_empty() {
2562        String::new()
2563    } else {
2564        format!(".{}", clean)
2565    }
2566}
2567
2568/// Describe a JSON value for logging (type + count, not the data itself)
2569fn describe_json(value: &Value) -> String {
2570    match value {
2571        Value::Array(arr) => format!("array[{}]", arr.len()),
2572        Value::Object(obj) => {
2573            let summaries: Vec<String> = obj
2574                .iter()
2575                .take(5)
2576                .map(|(k, v)| {
2577                    let v_desc = match v {
2578                        Value::Array(a) => format!("array[{}]", a.len()),
2579                        Value::Object(o) => format!("object{{{}}}", o.len()),
2580                        Value::String(s) => format!("str({})", s.len()),
2581                        Value::Number(_) => "num".to_string(),
2582                        Value::Bool(_) => "bool".to_string(),
2583                        Value::Null => "null".to_string(),
2584                    };
2585                    format!("{}: {}", k, v_desc)
2586                })
2587                .collect();
2588            if obj.len() > 5 {
2589                format!("object{{{}... +{}}}", summaries.join(", "), obj.len() - 5)
2590            } else {
2591                format!("object{{{}}}", summaries.join(", "))
2592            }
2593        }
2594        Value::String(s) => format!("string({}b)", s.len()),
2595        Value::Number(n) => format!("number({})", n),
2596        Value::Bool(b) => format!("bool({})", b),
2597        Value::Null => "null".to_string(),
2598    }
2599}
2600
2601async fn fetch_remote_json(state: &AppState, url: &str, use_cache: bool) -> Result<Value> {
2602    if use_cache && state.config.cache.enabled {
2603        if let Some(cached) = state.cache.get_api(url).await {
2604            tracing::info!("  cache hit: {}", url);
2605            return Ok(serde_json::from_str(&cached)?);
2606        }
2607    }
2608
2609    let start = std::time::Instant::now();
2610
2611    let response = state.http_client.get(url).send().await.map_err(|e| {
2612        tracing::warn!("  fetch failed ({}): {}", start.elapsed().as_millis(), e);
2613        crate::Error::Data(format!("HTTP request failed: {}", e))
2614    })?;
2615    let status = response.status();
2616    let body = response.text().await?;
2617    let elapsed = start.elapsed();
2618
2619    if !status.is_success() {
2620        tracing::warn!("  {} -> {} {}ms", url, status, elapsed.as_millis());
2621        return Err(crate::Error::Data(format!(
2622            "Remote fetch failed ({}): {}",
2623            status, url
2624        )));
2625    }
2626
2627    let value: Value = serde_json::from_str(&body)?;
2628    tracing::info!(
2629        "  {} -> {} {}ms {}",
2630        url,
2631        status,
2632        elapsed.as_millis(),
2633        describe_json(&value)
2634    );
2635
2636    if use_cache && state.config.cache.enabled {
2637        state.cache.set_api(url, body).await;
2638    }
2639
2640    Ok(value)
2641}
2642
2643async fn load_data_source(state: &AppState, name: &str, source: &DataSource) -> Result<()> {
2644    let value = match source {
2645        DataSource::File { file, .. } => {
2646            let path = resolve_data_source_path(&state.root, file);
2647            let content = tokio::fs::read_to_string(&path).await?;
2648            serde_json::from_str(&content)?
2649        }
2650        DataSource::SimplePath(path) => {
2651            let full_path = resolve_data_source_path(&state.root, path);
2652            let content = tokio::fs::read_to_string(&full_path).await?;
2653            serde_json::from_str(&content)?
2654        }
2655        DataSource::Url { url, .. } => fetch_remote_json(state, url, true).await?,
2656    };
2657
2658    store_data_value(state, name, value).await
2659}
2660
2661async fn hydrate_config_data_sources(state: &AppState) -> Result<()> {
2662    if state.config.data.is_empty() {
2663        return Ok(());
2664    }
2665
2666    let mut to_refresh: Vec<(String, DataSource)> = Vec::new();
2667    let use_cache = state.config.cache.enabled;
2668
2669    {
2670        let last_loaded = state.data_source_loaded.read().await;
2671        for (name, source) in &state.config.data {
2672            let ttl = if use_cache {
2673                data_source_cache_ttl(source)
2674            } else {
2675                0
2676            };
2677            let should_refresh = if ttl == 0 {
2678                true
2679            } else {
2680                last_loaded
2681                    .get(name)
2682                    .map(|t| t.elapsed() >= Duration::from_secs(ttl))
2683                    .unwrap_or(true)
2684            };
2685
2686            if should_refresh {
2687                to_refresh.push((name.clone(), source.clone()));
2688            }
2689        }
2690    }
2691
2692    if to_refresh.is_empty() {
2693        return Ok(());
2694    }
2695
2696    for (name, source) in to_refresh {
2697        match load_data_source(state, &name, &source).await {
2698            Ok(()) => {
2699                let mut last_loaded = state.data_source_loaded.write().await;
2700                last_loaded.insert(name, Instant::now());
2701            }
2702            Err(e) => {
2703                tracing::warn!("Failed to load data source {}: {}", name, e);
2704            }
2705        }
2706    }
2707
2708    Ok(())
2709}
2710
2711/// Collect fetch directives from app config and page directives.
2712/// Supports both simple `fetch.key = "url"` and enhanced sub-properties like
2713/// `fetch.key.method = "POST"`, `fetch.key.headers = "Authorization: Bearer x"`, etc.
2714fn collect_fetch_directives(
2715    app_config: Option<&WhatConfig>,
2716    page_directives: Option<&PageDirectives>,
2717) -> HashMap<String, FetchDirective> {
2718    let mut all_entries: HashMap<String, HashMap<String, String>> = HashMap::new();
2719
2720    // Collect raw key-value pairs from both sources
2721    let mut raw_pairs: Vec<(String, String)> = Vec::new();
2722
2723    if let Some(config) = app_config {
2724        for (key, value) in &config.values {
2725            if let Some(rest) = key.strip_prefix("fetch.") {
2726                if let Some(url) = value.as_str() {
2727                    raw_pairs.push((rest.to_string(), url.to_string()));
2728                }
2729            }
2730        }
2731    }
2732
2733    if let Some(directives) = page_directives {
2734        for (key, value) in &directives.custom {
2735            if let Some(rest) = key.strip_prefix("fetch.") {
2736                if !value.is_empty() {
2737                    raw_pairs.push((rest.to_string(), value.to_string()));
2738                }
2739            }
2740        }
2741    }
2742
2743    // Parse into structured directives
2744    for (rest, value) in raw_pairs {
2745        // Check if this is a sub-property: "key.property" vs simple "key"
2746        if let Some(dot_pos) = rest.find('.') {
2747            let fetch_key = rest[..dot_pos].to_string();
2748            let property = rest[dot_pos + 1..].to_string();
2749            all_entries
2750                .entry(fetch_key)
2751                .or_default()
2752                .insert(property, value);
2753        } else {
2754            // Simple: fetch.key = "url"
2755            all_entries
2756                .entry(rest)
2757                .or_default()
2758                .insert("url".to_string(), value);
2759        }
2760    }
2761
2762    // Build FetchDirective from collected entries
2763    let mut result = HashMap::new();
2764    for (key, props) in all_entries {
2765        let url = match props.get("url") {
2766            Some(u) => u.clone(),
2767            None => continue, // No URL, skip
2768        };
2769        let method = props
2770            .get("method")
2771            .cloned()
2772            .unwrap_or_else(|| "GET".to_string())
2773            .to_uppercase();
2774        let headers = props
2775            .get("headers")
2776            .map(|h| parse_header_string(h))
2777            .unwrap_or_default();
2778        let body = props.get("body").cloned();
2779        let path = props.get("path").cloned();
2780        if props.contains_key("poll") {
2781            tracing::warn!(
2782                "fetch.{}.poll was removed in v1.3 — move the markup into a partial and wrap it in <what-fetch url=\"/w-partial/...\" poll=\"...\"> instead",
2783                key
2784            );
2785        }
2786        let sort = props.get("sort").cloned();
2787        let filter = props.get("filter").cloned();
2788        let search = props.get("search").cloned();
2789        let search_fields = props.get("search_fields").cloned();
2790        let limit = props.get("limit").and_then(|l| l.parse().ok());
2791        let offset = props.get("offset").and_then(|o| o.parse().ok());
2792
2793        result.insert(
2794            key.clone(),
2795            FetchDirective {
2796                key,
2797                url,
2798                method,
2799                headers,
2800                body,
2801                path,
2802                sort,
2803                filter,
2804                search,
2805                search_fields,
2806                limit,
2807                offset,
2808            },
2809        );
2810    }
2811
2812    result
2813}
2814
2815/// Execute a fetch with custom method, headers, and body
2816async fn fetch_enhanced(
2817    state: &AppState,
2818    directive: &FetchDirective,
2819    resolved_url: &str,
2820) -> Result<Value> {
2821    let start = std::time::Instant::now();
2822
2823    let method = match directive.method.as_str() {
2824        "POST" => reqwest::Method::POST,
2825        "PUT" => reqwest::Method::PUT,
2826        "DELETE" => reqwest::Method::DELETE,
2827        "PATCH" => reqwest::Method::PATCH,
2828        _ => reqwest::Method::GET,
2829    };
2830
2831    let mut request = state.http_client.request(method, resolved_url);
2832
2833    // Add custom headers
2834    for (key, value) in &directive.headers {
2835        request = request.header(key.as_str(), value.as_str());
2836    }
2837
2838    // Add body if present
2839    if let Some(ref body) = directive.body {
2840        request = request.body(body.clone());
2841        // Set content-type if not already in headers
2842        if !directive
2843            .headers
2844            .iter()
2845            .any(|(k, _)| k.to_lowercase() == "content-type")
2846        {
2847            request = request.header("Content-Type", "application/json");
2848        }
2849    }
2850
2851    let response = request
2852        .send()
2853        .await
2854        .map_err(|e| crate::Error::Data(format!("HTTP request failed: {}", e)))?;
2855
2856    let status = response.status();
2857    let body_text = response.text().await?;
2858    let elapsed = start.elapsed();
2859
2860    if !status.is_success() {
2861        tracing::warn!("  {} -> {} {}ms", resolved_url, status, elapsed.as_millis());
2862        return Err(crate::Error::Data(format!(
2863            "Remote fetch failed ({}): {}",
2864            status, resolved_url
2865        )));
2866    }
2867
2868    let mut value: Value = serde_json::from_str(&body_text)?;
2869    tracing::info!(
2870        "  {} -> {} {}ms {}",
2871        resolved_url,
2872        status,
2873        elapsed.as_millis(),
2874        describe_json(&value)
2875    );
2876
2877    // Apply path extraction if specified
2878    if let Some(ref path) = directive.path {
2879        value = extract_json_path(&value, path);
2880    }
2881
2882    Ok(value)
2883}
2884
2885async fn apply_fetch_directives(
2886    state: &AppState,
2887    context: &mut HashMap<String, Value>,
2888    app_config: Option<&WhatConfig>,
2889    page_directives: Option<&PageDirectives>,
2890    actor: &crate::policy::Actor,
2891) -> Vec<FetchDebugEntry> {
2892    let fetches = collect_fetch_directives(app_config, page_directives);
2893    if fetches.is_empty() {
2894        return Vec::new();
2895    }
2896
2897    let total_start = std::time::Instant::now();
2898
2899    // Separate local, dsn, and remote fetches
2900    let mut local_fetches = Vec::new();
2901    let mut dsn_fetches = Vec::new();
2902    let mut remote_fetches = Vec::new();
2903    for (key, directive) in fetches {
2904        if directive.is_local() {
2905            local_fetches.push((key, directive));
2906        } else if directive.url.starts_with("dsn:") {
2907            dsn_fetches.push((key, directive));
2908        } else {
2909            remote_fetches.push((key, directive));
2910        }
2911    }
2912
2913    if !remote_fetches.is_empty() {
2914        tracing::info!("Fetching {} remote source(s):", remote_fetches.len());
2915    }
2916    if !local_fetches.is_empty() {
2917        tracing::info!("Querying {} local collection(s):", local_fetches.len());
2918    }
2919    if !dsn_fetches.is_empty() {
2920        tracing::info!("Querying {} datasource(s):", dsn_fetches.len());
2921    }
2922
2923    let mut fetch_errors = serde_json::Map::new();
2924    let mut debug_entries = Vec::new();
2925
2926    // Process local fetches (database queries)
2927    for (key, directive) in local_fetches {
2928        let start = std::time::Instant::now();
2929        if let Some(collection) = directive.local_collection() {
2930            // Query parameters can be supplied two ways, and the sub-directive wins:
2931            //   1. sub-directives:  fetch.x.sort = "...", fetch.x.filter = "...", etc.
2932            //   2. URL query string: fetch.x = "local:coll?sort=...&filter=...&limit=10"
2933            let (mut q_sort, mut q_filter, mut q_search, mut q_limit, mut q_offset) =
2934                (None, None, None, None, None);
2935            if let Some(qs) = directive.local_query() {
2936                for pair in qs.split('&') {
2937                    // split_once('=') so filter values may themselves contain '='
2938                    if let Some((k, v)) = pair.split_once('=') {
2939                        match k {
2940                            "sort" => q_sort = Some(v.to_string()),
2941                            "filter" => q_filter = Some(v.to_string()),
2942                            "search" => q_search = Some(v.to_string()),
2943                            "limit" => q_limit = v.parse::<usize>().ok(),
2944                            "offset" => q_offset = v.parse::<usize>().ok(),
2945                            _ => {}
2946                        }
2947                    }
2948                }
2949            }
2950
2951            // Resolve template variables in query parameters
2952            let sort = directive
2953                .sort
2954                .clone()
2955                .or(q_sort)
2956                .map(|s| crate::parser::replace_variables(&s, context));
2957            let filter = directive
2958                .filter
2959                .clone()
2960                .or(q_filter)
2961                .map(|s| crate::parser::replace_variables(&s, context));
2962            let search = directive
2963                .search
2964                .clone()
2965                .or(q_search)
2966                .map(|s| crate::parser::replace_variables(&s, context));
2967            let search_fields = directive.search_fields.clone();
2968            let offset = directive.offset.or(q_offset);
2969            let limit = directive.limit.or(q_limit);
2970
2971            // Apply the collection's read policy: deny (empty), scope (forced
2972            // filters), or allow.
2973            let policy = state.policies.get(collection);
2974            let forced_filters = match policy.read_scope(actor, context) {
2975                crate::policy::ReadScope::Deny => {
2976                    tracing::debug!(target: "what::policy", "read denied for '{}' (no matching identity)", collection);
2977                    debug_entries.push(FetchDebugEntry {
2978                        key: key.clone(),
2979                        url: format!("local:{}", collection),
2980                        elapsed_ms: start.elapsed().as_millis() as u64,
2981                        result: "0 items (policy: denied)".to_string(),
2982                    });
2983                    context.insert(key, json!([]));
2984                    continue;
2985                }
2986                crate::policy::ReadScope::All => Vec::new(),
2987                crate::policy::ReadScope::Filters(f) => f,
2988            };
2989            let scoped = !forced_filters.is_empty();
2990
2991            let query = crate::database::CollectionQuery {
2992                sort,
2993                filter,
2994                search,
2995                search_fields,
2996                limit,
2997                offset,
2998                forced_filters,
2999            };
3000
3001            let has_query = query.sort.is_some()
3002                || query.filter.is_some()
3003                || query.search.is_some()
3004                || query.limit.is_some()
3005                || query.offset.is_some()
3006                || !query.forced_filters.is_empty();
3007
3008            let value = if has_query {
3009                state.store.query_collection(collection, &query).await
3010            } else {
3011                state.store.get_collection(collection).await
3012            };
3013
3014            let elapsed_ms = start.elapsed().as_millis() as u64;
3015            match value {
3016                Some(mut items) => {
3017                    let count = items.len();
3018                    let mut items_val = json!(items.drain(..).collect::<Vec<_>>());
3019                    crate::policy::strip_private_fields(&mut items_val, &policy.private_fields);
3020                    debug_entries.push(FetchDebugEntry {
3021                        key: key.clone(),
3022                        url: format!("local:{}", collection),
3023                        elapsed_ms,
3024                        result: if scoped {
3025                            format!("{} items (policy-scoped)", count)
3026                        } else {
3027                            format!("{} items", count)
3028                        },
3029                    });
3030                    context.insert(key, items_val);
3031                }
3032                None => {
3033                    debug_entries.push(FetchDebugEntry {
3034                        key: key.clone(),
3035                        url: format!("local:{}", collection),
3036                        elapsed_ms,
3037                        result: "empty collection".to_string(),
3038                    });
3039                    context.insert(key, json!([]));
3040                }
3041            }
3042        }
3043    }
3044
3045    // Process DSN fetches (named datasources)
3046    for (key, directive) in dsn_fetches {
3047        let start = std::time::Instant::now();
3048        let resolved_url = crate::parser::replace_variables(&directive.url, context);
3049
3050        if let Some((ds_name, target)) = crate::datasource::parse_dsn(&resolved_url) {
3051            if let Some(datasource) = state.datasources.get(ds_name) {
3052                match (datasource, &target) {
3053                    // DB datasource + collection target
3054                    (
3055                        crate::datasource::Datasource::Database(adapter),
3056                        crate::datasource::DsnTarget::Collection(collection),
3057                    ) => {
3058                        let sort = directive
3059                            .sort
3060                            .as_ref()
3061                            .map(|s| crate::parser::replace_variables(s, context));
3062                        let filter = directive
3063                            .filter
3064                            .as_ref()
3065                            .map(|s| crate::parser::replace_variables(s, context));
3066                        let search = directive
3067                            .search
3068                            .as_ref()
3069                            .map(|s| crate::parser::replace_variables(s, context));
3070                        let search_fields = directive.search_fields.clone();
3071
3072                        // Policies apply by collection name across backends.
3073                        let policy = state.policies.get(collection);
3074                        let forced_filters = match policy.read_scope(actor, context) {
3075                            crate::policy::ReadScope::Deny => {
3076                                debug_entries.push(FetchDebugEntry {
3077                                    key: key.clone(),
3078                                    url: resolved_url.clone(),
3079                                    elapsed_ms: start.elapsed().as_millis() as u64,
3080                                    result: "0 items (policy: denied)".to_string(),
3081                                });
3082                                context.insert(key, json!([]));
3083                                continue;
3084                            }
3085                            crate::policy::ReadScope::All => Vec::new(),
3086                            crate::policy::ReadScope::Filters(f) => f,
3087                        };
3088                        let scoped = !forced_filters.is_empty();
3089
3090                        let query = crate::database::CollectionQuery {
3091                            sort,
3092                            filter,
3093                            search,
3094                            search_fields,
3095                            limit: directive.limit,
3096                            offset: directive.offset,
3097                            forced_filters,
3098                        };
3099
3100                        let has_query = query.sort.is_some()
3101                            || query.filter.is_some()
3102                            || query.search.is_some()
3103                            || query.limit.is_some()
3104                            || query.offset.is_some()
3105                            || !query.forced_filters.is_empty();
3106
3107                        let value = if has_query {
3108                            adapter.query_collection(collection, &query).await
3109                        } else {
3110                            adapter.get_collection(collection).await
3111                        };
3112
3113                        let elapsed_ms = start.elapsed().as_millis() as u64;
3114                        match value {
3115                            Some(mut items) => {
3116                                let count = items.len();
3117                                let mut items_val = json!(items.drain(..).collect::<Vec<_>>());
3118                                crate::policy::strip_private_fields(&mut items_val, &policy.private_fields);
3119                                debug_entries.push(FetchDebugEntry {
3120                                    key: key.clone(),
3121                                    url: resolved_url.clone(),
3122                                    elapsed_ms,
3123                                    result: if scoped {
3124                                        format!("{} items (policy-scoped)", count)
3125                                    } else {
3126                                        format!("{} items", count)
3127                                    },
3128                                });
3129                                context.insert(key, items_val);
3130                            }
3131                            None => {
3132                                debug_entries.push(FetchDebugEntry {
3133                                    key: key.clone(),
3134                                    url: resolved_url.clone(),
3135                                    elapsed_ms,
3136                                    result: "empty collection".to_string(),
3137                                });
3138                                context.insert(key, json!([]));
3139                            }
3140                        }
3141                    }
3142                    // DB datasource + root target — load all collections as context
3143                    (
3144                        crate::datasource::Datasource::Database(adapter),
3145                        crate::datasource::DsnTarget::Root,
3146                    ) => {
3147                        let ctx = adapter.as_context().await;
3148                        let elapsed_ms = start.elapsed().as_millis() as u64;
3149                        let count = ctx.len();
3150                        debug_entries.push(FetchDebugEntry {
3151                            key: key.clone(),
3152                            url: resolved_url.clone(),
3153                            elapsed_ms,
3154                            result: format!("{} entries", count),
3155                        });
3156                        context.insert(key, json!(ctx));
3157                    }
3158                    // API datasource + path target — HTTP GET with configured headers
3159                    (
3160                        crate::datasource::Datasource::Api { base_url, headers },
3161                        crate::datasource::DsnTarget::Path(path),
3162                    ) => {
3163                        let full_url = format!("{}{}", base_url, path);
3164                        let mut request = state.http_client.get(&full_url);
3165                        for (hk, hv) in headers {
3166                            request = request.header(hk.as_str(), hv.as_str());
3167                        }
3168                        // Add any per-fetch headers from the directive
3169                        for (hk, hv) in &directive.headers {
3170                            request = request.header(hk.as_str(), hv.as_str());
3171                        }
3172
3173                        match request.send().await {
3174                            Ok(resp) if resp.status().is_success() => {
3175                                let elapsed_ms = start.elapsed().as_millis() as u64;
3176                                if let Ok(body) = resp.text().await {
3177                                    if let Ok(mut value) = serde_json::from_str::<Value>(&body) {
3178                                        if let Some(ref extract_path) = directive.path {
3179                                            value = extract_json_path(&value, extract_path);
3180                                        }
3181                                        debug_entries.push(FetchDebugEntry {
3182                                            key: key.clone(),
3183                                            url: full_url,
3184                                            elapsed_ms,
3185                                            result: describe_json(&value),
3186                                        });
3187                                        context.insert(key, value);
3188                                    } else {
3189                                        debug_entries.push(FetchDebugEntry {
3190                                            key: key.clone(),
3191                                            url: full_url,
3192                                            elapsed_ms,
3193                                            result: "error: invalid JSON".to_string(),
3194                                        });
3195                                    }
3196                                }
3197                            }
3198                            Ok(resp) => {
3199                                let elapsed_ms = start.elapsed().as_millis() as u64;
3200                                let status = resp.status();
3201                                debug_entries.push(FetchDebugEntry {
3202                                    key: key.clone(),
3203                                    url: full_url,
3204                                    elapsed_ms,
3205                                    result: format!("error: HTTP {}", status),
3206                                });
3207                                fetch_errors.insert(key, json!(format!("HTTP {}", status)));
3208                            }
3209                            Err(e) => {
3210                                let elapsed_ms = start.elapsed().as_millis() as u64;
3211                                debug_entries.push(FetchDebugEntry {
3212                                    key: key.clone(),
3213                                    url: full_url,
3214                                    elapsed_ms,
3215                                    result: format!("error: {}", e),
3216                                });
3217                                fetch_errors.insert(key, json!(e.to_string()));
3218                            }
3219                        }
3220                    }
3221                    // API datasource + root target — hit base URL
3222                    (
3223                        crate::datasource::Datasource::Api { base_url, headers },
3224                        crate::datasource::DsnTarget::Root,
3225                    ) => {
3226                        let mut request = state.http_client.get(base_url.as_str());
3227                        for (hk, hv) in headers {
3228                            request = request.header(hk.as_str(), hv.as_str());
3229                        }
3230                        match request.send().await {
3231                            Ok(resp) if resp.status().is_success() => {
3232                                let elapsed_ms = start.elapsed().as_millis() as u64;
3233                                if let Ok(body) = resp.text().await {
3234                                    if let Ok(value) = serde_json::from_str::<Value>(&body) {
3235                                        debug_entries.push(FetchDebugEntry {
3236                                            key: key.clone(),
3237                                            url: base_url.clone(),
3238                                            elapsed_ms,
3239                                            result: describe_json(&value),
3240                                        });
3241                                        context.insert(key, value);
3242                                    }
3243                                }
3244                            }
3245                            _ => {
3246                                let elapsed_ms = start.elapsed().as_millis() as u64;
3247                                debug_entries.push(FetchDebugEntry {
3248                                    key: key.clone(),
3249                                    url: base_url.clone(),
3250                                    elapsed_ms,
3251                                    result: "error: request failed".to_string(),
3252                                });
3253                            }
3254                        }
3255                    }
3256                    // DB datasource + path target — not supported, treat as collection
3257                    (
3258                        crate::datasource::Datasource::Database(adapter),
3259                        crate::datasource::DsnTarget::Path(path),
3260                    ) => {
3261                        // Treat path as collection name (strip leading /)
3262                        let collection = path.trim_start_matches('/');
3263                        let value = adapter.get_collection(collection).await;
3264                        let elapsed_ms = start.elapsed().as_millis() as u64;
3265                        match value {
3266                            Some(items) => {
3267                                debug_entries.push(FetchDebugEntry {
3268                                    key: key.clone(),
3269                                    url: resolved_url.clone(),
3270                                    elapsed_ms,
3271                                    result: format!("{} items", items.len()),
3272                                });
3273                                context.insert(key, json!(items));
3274                            }
3275                            None => {
3276                                debug_entries.push(FetchDebugEntry {
3277                                    key: key.clone(),
3278                                    url: resolved_url.clone(),
3279                                    elapsed_ms,
3280                                    result: "empty collection".to_string(),
3281                                });
3282                                context.insert(key, json!([]));
3283                            }
3284                        }
3285                    }
3286                    // API datasource + collection target — append as path
3287                    (
3288                        crate::datasource::Datasource::Api { base_url, headers },
3289                        crate::datasource::DsnTarget::Collection(collection),
3290                    ) => {
3291                        let full_url = format!("{}/{}", base_url, collection);
3292                        let mut request = state.http_client.get(&full_url);
3293                        for (hk, hv) in headers {
3294                            request = request.header(hk.as_str(), hv.as_str());
3295                        }
3296                        match request.send().await {
3297                            Ok(resp) if resp.status().is_success() => {
3298                                let elapsed_ms = start.elapsed().as_millis() as u64;
3299                                if let Ok(body) = resp.text().await {
3300                                    if let Ok(value) = serde_json::from_str::<Value>(&body) {
3301                                        debug_entries.push(FetchDebugEntry {
3302                                            key: key.clone(),
3303                                            url: full_url,
3304                                            elapsed_ms,
3305                                            result: describe_json(&value),
3306                                        });
3307                                        context.insert(key, value);
3308                                    }
3309                                }
3310                            }
3311                            _ => {
3312                                let elapsed_ms = start.elapsed().as_millis() as u64;
3313                                debug_entries.push(FetchDebugEntry {
3314                                    key: key.clone(),
3315                                    url: full_url,
3316                                    elapsed_ms,
3317                                    result: "error: request failed".to_string(),
3318                                });
3319                            }
3320                        }
3321                    }
3322                }
3323            } else {
3324                let elapsed_ms = start.elapsed().as_millis() as u64;
3325                let ds_name_owned = ds_name.to_string();
3326                tracing::warn!("  dsn '{}' not found in [datasources]", ds_name_owned);
3327                debug_entries.push(FetchDebugEntry {
3328                    key: key.clone(),
3329                    url: resolved_url.clone(),
3330                    elapsed_ms,
3331                    result: format!("error: datasource '{}' not configured", ds_name_owned),
3332                });
3333                fetch_errors.insert(
3334                    key,
3335                    json!(format!("datasource '{}' not configured", ds_name_owned)),
3336                );
3337            }
3338        } else {
3339            let elapsed_ms = start.elapsed().as_millis() as u64;
3340            tracing::warn!("  invalid dsn URL: {}", resolved_url);
3341            debug_entries.push(FetchDebugEntry {
3342                key: key.clone(),
3343                url: resolved_url.clone(),
3344                elapsed_ms,
3345                result: "error: invalid dsn URL format".to_string(),
3346            });
3347            fetch_errors.insert(key, json!("invalid dsn URL format"));
3348        }
3349    }
3350
3351    // Run all remote fetches concurrently
3352    let handles: Vec<_> = remote_fetches
3353        .into_iter()
3354        .map(|(key, directive)| {
3355            let state = state.clone();
3356            let resolved_url = crate::parser::replace_variables(&directive.url, context);
3357            let directive = directive.clone();
3358            tokio::spawn(async move {
3359                let start = std::time::Instant::now();
3360                let result = if directive.method == "GET"
3361                    && directive.headers.is_empty()
3362                    && directive.body.is_none()
3363                    && directive.path.is_none()
3364                {
3365                    // Simple GET — use existing cached fetcher
3366                    fetch_remote_json(&state, &resolved_url, false).await
3367                } else {
3368                    // Enhanced fetch
3369                    fetch_enhanced(&state, &directive, &resolved_url).await
3370                };
3371                let elapsed_ms = start.elapsed().as_millis() as u64;
3372                (
3373                    key,
3374                    resolved_url,
3375                    elapsed_ms,
3376                    result,
3377                    directive.path.clone(),
3378                )
3379            })
3380        })
3381        .collect();
3382
3383    for handle in handles {
3384        match handle.await {
3385            Ok((key, url, elapsed_ms, Ok(value), _path)) => {
3386                let desc = describe_json(&value);
3387                debug_entries.push(FetchDebugEntry {
3388                    key: key.clone(),
3389                    url,
3390                    elapsed_ms,
3391                    result: desc,
3392                });
3393                context.insert(key, value);
3394            }
3395            Ok((key, url, elapsed_ms, Err(e), _)) => {
3396                let err_str = e.to_string();
3397                debug_entries.push(FetchDebugEntry {
3398                    key: key.clone(),
3399                    url,
3400                    elapsed_ms,
3401                    result: format!("error: {}", err_str),
3402                });
3403                fetch_errors.insert(key, json!(err_str));
3404            }
3405            Err(e) => {
3406                tracing::warn!("  fetch task panicked: {}", e);
3407            }
3408        }
3409    }
3410
3411    tracing::info!(
3412        "All fetches done in {}ms",
3413        total_start.elapsed().as_millis()
3414    );
3415
3416    if !fetch_errors.is_empty() {
3417        context.insert("fetch_errors".to_string(), Value::Object(fetch_errors));
3418    }
3419
3420    debug_entries
3421}
3422
3423/// Render content string to HTML with session, user, and application context
3424/// If `is_partial` is true, uses reactive rendering which tracks session variables
3425async fn render_content_internal(
3426    state: &AppState,
3427    content: &str,
3428    session: Option<&sessions::Session>,
3429    user: &UserContext,
3430    app_config: Option<&WhatConfig>,
3431    page_directives: Option<&PageDirectives>,
3432    query_params: Option<&HashMap<String, String>>,
3433    route_params: &HashMap<String, String>,
3434    _is_partial: bool,
3435    flash: Option<&FlashData>,
3436) -> Result<RenderResult> {
3437    if let Err(e) = hydrate_config_data_sources(state).await {
3438        tracing::warn!("Failed to hydrate configured data sources: {}", e);
3439    }
3440
3441    // Build context from data store
3442    let mut context = state.store.as_context().await;
3443
3444    // The base context exposes every collection for looping. Scrub it: drop
3445    // read-scoped collections entirely (fetch directives re-add them scoped)
3446    // and strip private fields from the rest. Unconfigured collections (the
3447    // implicit read="all", no private fields) are untouched.
3448    crate::policy::scrub_base_context(&state.policies, &mut context);
3449
3450    // Add base path for includes (project root) and content directory
3451    context.insert(
3452        "_base_path".to_string(),
3453        json!(state.root.to_string_lossy()),
3454    );
3455    context.insert(
3456        "_content_dir".to_string(),
3457        json!(state.content_dir.to_string_lossy()),
3458    );
3459    context.insert("_dev_mode".to_string(), json!(state.dev_mode));
3460    context.insert("_strict".to_string(), json!(state.config.strict));
3461    if let Some(ref cf) = state.config.cloudflare {
3462        if let Some(ref site_key) = cf.turnstile_site_key {
3463            context.insert("_turnstile_site_key".to_string(), json!(site_key));
3464        }
3465    }
3466
3467    // Always inject empty flash/errors/old so unresolved #old.field# etc. become ""
3468    context.insert("flash".to_string(), Value::Object(serde_json::Map::new()));
3469    context.insert("errors".to_string(), Value::Object(serde_json::Map::new()));
3470    context.insert("old".to_string(), Value::Object(serde_json::Map::new()));
3471    context.insert("has_errors".to_string(), json!(false));
3472
3473    // Inject flash messages into context (consume-on-read, already removed from session)
3474    if let Some(flash) = flash {
3475        inject_flash_into_context(flash, &mut context);
3476    }
3477
3478    // Add dynamic route parameters to context (e.g., #id# from /post/[id].html)
3479    for (key, value) in route_params {
3480        context.insert(key.clone(), json!(value));
3481    }
3482
3483    // Add query parameters to context
3484    if let Some(params) = query_params {
3485        let query_obj: serde_json::Map<String, Value> =
3486            params.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
3487        context.insert("query".to_string(), Value::Object(query_obj));
3488    } else {
3489        context.insert("query".to_string(), Value::Object(serde_json::Map::new()));
3490    }
3491
3492    // Add application config values (from application.what files)
3493    if let Some(config) = app_config {
3494        for (key, value) in &config.values {
3495            if !key.starts_with("fetch.") {
3496                context.insert(key.clone(), value.clone());
3497            }
3498        }
3499    }
3500
3501    // Add page directive values to context (these override app_config values)
3502    if let Some(directives) = page_directives {
3503        if let Some(ref title) = directives.title {
3504            context.insert("title".to_string(), json!(title));
3505        }
3506        for (key, value) in &directives.custom {
3507            if !key.starts_with("fetch.") {
3508                context.insert(key.clone(), json!(value));
3509            }
3510        }
3511        // Inject inline variables (typed: arrays, objects, numbers, strings)
3512        // These go before fetches so fetch URLs can reference them
3513        // Dotted keys (e.g. "wired.counter") are nested into objects so
3514        // #wired.counter# resolves via the standard dot-path resolver.
3515        for (key, value) in &directives.vars {
3516            if let Some((root, child)) = key.split_once('.') {
3517                if let Some(Value::Object(obj)) = context.get_mut(root) {
3518                    obj.entry(child.to_string())
3519                        .or_insert_with(|| value.clone());
3520                } else {
3521                    let mut obj = serde_json::Map::new();
3522                    obj.insert(child.to_string(), value.clone());
3523                    context.insert(root.to_string(), Value::Object(obj));
3524                }
3525            } else {
3526                context.insert(key.clone(), value.clone());
3527            }
3528        }
3529    }
3530
3531    // Add session and user context BEFORE fetch directives so #session.*# and
3532    // #user.*# variables resolve in fetch URLs (e.g. fetch.api = "https://api.example.com?page=#session.page#")
3533    if let Some(s) = session {
3534        context.insert("session".to_string(), s.to_context());
3535    }
3536    // The policy actor identifies who is reading — drives owner/tenant scoping.
3537    let actor = crate::policy::Actor::from_parts(user, session);
3538    // Expose #user.owner# (the actor's primary owner key) for template-side
3539    // ownership checks like <if note._owner == user.owner>.
3540    let mut user_ctx = user.to_context();
3541    if let (Value::Object(map), Some(owner)) = (&mut user_ctx, actor.primary_owner_key()) {
3542        map.insert("owner".to_string(), json!(owner));
3543    }
3544    context.insert("user".to_string(), user_ctx);
3545
3546    // Built-in #now# variable — current datetime as ISO string
3547    context.insert(
3548        "now".to_string(),
3549        json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()),
3550    );
3551
3552    let fetch_debug =
3553        apply_fetch_directives(state, &mut context, app_config, page_directives, &actor).await;
3554    for entry in &fetch_debug {
3555        state.record_activity(ActivityEvent::Fetch {
3556            time: chrono::Local::now(),
3557            key: entry.key.clone(),
3558            url: entry.url.clone(),
3559            elapsed_ms: entry.elapsed_ms,
3560            result: entry.result.clone(),
3561        });
3562    }
3563    tracing::debug!(
3564        "Context keys after fetch: {:?}",
3565        context.keys().collect::<Vec<_>>()
3566    );
3567
3568    // Build data object with application and session sub-objects
3569    let mut data_obj = serde_json::Map::new();
3570
3571    // data.application - shared application data (from DataStore)
3572    if let Some(config) = app_config {
3573        let mut app_data = serde_json::Map::new();
3574        for decl in &config.data_application {
3575            let key = &decl.name;
3576            // Try key-value store first (scalar values like counters),
3577            // then fall back to collections (arrays of objects)
3578            if let Some(value) = state.store.get(key).await {
3579                app_data.insert(key.clone(), value);
3580            } else if state.policies.is_read_scoped(key) {
3581                // A read-scoped collection must not be exposed wholesale via
3582                // data.application — force it empty (fetch directives are the
3583                // sanctioned scoped path).
3584                tracing::debug!(target: "what::policy", "data.application '{}' is read-scoped — exposing empty", key);
3585                app_data.insert(key.clone(), json!([]));
3586            } else if let Some(value) = state.store.get_collection(key).await {
3587                let mut v = json!(value);
3588                crate::policy::strip_private_fields(&mut v, &state.policies.get(key).private_fields);
3589                app_data.insert(key.clone(), v);
3590            } else {
3591                // Default to 0 for uninitialized values
3592                app_data.insert(key.clone(), json!(0));
3593            }
3594        }
3595        data_obj.insert("application".to_string(), Value::Object(app_data));
3596
3597        // wired.* - real-time synced data (reads from same DataStore, auto-wrapped with w-bind)
3598        let mut wired_data = serde_json::Map::new();
3599        for decl in &config.data_wired {
3600            if let Some(value) = state.store.get(&decl.name).await {
3601                wired_data.insert(decl.name.clone(), value);
3602            } else {
3603                wired_data.insert(decl.name.clone(), json!(0));
3604            }
3605        }
3606        if !wired_data.is_empty() {
3607            context.insert("wired".to_string(), Value::Object(wired_data));
3608        }
3609    }
3610
3611    // Process set.wired.* directives — resolve template vars from fetch results,
3612    // persist to DataStore, update context, and broadcast to all connected clients.
3613    // Must run AFTER wired context building above so context["wired"] already exists.
3614    if let Some(directives) = page_directives {
3615        for (key, value_template) in &directives.custom {
3616            if let Some(wired_key) = key.strip_prefix("set.wired.") {
3617                let resolved = crate::parser::replace_variables(value_template, &context);
3618                tracing::info!("set.wired.{} = {}", wired_key, &resolved);
3619                if let Err(e) = state.store.set(wired_key, json!(&resolved)).await {
3620                    tracing::error!("Failed to set wired.{}: {}", wired_key, e);
3621                }
3622                // Update wired context so #wired.key# resolves in this render
3623                if let Some(Value::Object(wired_data)) = context.get_mut("wired") {
3624                    wired_data.insert(wired_key.to_string(), json!(&resolved));
3625                } else {
3626                    let mut wired_data = serde_json::Map::new();
3627                    wired_data.insert(wired_key.to_string(), json!(&resolved));
3628                    context.insert("wired".to_string(), Value::Object(wired_data));
3629                }
3630                // Broadcast to connected WebSocket clients (with scope filtering)
3631                let mut wired_map = serde_json::Map::new();
3632                wired_map.insert(format!("wired.{}", wired_key), Value::String(resolved));
3633                let json_str = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
3634                let mut scope = state.get_wired_scope(wired_key).await;
3635                // For [user] scope, fill in the current user's ID
3636                if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
3637                    let uid = user
3638                        .claims
3639                        .get("sub")
3640                        .and_then(|v| v.as_str())
3641                        .unwrap_or("")
3642                        .to_string();
3643                    scope = WiredScope::User(uid);
3644                }
3645                let _ = state.wired_tx.send(WiredMessage {
3646                    json: json_str,
3647                    scope,
3648                });
3649            }
3650        }
3651    }
3652
3653    // Inject mutation.* directives into context as nested "mutation" object
3654    // so #mutation.name# resolves in w-set attributes
3655    if let Some(directives) = page_directives {
3656        let mut mutation_data = serde_json::Map::new();
3657        for (key, value) in &directives.custom {
3658            if let Some(name) = key.strip_prefix("mutation.") {
3659                mutation_data.insert(name.to_string(), Value::String(value.clone()));
3660            }
3661        }
3662        if !mutation_data.is_empty() {
3663            context.insert("mutation".to_string(), Value::Object(mutation_data));
3664        }
3665    }
3666
3667    // data.session - per-user session data
3668    if let Some(s) = session {
3669        let mut session_data = serde_json::Map::new();
3670        if let Some(config) = app_config {
3671            for key in &config.data_session {
3672                if let Some(value) = s.data.get(key) {
3673                    session_data.insert(key.clone(), value.clone());
3674                } else {
3675                    // Default to 0 for uninitialized values
3676                    session_data.insert(key.clone(), json!(0));
3677                }
3678            }
3679        }
3680        data_obj.insert("session".to_string(), Value::Object(session_data));
3681    }
3682
3683    context.insert("data".to_string(), Value::Object(data_obj));
3684
3685    // Resolve computed variables from page directives
3686    // These use string interpolation: compute.name = "Hello #user.name#!"
3687    // Available as #name# (without compute. prefix) in templates
3688    if let Some(directives) = page_directives {
3689        if !directives.computed.is_empty() {
3690            crate::parser::resolve_computed_variables(&directives.computed, &mut context);
3691        }
3692    }
3693
3694    // Determine the layout to use:
3695    // 1. Page-level layout directive takes priority
3696    // 2. Fall back to app config layout
3697    // 3. "none" explicitly disables layout
3698    let layout_path = page_directives
3699        .and_then(|d| d.layout.as_ref())
3700        .or_else(|| app_config.and_then(|c| c.layout.as_ref()));
3701
3702    // Validation secret for form signing
3703    let validation_secret = state
3704        .config
3705        .auth
3706        .jwt_secret
3707        .as_deref()
3708        .unwrap_or("wwwhat-validation-secret");
3709
3710    // Render the page content
3711    // Always use reactive rendering to wrap session variables with w-bind
3712    // Client-side JS will remove w-bind from injected content to prevent unwanted updates
3713    let render_result = state
3714        .engine
3715        .render_reactive_with_secret(content, &context, Some(validation_secret))
3716        .await?;
3717    let (page_html, session_keys) = (render_result.html, render_result.session_keys);
3718
3719    // Layout path already determined above
3720
3721    // Apply layout if specified and not "none"
3722    if let Some(layout) = layout_path {
3723        if layout.to_lowercase() != "none" {
3724            let layout_file = state.root.join(layout);
3725            if layout_file.exists() {
3726                let raw_layout = tokio::fs::read_to_string(&layout_file).await?;
3727                if state.dev_mode {
3728                    engine::warn_template_lints_once(&layout_file, &raw_layout);
3729                }
3730                // Strip <what> directive block from layout (contains props/defaults)
3731                let (_, layout_content) = parse_page_directives(&raw_layout);
3732                // Replace <slot/> or <slot /> with page content
3733                let wrapped = layout_content
3734                    .replace("<slot/>", &page_html)
3735                    .replace("<slot />", &page_html);
3736                // Render the wrapped content (for variable substitution in layout)
3737                // Always use reactive rendering for consistent w-bind wrapping
3738                let layout_result = state
3739                    .engine
3740                    .render_reactive_with_secret(&wrapped, &context, Some(validation_secret))
3741                    .await?;
3742                let (final_html, layout_session_keys) =
3743                    (layout_result.html, layout_result.session_keys);
3744                // Merge session keys from both page and layout
3745                let mut all_keys = session_keys;
3746                all_keys.extend(layout_session_keys);
3747                return Ok(RenderResult {
3748                    html: final_html,
3749                    session_keys: all_keys,
3750                    fetch_debug,
3751                });
3752            } else {
3753                tracing::warn!("Layout file not found: {:?}", layout_file);
3754            }
3755        }
3756    }
3757
3758    Ok(RenderResult {
3759        html: page_html,
3760        session_keys,
3761        fetch_debug,
3762    })
3763}
3764
3765/// Wrapper for render_content_internal - maintains backward compatibility
3766async fn render_content(
3767    state: &AppState,
3768    content: &str,
3769    session: Option<&sessions::Session>,
3770    user: &UserContext,
3771    app_config: Option<&WhatConfig>,
3772    page_directives: Option<&PageDirectives>,
3773    query_params: Option<&HashMap<String, String>>,
3774) -> Result<String> {
3775    let result = render_content_internal(
3776        state,
3777        content,
3778        session,
3779        user,
3780        app_config,
3781        page_directives,
3782        query_params,
3783        &HashMap::new(),
3784        false,
3785        None,
3786    )
3787    .await?;
3788    Ok(result.html)
3789}
3790
3791/// Discover all page routes from the content directory (site/ or pages/).
3792/// Returns (url_path, is_dynamic) pairs. Dynamic routes have `[id]` segments.
3793pub fn discover_routes(root: &std::path::Path) -> Vec<(String, bool)> {
3794    let pages_dir = content_dir(root);
3795    if !pages_dir.exists() {
3796        return Vec::new();
3797    }
3798
3799    let mut routes = Vec::new();
3800    collect_routes(&pages_dir, &pages_dir, &mut routes);
3801    routes.sort_by(|a, b| a.0.cmp(&b.0));
3802    routes
3803}
3804
3805fn collect_routes(base: &std::path::Path, dir: &std::path::Path, routes: &mut Vec<(String, bool)>) {
3806    let entries = match std::fs::read_dir(dir) {
3807        Ok(e) => e,
3808        Err(_) => return,
3809    };
3810
3811    for entry in entries.flatten() {
3812        let path = entry.path();
3813        if path.is_dir() {
3814            // Skip partials directory
3815            if path.file_name().map_or(false, |n| n == "partials") {
3816                continue;
3817            }
3818            collect_routes(base, &path, routes);
3819        } else if path.extension().map_or(false, |e| e == "html") {
3820            let file_name = path.file_stem().unwrap_or_default().to_string_lossy();
3821            let relative = path.strip_prefix(base).unwrap_or(&path);
3822            let is_dynamic = file_name.starts_with('[') && file_name.ends_with(']');
3823
3824            // Convert file path to URL path
3825            let parent = relative.parent().unwrap_or(std::path::Path::new(""));
3826            let parent_str = parent.to_string_lossy();
3827
3828            let url_path = if file_name == "index" {
3829                if parent_str.is_empty() {
3830                    "/".to_string()
3831                } else {
3832                    format!("/{}", parent_str)
3833                }
3834            } else if parent_str.is_empty() {
3835                format!("/{}", file_name)
3836            } else {
3837                format!("/{}/{}", parent_str, file_name)
3838            };
3839
3840            routes.push((url_path, is_dynamic));
3841        }
3842    }
3843}
3844
3845/// Render a page to static HTML for pre-rendering / build output.
3846///
3847/// Resolves URL path to a page file, loads application config, builds a minimal
3848/// context (no session, no user, no query params), and renders through the engine.
3849/// Returns None if the page requires authentication or is excluded.
3850pub async fn render_page_to_html(state: &AppState, url_path: &str) -> Result<Option<String>> {
3851    let resolved = resolve_page_path(&state.root, url_path);
3852    let page_path = match resolved {
3853        Some(r) if r.params.is_empty() => r.path,
3854        // Dynamic routes can't be pre-rendered
3855        _ => return Ok(None),
3856    };
3857
3858    let raw_content = tokio::fs::read_to_string(&page_path).await?;
3859    let (mut directives, content) = parse_page_directives(&raw_content);
3860
3861    // Load and merge application config
3862    let app_config = load_application_config(&state.root, url_path);
3863    if !directives.requires_auth() && app_config.directives.requires_auth() {
3864        directives.auth = app_config.directives.auth.clone();
3865        directives.protected = app_config.directives.protected;
3866        directives.roles = app_config.directives.roles.clone();
3867    }
3868
3869    // Skip pages that require auth or are excluded
3870    if directives.requires_auth() || directives.exclude {
3871        return Ok(None);
3872    }
3873
3874    let user = UserContext::unauthenticated();
3875    let result = render_content_internal(
3876        state,
3877        &content,
3878        None,
3879        &user,
3880        Some(&app_config),
3881        Some(&directives),
3882        None,
3883        &HashMap::new(),
3884        false,
3885        None,
3886    )
3887    .await?;
3888
3889    register_validated_actions(&result.html, state);
3890    let html = inject_what_css(&result.html, state.css_mode);
3891    let html = inject_what_js(&html);
3892    let html = inject_theme_restore(&html);
3893    Ok(Some(html))
3894}
3895
3896/// Handle form actions (CRUD operations)
3897#[derive(Deserialize)]
3898struct ActionParams {
3899    #[serde(rename = "w-action")]
3900    action: Option<String>,
3901    #[serde(rename = "w-redirect")]
3902    redirect: Option<String>,
3903    #[serde(flatten)]
3904    extra: HashMap<String, String>,
3905}
3906
3907fn decode_query_params(query: Option<&str>) -> HashMap<String, String> {
3908    query
3909        .map(|q| {
3910            q.split('&')
3911                .filter_map(|pair| {
3912                    let mut parts = pair.splitn(2, '=');
3913                    let key = parts.next()?;
3914                    let value = parts.next().unwrap_or("");
3915                    Some((
3916                        urlencoding::decode(key).unwrap_or_default().into_owned(),
3917                        urlencoding::decode(value).unwrap_or_default().into_owned(),
3918                    ))
3919                })
3920                .collect()
3921        })
3922        .unwrap_or_default()
3923}
3924
3925/// Helper: extract session from cookie headers and return (session, session_store_ref)
3926async fn extract_session_from_headers(
3927    state: &AppState,
3928    headers: &HeaderMap,
3929) -> (Option<sessions::Session>, bool) {
3930    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
3931    if let Some(ref sessions) = state.sessions {
3932        let session_id =
3933            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
3934        match sessions.get_or_create(session_id.as_deref()).await {
3935            Ok(session) => {
3936                let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
3937                (Some(session), is_new)
3938            }
3939            Err(_) => (None, false),
3940        }
3941    } else {
3942        (None, false)
3943    }
3944}
3945
3946/// Helper: extract the authenticated user context from cookie headers.
3947/// Mirrors the JWT flow in `handle_page` so action/upload/w-set handlers can
3948/// identify the actor without duplicating the decode logic.
3949fn extract_user_context(state: &AppState, headers: &HeaderMap) -> UserContext {
3950    if !state.auth.is_enabled() {
3951        return UserContext::unauthenticated();
3952    }
3953    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
3954    if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
3955        match state.auth.decode_jwt(&token) {
3956            Ok(claims) if !claims.is_expired() => {
3957                UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
3958            }
3959            _ => UserContext::unauthenticated(),
3960        }
3961    } else {
3962        UserContext::unauthenticated()
3963    }
3964}
3965
3966/// Helper: build the policy Actor for a request from its user context + session.
3967fn extract_actor(
3968    state: &AppState,
3969    headers: &HeaderMap,
3970    session: Option<&sessions::Session>,
3971) -> crate::policy::Actor {
3972    let user = extract_user_context(state, headers);
3973    crate::policy::Actor::from_parts(&user, session)
3974}
3975
3976/// Build a policy-denial response. Full-page form submits get a flash error +
3977/// 303 redirect back (matching the validation/error UX); AJAX/partial requests
3978/// get a 403 with a plain-text message and, in dev mode, an `X-What-Policy`
3979/// header describing the denied rule.
3980async fn deny_response(
3981    state: &AppState,
3982    session: &mut Option<sessions::Session>,
3983    is_new_session: bool,
3984    is_partial: bool,
3985    referer: Option<&str>,
3986    msg: String,
3987    dev_detail: String,
3988) -> axum::response::Response {
3989    tracing::info!(target: "what::policy", "denied: {}", dev_detail);
3990    state.record_activity(ActivityEvent::PolicyDenial {
3991        time: chrono::Local::now(),
3992        detail: dev_detail.clone(),
3993    });
3994
3995    if is_partial {
3996        let mut headers = vec![(header::CONTENT_TYPE, "text/plain".to_string())];
3997        if state.dev_mode {
3998            headers.push((
3999                header::HeaderName::from_static("x-what-policy"),
4000                format!("deny; {}", dev_detail),
4001            ));
4002        }
4003        return build_response(StatusCode::FORBIDDEN, headers, msg);
4004    }
4005
4006    if let Some(sess) = session {
4007        let flash = FlashData {
4008            flash: HashMap::from([("error".to_string(), msg)]),
4009            ..Default::default()
4010        };
4011        set_flash_data(sess, &flash);
4012        if let Some(ref sessions) = state.sessions {
4013            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4014        }
4015    }
4016    let fallback = referer.unwrap_or("/");
4017    redirect_with_session(state, fallback, session.as_ref(), is_new_session)
4018}
4019
4020/// Helper: build redirect response with optional session cookie
4021fn redirect_with_session(
4022    state: &AppState,
4023    redirect_to: &str,
4024    session: Option<&sessions::Session>,
4025    is_new_session: bool,
4026) -> axum::response::Response {
4027    let mut headers = vec![(header::LOCATION, redirect_to.to_string())];
4028    if is_new_session {
4029        if let Some(s) = session {
4030            headers.push((
4031                header::SET_COOKIE,
4032                sessions::build_session_cookie(
4033                    &s.id,
4034                    &state.config.session.cookie_name,
4035                    state.config.session.max_age,
4036                    state.config.session.secure,
4037                ),
4038            ));
4039        }
4040    }
4041    build_response(StatusCode::SEE_OTHER, headers, String::new())
4042}
4043
4044/// Validate form data if w-rules token is present.
4045/// Returns Ok(()) if valid or no rules. Returns Err with redirect response if invalid.
4046/// If the action_url is registered as requiring validation but w-rules is missing,
4047/// the request is rejected (fail-closed: prevents bypassing validation by stripping the token).
4048async fn validate_form_data(
4049    state: &AppState,
4050    form_data: &HashMap<String, String>,
4051    session: &mut Option<sessions::Session>,
4052    is_new_session: bool,
4053    referer: Option<&str>,
4054    action_url: Option<String>,
4055) -> std::result::Result<(), axum::response::Response> {
4056    let rules_token = match form_data.get("w-rules") {
4057        Some(t) => t,
4058        None => {
4059            // Fail-closed: if this action URL was registered as requiring validation
4060            // (from a rendered <form w-validate>), reject submissions without w-rules.
4061            if let Some(ref url) = action_url {
4062                // Check registry — drop guard before any .await
4063                let requires_validation = {
4064                    let registry = state.validated_actions.read().unwrap_or_else(|e| e.into_inner());
4065                    registry.contains(url.as_str())
4066                        || registry.iter().any(|r| {
4067                            let r_path = r.split('?').next().unwrap_or(r);
4068                            r_path == url.as_str()
4069                        })
4070                };
4071                if requires_validation {
4072                    tracing::warn!(
4073                        "Validation bypass rejected: w-rules missing for registered action '{}'",
4074                        url
4075                    );
4076                    let redirect_to = referer.unwrap_or("/");
4077                    let flash = FlashData {
4078                        flash: HashMap::from([(
4079                            "error".to_string(),
4080                            "Form validation required. Please reload and try again.".to_string(),
4081                        )]),
4082                        errors: HashMap::new(),
4083                        old: HashMap::new(),
4084                    };
4085                    if let Some(sess) = session {
4086                        set_flash_data(sess, &flash);
4087                        if let Some(ref sessions) = state.sessions {
4088                            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4089                        }
4090                    }
4091                    return Err(redirect_with_session(
4092                        state,
4093                        redirect_to,
4094                        session.as_ref(),
4095                        is_new_session,
4096                    ));
4097                }
4098            }
4099            return Ok(());
4100        }
4101    };
4102
4103    let secret = state
4104        .config
4105        .auth
4106        .jwt_secret
4107        .as_deref()
4108        .unwrap_or("wwwhat-validation-secret");
4109    let rules = match validation::decode_rules(rules_token, secret) {
4110        Some(r) => r,
4111        None => {
4112            tracing::warn!("Invalid w-rules token — possible tampering, rejecting submission");
4113            // Fail closed: reject the submission when validation rules are tampered
4114            let redirect_to = referer.unwrap_or("/");
4115            let flash = FlashData {
4116                flash: HashMap::from([(
4117                    "error".to_string(),
4118                    "Form validation failed. Please reload and try again.".to_string(),
4119                )]),
4120                errors: HashMap::new(),
4121                old: HashMap::new(),
4122            };
4123            if let Some(sess) = session {
4124                set_flash_data(sess, &flash);
4125                if let Some(ref sessions) = state.sessions {
4126                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4127                }
4128            }
4129            return Err(redirect_with_session(
4130                state,
4131                redirect_to,
4132                session.as_ref(),
4133                is_new_session,
4134            ));
4135        }
4136    };
4137
4138    let mut result = validation::validate_form(form_data, &rules);
4139
4140    // Enforce w-unique constraints against DataStore
4141    for (field_name, field_rules) in &rules.fields {
4142        if let Some(ref unique_spec) = field_rules.unique {
4143            let value = form_data.get(field_name).map(|s| s.as_str()).unwrap_or("");
4144            if !value.is_empty() {
4145                let parts: Vec<&str> = unique_spec.split('.').collect();
4146                if parts.len() == 2 {
4147                    let collection = parts[0];
4148                    let check_field = parts[1];
4149                    // Search collection for existing records with this value
4150                    if let Some(items) = state.store.get_collection(collection).await {
4151                        let exists = items.iter().any(|item| {
4152                            item.get(check_field)
4153                                .and_then(|v| v.as_str())
4154                                .map(|v| v == value)
4155                                .unwrap_or(false)
4156                        });
4157                        if exists {
4158                            let msg = field_rules
4159                                .error_message
4160                                .clone()
4161                                .unwrap_or_else(|| format!("{} already exists", field_name));
4162                            result.errors.insert(field_name.clone(), msg);
4163                            result.is_valid = false;
4164                        }
4165                    }
4166                }
4167            }
4168        }
4169    }
4170
4171    if result.is_valid {
4172        return Ok(());
4173    }
4174
4175    // Validation failed — store errors + old values as flash, redirect back
4176    let redirect_to = referer.unwrap_or("/");
4177    let flash = FlashData {
4178        flash: HashMap::from([(
4179            "error".to_string(),
4180            "Please fix the errors below".to_string(),
4181        )]),
4182        errors: result.errors,
4183        old: form_data
4184            .iter()
4185            .filter(|(k, _)| !k.starts_with("w-"))
4186            .map(|(k, v)| (k.clone(), v.clone()))
4187            .collect(),
4188    };
4189
4190    if let Some(sess) = session {
4191        set_flash_data(sess, &flash);
4192        if let Some(ref sessions) = state.sessions {
4193            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4194        }
4195    }
4196
4197    Err(redirect_with_session(
4198        state,
4199        redirect_to,
4200        session.as_ref(),
4201        is_new_session,
4202    ))
4203}
4204
4205/// Framework-internal form fields that must never be persisted as record data.
4206/// Real app fields (including `_session_id`) are kept; only control fields are dropped.
4207fn is_framework_field(key: &str) -> bool {
4208    key.starts_with("w-")
4209        || key == "_csrf"
4210        || key == "redirect"
4211        || key == "cf-turnstile-response"
4212}
4213
4214/// Warn once per collection when a mutation is permitted only because a record
4215/// predates ownership tracking (implicit default policy + unowned record).
4216fn warn_legacy_mutation_once(collection: &str) {
4217    static WARNED: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
4218        LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
4219    let mut warned = WARNED.lock().unwrap_or_else(|e| e.into_inner());
4220    if warned.insert(collection.to_string()) {
4221        tracing::warn!(
4222            target: "what::policy",
4223            "collection '{}' has records without an _owner (created before ownership tracking) — these remain modifiable by anyone under the default policy. Declare an explicit policy or backfill _owner to lock them.",
4224            collection
4225        );
4226    }
4227}
4228
4229async fn handle_action(
4230    State(state): State<AppState>,
4231    Path(collection): Path<String>,
4232    headers: HeaderMap,
4233    Query(params): Query<ActionParams>,
4234    Form(form_data): Form<HashMap<String, String>>,
4235) -> impl IntoResponse {
4236    let action = params.action.as_deref().unwrap_or("create");
4237    let referer = headers
4238        .get(header::REFERER)
4239        .and_then(|v| v.to_str().ok())
4240        .map(|s| s.to_string());
4241    let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4242    let action_url = format!("/w-action/{}", collection);
4243
4244    // Validate if w-rules present
4245    if let Err(resp) = validate_form_data(
4246        &state,
4247        &form_data,
4248        &mut session,
4249        is_new_session,
4250        referer.as_deref(),
4251        Some(action_url.clone()),
4252    )
4253    .await
4254    {
4255        return resp;
4256    }
4257
4258    // Verify Turnstile if configured
4259    let turnstile_token = form_data.get("cf-turnstile-response").map(|s| s.as_str());
4260    if let Err(msg) = verify_turnstile(&state, turnstile_token).await {
4261        tracing::warn!("Turnstile verification failed: {}", msg);
4262        if let Some(ref mut sess) = session {
4263            let flash = FlashData {
4264                flash: HashMap::from([("error".to_string(), msg)]),
4265                ..Default::default()
4266            };
4267            set_flash_data(sess, &flash);
4268            if let Some(ref sessions) = state.sessions {
4269                let _ = sessions.update(&sess.id, sess.data.clone()).await;
4270            }
4271        }
4272        let fallback = referer.as_deref().unwrap_or("/");
4273        return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4274    }
4275
4276    // Extract email trigger and partial path before consuming form_data
4277    let email_trigger = extract_email_trigger(&form_data);
4278    let partial_src = form_data.get("w-partial").cloned();
4279    let is_partial = headers
4280        .get("X-Requested-With")
4281        .and_then(|v| v.to_str().ok())
4282        .map(|v| v == "What")
4283        .unwrap_or(false);
4284
4285    // Authorization: does this actor may create in this collection?
4286    let actor = extract_actor(&state, &headers, session.as_ref());
4287    let policy = state.policies.get(&collection);
4288    if action == "create" && !policy.allows_create(&actor) {
4289        return deny_response(
4290            &state,
4291            &mut session,
4292            is_new_session,
4293            is_partial,
4294            referer.as_deref(),
4295            format!("You don't have permission to add to {}.", collection),
4296            format!("collection={}; action=create; rule={:?}", collection, policy.create),
4297        )
4298        .await;
4299    }
4300
4301    let result = match action {
4302        "create" => {
4303            let mut map: serde_json::Map<String, Value> = form_data
4304                .into_iter()
4305                .filter(|(k, _)| !is_framework_field(k))
4306                .map(|(k, v)| (k, Value::String(v)))
4307                .collect();
4308            // Drop spoofed _owner + readonly fields, then stamp the real owner.
4309            policy.sanitize_input(&mut map);
4310            policy.stamp_owner(&mut map, &actor);
4311            state.store.create(&collection, Value::Object(map)).await
4312        }
4313        _ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
4314    };
4315
4316    // Invalidate cache for this collection
4317    state.cache.invalidate_content_type(&collection).await;
4318
4319    let redirect_to = params.redirect.as_deref().unwrap_or("/");
4320
4321    match result {
4322        Ok(_) => {
4323            // Enqueue email if w-email-to was specified
4324            maybe_enqueue_email(&state, email_trigger).await;
4325
4326            // Return rendered partial for AJAX requests
4327            if is_partial {
4328                if let Some(ref partial_path) = partial_src {
4329                    if let Ok(html) = render_partial_for_action(
4330                        &state,
4331                        &headers,
4332                        partial_path,
4333                        &params.extra,
4334                    )
4335                    .await
4336                    {
4337                        return build_partial_response(
4338                            html,
4339                            session.as_ref(),
4340                            is_new_session,
4341                            &state,
4342                        );
4343                    }
4344                }
4345            }
4346
4347            // Set success flash and redirect for full-page requests
4348            if let Some(ref mut sess) = session {
4349                let flash = FlashData {
4350                    flash: HashMap::from([(
4351                        "success".to_string(),
4352                        format!("Item created in {}", collection),
4353                    )]),
4354                    ..Default::default()
4355                };
4356                set_flash_data(sess, &flash);
4357                if let Some(ref sessions) = state.sessions {
4358                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4359                }
4360            }
4361            redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4362        }
4363        Err(e) => {
4364            tracing::error!("Action error: {}", e);
4365            // Set error flash and redirect back
4366            if let Some(ref mut sess) = session {
4367                let flash = FlashData {
4368                    flash: HashMap::from([(
4369                        "error".to_string(),
4370                        format!("Failed to create: {}", e),
4371                    )]),
4372                    ..Default::default()
4373                };
4374                set_flash_data(sess, &flash);
4375                if let Some(ref sessions) = state.sessions {
4376                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4377                }
4378            }
4379            let fallback = referer.as_deref().unwrap_or(redirect_to);
4380            redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4381        }
4382    }
4383}
4384
4385async fn handle_action_with_id(
4386    State(state): State<AppState>,
4387    Path((collection, id)): Path<(String, String)>,
4388    headers: HeaderMap,
4389    Query(params): Query<ActionParams>,
4390    Form(form_data): Form<HashMap<String, String>>,
4391) -> impl IntoResponse {
4392    let action = params.action.as_deref().unwrap_or("update");
4393    let referer = headers
4394        .get(header::REFERER)
4395        .and_then(|v| v.to_str().ok())
4396        .map(|s| s.to_string());
4397    let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4398    // Include the id so update/delete on /w-action/{collection}/{id} don't
4399    // collide with the create form's registered validated action at
4400    // /w-action/{collection} (which would wrongly reject them as missing w-rules).
4401    let action_url = format!("/w-action/{}/{}", collection, id);
4402
4403    // Validate if w-rules present
4404    if let Err(resp) = validate_form_data(
4405        &state,
4406        &form_data,
4407        &mut session,
4408        is_new_session,
4409        referer.as_deref(),
4410        Some(action_url.clone()),
4411    )
4412    .await
4413    {
4414        return resp;
4415    }
4416
4417    // Extract email trigger and partial path before consuming form_data
4418    let email_trigger = extract_email_trigger(&form_data);
4419    let partial_src = form_data.get("w-partial").cloned();
4420    let is_partial = headers
4421        .get("X-Requested-With")
4422        .and_then(|v| v.to_str().ok())
4423        .map(|v| v == "What")
4424        .unwrap_or(false);
4425
4426    let id_value: Value = if let Ok(num) = id.parse::<i64>() {
4427        Value::Number(num.into())
4428    } else {
4429        Value::String(id)
4430    };
4431
4432    // Authorization: fetch the existing record ONCE and check ownership /
4433    // tenant scope before any mutation. This is the gate that closes the
4434    // "edit/delete anyone's record by id" hole.
4435    let actor = extract_actor(&state, &headers, session.as_ref());
4436    let policy = state.policies.get(&collection);
4437    let kind = if action == "delete" {
4438        crate::policy::MutationKind::Delete
4439    } else {
4440        crate::policy::MutationKind::Update
4441    };
4442    let existing = state
4443        .store
4444        .find_by(&collection, "id", &id_value)
4445        .await
4446        .into_iter()
4447        .next();
4448    // Resolve the tenant filter (if any) against the current request context.
4449    let mut filter_ctx: HashMap<String, Value> = HashMap::new();
4450    {
4451        let user = extract_user_context(&state, &headers);
4452        filter_ctx.insert("user".to_string(), user.to_context());
4453        if let Some(s) = session.as_ref() {
4454            filter_ctx.insert("session".to_string(), s.to_context());
4455        }
4456    }
4457    let resolved_filter = policy.resolved_filter(&filter_ctx);
4458    if action == "update" || action == "delete" {
4459        match policy.allows_mutation(&actor, existing.as_ref(), kind, resolved_filter.as_deref()) {
4460            crate::policy::Decision::Deny => {
4461                return deny_response(
4462                    &state,
4463                    &mut session,
4464                    is_new_session,
4465                    is_partial,
4466                    referer.as_deref(),
4467                    format!("You don't have permission to {} that item.", action),
4468                    format!("collection={}; action={}; id={}", collection, action, id_value),
4469                )
4470                .await;
4471            }
4472            crate::policy::Decision::AllowLegacy => {
4473                warn_legacy_mutation_once(&collection);
4474            }
4475            crate::policy::Decision::Allow => {}
4476        }
4477    }
4478
4479    let result = match action {
4480        "update" => {
4481            let mut map: serde_json::Map<String, Value> = form_data
4482                .into_iter()
4483                .filter(|(k, _)| !is_framework_field(k))
4484                .map(|(k, v)| (k, Value::String(v)))
4485                .collect();
4486            // Drop spoofed _owner + readonly fields (owner is immutable).
4487            policy.sanitize_input(&mut map);
4488            state
4489                .store
4490                .update(&collection, &id_value, Value::Object(map))
4491                .await
4492                .map(|_| ())
4493        }
4494        "delete" => {
4495            // Clean up uploaded files using the record we already fetched.
4496            if state.config.uploads.enabled {
4497                if let Some(record) = existing.as_ref() {
4498                    cleanup_uploaded_files(&state.root, &state.config.uploads.directory, record)
4499                        .await;
4500                }
4501            }
4502            state.store.delete(&collection, &id_value).await.map(|_| ())
4503        }
4504        _ => Err(crate::Error::Action(format!("Unknown action: {}", action))),
4505    };
4506
4507    // Invalidate cache
4508    state.cache.invalidate_content_type(&collection).await;
4509
4510    let redirect_to = params.redirect.as_deref().unwrap_or("/");
4511
4512    match result {
4513        Ok(_) => {
4514            // Enqueue email if w-email-to was specified
4515            maybe_enqueue_email(&state, email_trigger).await;
4516
4517            // Return rendered partial for AJAX requests
4518            if is_partial {
4519                if let Some(ref partial_path) = partial_src {
4520                    if let Ok(html) = render_partial_for_action(
4521                        &state,
4522                        &headers,
4523                        partial_path,
4524                        &params.extra,
4525                    )
4526                    .await
4527                    {
4528                        return build_partial_response(
4529                            html,
4530                            session.as_ref(),
4531                            is_new_session,
4532                            &state,
4533                        );
4534                    }
4535                }
4536            }
4537
4538            // Set success flash and redirect for full-page requests
4539            if let Some(ref mut sess) = session {
4540                let action_label = if action == "delete" {
4541                    "deleted"
4542                } else {
4543                    "updated"
4544                };
4545                let flash = FlashData {
4546                    flash: HashMap::from([(
4547                        "success".to_string(),
4548                        format!("Item {} in {}", action_label, collection),
4549                    )]),
4550                    ..Default::default()
4551                };
4552                set_flash_data(sess, &flash);
4553                if let Some(ref sessions) = state.sessions {
4554                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4555                }
4556            }
4557            redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4558        }
4559        Err(e) => {
4560            tracing::error!("Action error: {}", e);
4561            if let Some(ref mut sess) = session {
4562                let flash = FlashData {
4563                    flash: HashMap::from([("error".to_string(), format!("Action failed: {}", e))]),
4564                    ..Default::default()
4565                };
4566                set_flash_data(sess, &flash);
4567                if let Some(ref sessions) = state.sessions {
4568                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4569                }
4570            }
4571            let fallback = referer.as_deref().unwrap_or(redirect_to);
4572            redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4573        }
4574    }
4575}
4576
4577/// Render a partial template for returning inline HTML from action handlers.
4578/// Reuses the same rendering pipeline as handle_partial() but takes the state
4579/// and headers directly instead of being an axum handler.
4580async fn render_partial_for_action(
4581    state: &AppState,
4582    headers: &HeaderMap,
4583    partial_path: &str,
4584    query_params: &HashMap<String, String>,
4585) -> Result<String> {
4586    let clean_path = partial_path.trim_start_matches('/');
4587    let partials_dir = state.content_dir.join("partials");
4588    let file_path = {
4589        let with_ext = partials_dir.join(format!("{}.html", clean_path));
4590        if with_ext.exists() {
4591            with_ext
4592        } else {
4593            partials_dir.join(clean_path).join("index.html")
4594        }
4595    };
4596
4597    // Path traversal protection
4598    let canonical = file_path
4599        .canonicalize()
4600        .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4601    let partials_canonical = partials_dir
4602        .canonicalize()
4603        .map_err(|_| crate::Error::Action("Partials dir not found".into()))?;
4604    if !canonical.starts_with(&partials_canonical) {
4605        return Err(crate::Error::Action("Access denied".into()));
4606    }
4607
4608    let raw_content = tokio::fs::read_to_string(&canonical)
4609        .await
4610        .map_err(|_| crate::Error::Action("Partial not found".into()))?;
4611
4612    if state.dev_mode {
4613        engine::warn_template_lints_once(&canonical, &raw_content);
4614    }
4615
4616    let (directives, content) = parse_page_directives(&raw_content);
4617
4618    // Load session from headers
4619    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
4620    let session = if let Some(ref sessions) = state.sessions {
4621        let session_id =
4622            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
4623        sessions.get_or_create(session_id.as_deref()).await.ok()
4624    } else {
4625        None
4626    };
4627
4628    let user_context = if state.auth.is_enabled() {
4629        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
4630            match state.auth.decode_jwt(&token) {
4631                Ok(claims) if !claims.is_expired() => {
4632                    UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
4633                }
4634                _ => UserContext::unauthenticated(),
4635            }
4636        } else {
4637            UserContext::unauthenticated()
4638        }
4639    } else {
4640        UserContext::unauthenticated()
4641    };
4642
4643    let render_result = render_content_internal(
4644        state,
4645        &content,
4646        session.as_ref(),
4647        &user_context,
4648        None,
4649        Some(&directives),
4650        Some(query_params),
4651        &HashMap::new(),
4652        true,
4653        None,
4654    )
4655    .await?;
4656
4657    let mut html = render_result.html;
4658
4659    // Inject CSRF tokens into any forms in the partial
4660    if let Some(ref s) = session {
4661        if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
4662            html = inject_csrf_tokens(&html, csrf);
4663        }
4664    }
4665
4666    Ok(html)
4667}
4668
4669/// Build an HTML response for a partial update, with session cookie if needed.
4670fn build_partial_response(
4671    html: String,
4672    session: Option<&sessions::Session>,
4673    is_new_session: bool,
4674    state: &AppState,
4675) -> Response {
4676    let mut resp_headers: Vec<(header::HeaderName, String)> =
4677        vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())];
4678    if is_new_session {
4679        if let Some(s) = session {
4680            resp_headers.push((
4681                header::SET_COOKIE,
4682                sessions::build_session_cookie(
4683                    &s.id,
4684                    &state.config.session.cookie_name,
4685                    state.config.session.max_age,
4686                    state.config.session.secure,
4687                ),
4688            ));
4689        }
4690    }
4691    build_response(StatusCode::OK, resp_headers, html)
4692}
4693
4694/// Handle file upload via multipart/form-data
4695async fn handle_upload(
4696    State(state): State<AppState>,
4697    Path(collection): Path<String>,
4698    headers: HeaderMap,
4699    Query(params): Query<ActionParams>,
4700    mut multipart: Multipart,
4701) -> impl IntoResponse {
4702    let referer = headers
4703        .get(header::REFERER)
4704        .and_then(|v| v.to_str().ok())
4705        .map(|s| s.to_string());
4706    let (mut session, is_new_session) = extract_session_from_headers(&state, &headers).await;
4707    let redirect_to = params.redirect.as_deref().unwrap_or("/");
4708
4709    // Check if uploads are enabled
4710    if !state.config.uploads.enabled {
4711        if let Some(ref mut sess) = session {
4712            let flash = FlashData {
4713                flash: HashMap::from([(
4714                    "error".to_string(),
4715                    "File uploads are not enabled".to_string(),
4716                )]),
4717                ..Default::default()
4718            };
4719            set_flash_data(sess, &flash);
4720            if let Some(ref sessions) = state.sessions {
4721                let _ = sessions.update(&sess.id, sess.data.clone()).await;
4722            }
4723        }
4724        let fallback = referer.as_deref().unwrap_or(redirect_to);
4725        return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4726    }
4727
4728    let upload_backend = match state.upload_backend {
4729        Some(ref backend) => backend.clone(),
4730        None => {
4731            if let Some(ref mut sess) = session {
4732                let flash = FlashData {
4733                    flash: HashMap::from([(
4734                        "error".to_string(),
4735                        "Upload backend not configured".to_string(),
4736                    )]),
4737                    ..Default::default()
4738                };
4739                set_flash_data(sess, &flash);
4740                if let Some(ref sessions) = state.sessions {
4741                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4742                }
4743            }
4744            let fallback = referer.as_deref().unwrap_or(redirect_to);
4745            return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4746        }
4747    };
4748    // Authorization: uploads obey the collection's create rule. Checked before
4749    // the multipart loop so no files are written on denial.
4750    let actor = extract_actor(&state, &headers, session.as_ref());
4751    let policy = state.policies.get(&collection);
4752    if !policy.allows_create(&actor) {
4753        let is_partial = headers
4754            .get("X-Requested-With")
4755            .and_then(|v| v.to_str().ok())
4756            .map(|v| v == "What")
4757            .unwrap_or(false);
4758        return deny_response(
4759            &state,
4760            &mut session,
4761            is_new_session,
4762            is_partial,
4763            referer.as_deref(),
4764            format!("You don't have permission to upload to {}.", collection),
4765            format!("collection={}; action=upload", collection),
4766        )
4767        .await;
4768    }
4769
4770    let max_size = state.config.uploads.max_size_bytes();
4771    let mut form_fields: HashMap<String, String> = HashMap::new();
4772    let mut uploaded_files: Vec<(String, String)> = Vec::new(); // (field_name, saved_filename)
4773
4774    // Process multipart fields
4775    while let Ok(Some(field)) = multipart.next_field().await {
4776        let field_name = field.name().unwrap_or("").to_string();
4777
4778        if let Some(file_name) = field.file_name().map(|s| s.to_string()) {
4779            // This is a file field
4780            if file_name.is_empty() {
4781                continue; // Skip empty file inputs
4782            }
4783
4784            let content_type = field
4785                .content_type()
4786                .unwrap_or("application/octet-stream")
4787                .to_string();
4788
4789            // Check allowed types
4790            if !state.config.uploads.is_type_allowed(&content_type) {
4791                if let Some(ref mut sess) = session {
4792                    let flash = FlashData {
4793                        flash: HashMap::from([(
4794                            "error".to_string(),
4795                            format!("File type '{}' is not allowed", content_type),
4796                        )]),
4797                        ..Default::default()
4798                    };
4799                    set_flash_data(sess, &flash);
4800                    if let Some(ref sessions) = state.sessions {
4801                        let _ = sessions.update(&sess.id, sess.data.clone()).await;
4802                    }
4803                }
4804                // Clean up any already-saved files
4805                for (_, saved) in &uploaded_files {
4806                    let _ = upload_backend.delete(saved).await;
4807                }
4808                let fallback = referer.as_deref().unwrap_or(redirect_to);
4809                return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4810            }
4811
4812            // Read file data
4813            let data = match field.bytes().await {
4814                Ok(bytes) => bytes,
4815                Err(e) => {
4816                    tracing::error!("Failed to read upload field: {}", e);
4817                    continue;
4818                }
4819            };
4820
4821            // Check file size
4822            if data.len() > max_size {
4823                if let Some(ref mut sess) = session {
4824                    let flash = FlashData {
4825                        flash: HashMap::from([(
4826                            "error".to_string(),
4827                            format!(
4828                                "File '{}' exceeds maximum size of {}",
4829                                file_name, state.config.uploads.max_size
4830                            ),
4831                        )]),
4832                        ..Default::default()
4833                    };
4834                    set_flash_data(sess, &flash);
4835                    if let Some(ref sessions) = state.sessions {
4836                        let _ = sessions.update(&sess.id, sess.data.clone()).await;
4837                    }
4838                }
4839                for (_, saved) in &uploaded_files {
4840                    let _ = upload_backend.delete(saved).await;
4841                }
4842                let fallback = referer.as_deref().unwrap_or(redirect_to);
4843                return redirect_with_session(&state, fallback, session.as_ref(), is_new_session);
4844            }
4845
4846            // Generate unique filename with sanitized extension
4847            let extension = std::path::Path::new(&file_name)
4848                .extension()
4849                .and_then(|e| e.to_str())
4850                .map(|e| sanitize_extension(e))
4851                .unwrap_or_default();
4852            let saved_name = format!("{}{}", uuid::Uuid::new_v4(), extension);
4853
4854            // Save file via upload backend
4855            match upload_backend.put(&saved_name, &data, &content_type).await {
4856                Ok(public_url) => {
4857                    form_fields.insert(field_name.clone(), public_url);
4858                    uploaded_files.push((field_name, saved_name));
4859                }
4860                Err(e) => {
4861                    tracing::error!("Failed to save uploaded file: {}", e);
4862                    if let Some(ref mut sess) = session {
4863                        let flash = FlashData {
4864                            flash: HashMap::from([(
4865                                "error".to_string(),
4866                                "Failed to save uploaded file".to_string(),
4867                            )]),
4868                            ..Default::default()
4869                        };
4870                        set_flash_data(sess, &flash);
4871                        if let Some(ref sessions) = state.sessions {
4872                            let _ = sessions.update(&sess.id, sess.data.clone()).await;
4873                        }
4874                    }
4875                    for (_, saved) in &uploaded_files {
4876                        let _ = upload_backend.delete(saved).await;
4877                    }
4878                    let fallback = referer.as_deref().unwrap_or(redirect_to);
4879                    return redirect_with_session(
4880                        &state,
4881                        fallback,
4882                        session.as_ref(),
4883                        is_new_session,
4884                    );
4885                }
4886            }
4887        } else {
4888            // Regular form field
4889            let value = field.text().await.unwrap_or_default();
4890            form_fields.insert(field_name, value);
4891        }
4892    }
4893
4894    let action_url = format!("/w-upload/{}", collection);
4895
4896    // Validate if w-rules present
4897    if let Err(resp) = validate_form_data(
4898        &state,
4899        &form_fields,
4900        &mut session,
4901        is_new_session,
4902        referer.as_deref(),
4903        Some(action_url.clone()),
4904    )
4905    .await
4906    {
4907        // Clean up uploaded files on validation failure
4908        for (_, saved) in &uploaded_files {
4909            let _ = upload_backend.delete(saved).await;
4910        }
4911        return resp;
4912    }
4913
4914    // Create the record in the data store
4915    let mut item_map: serde_json::Map<String, Value> = form_fields
4916        .into_iter()
4917        .filter(|(k, _)| !k.starts_with("w-"))
4918        .map(|(k, v)| (k, Value::String(v)))
4919        .collect();
4920    policy.sanitize_input(&mut item_map);
4921    policy.stamp_owner(&mut item_map, &actor);
4922
4923    let result = state.store.create(&collection, Value::Object(item_map)).await;
4924    state.cache.invalidate_content_type(&collection).await;
4925
4926    match result {
4927        Ok(_) => {
4928            if let Some(ref mut sess) = session {
4929                let flash = FlashData {
4930                    flash: HashMap::from([(
4931                        "success".to_string(),
4932                        format!("Item created in {}", collection),
4933                    )]),
4934                    ..Default::default()
4935                };
4936                set_flash_data(sess, &flash);
4937                if let Some(ref sessions) = state.sessions {
4938                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4939                }
4940            }
4941            redirect_with_session(&state, redirect_to, session.as_ref(), is_new_session)
4942        }
4943        Err(e) => {
4944            tracing::error!("Upload action error: {}", e);
4945            // Clean up uploaded files on store error
4946            for (_, saved) in &uploaded_files {
4947                let _ = upload_backend.delete(saved).await;
4948            }
4949            if let Some(ref mut sess) = session {
4950                let flash = FlashData {
4951                    flash: HashMap::from([(
4952                        "error".to_string(),
4953                        format!("Failed to create: {}", e),
4954                    )]),
4955                    ..Default::default()
4956                };
4957                set_flash_data(sess, &flash);
4958                if let Some(ref sessions) = state.sessions {
4959                    let _ = sessions.update(&sess.id, sess.data.clone()).await;
4960                }
4961            }
4962            let fallback = referer.as_deref().unwrap_or(redirect_to);
4963            redirect_with_session(&state, fallback, session.as_ref(), is_new_session)
4964        }
4965    }
4966}
4967
4968/// Clean up uploaded files referenced in a data store record
4969async fn cleanup_uploaded_files(root: &std::path::Path, upload_dir: &str, record: &Value) {
4970    if let Value::Object(map) = record {
4971        for (_key, value) in map {
4972            if let Value::String(s) = value {
4973                // Check if the value looks like an upload path
4974                if s.starts_with("/uploads/") {
4975                    let filename = &s["/uploads/".len()..];
4976                    let file_path = root.join(upload_dir).join(filename);
4977                    if file_path.exists() {
4978                        if let Err(e) = tokio::fs::remove_file(&file_path).await {
4979                            tracing::warn!(
4980                                "Failed to delete uploaded file {}: {}",
4981                                file_path.display(),
4982                                e
4983                            );
4984                        } else {
4985                            tracing::debug!("Cleaned up uploaded file: {}", file_path.display());
4986                        }
4987                    }
4988                }
4989            }
4990        }
4991    }
4992}
4993
4994/// Handle session reset - clears the current session and creates a new one
4995async fn handle_session_reset(
4996    State(state): State<AppState>,
4997    headers: HeaderMap,
4998    Form(form): Form<HashMap<String, String>>,
4999) -> impl IntoResponse {
5000    // Extract current session cookie
5001    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5002
5003    // Delete the old session if it exists
5004    if let Some(ref sessions) = state.sessions {
5005        if let Some(session_id) =
5006            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name)
5007        {
5008            let _ = sessions.delete(&session_id).await;
5009        }
5010
5011        // Create a new session
5012        match sessions.create().await {
5013            Ok(new_session) => {
5014                let cookie = sessions::build_session_cookie(
5015                    &new_session.id,
5016                    &state.config.session.cookie_name,
5017                    state.config.session.max_age,
5018                    state.config.session.secure,
5019                );
5020
5021                // Get redirect from form, referer, or default to /state
5022                let redirect_url = form
5023                    .get("redirect")
5024                    .map(|s| s.as_str())
5025                    .or_else(|| headers.get(header::REFERER).and_then(|v| v.to_str().ok()))
5026                    .unwrap_or("/state");
5027
5028                // Redirect back with new session cookie
5029                build_response(
5030                    StatusCode::SEE_OTHER,
5031                    vec![
5032                        (header::LOCATION, redirect_url.to_string()),
5033                        (header::SET_COOKIE, cookie),
5034                    ],
5035                    String::new(),
5036                )
5037            }
5038            Err(e) => {
5039                tracing::error!("Session reset error: {}", e);
5040                build_response(
5041                    StatusCode::INTERNAL_SERVER_ERROR,
5042                    vec![(header::CONTENT_TYPE, "text/plain".to_string())],
5043                    "Failed to reset session".to_string(),
5044                )
5045            }
5046        }
5047    } else {
5048        // Sessions not enabled, just redirect back
5049        Redirect::to("/state").into_response()
5050    }
5051}
5052
5053/// Parsed w-set operation
5054enum SetOp {
5055    Increment(i64),
5056    Decrement(i64),
5057    SetInt(i64),
5058    SetStr(String),
5059}
5060
5061/// Validate that a redirect URL is safe (relative path only, no open redirect).
5062fn sanitize_redirect(url: &str, fallback: &str) -> String {
5063    let trimmed = url.trim();
5064    if trimmed.starts_with('/') && !trimmed.starts_with("//") {
5065        trimmed.to_string()
5066    } else {
5067        fallback.to_string()
5068    }
5069}
5070
5071/// Parse a w-set expression like "session.counter += 1" or "app.visits = 0"
5072fn parse_w_set_expr(expr: &str) -> Option<(String, String, SetOp)> {
5073    let expr = expr.trim();
5074
5075    // Try +=
5076    if let Some((left, right)) = expr.split_once("+=") {
5077        let left = left.trim();
5078        let right = right.trim();
5079        let (scope, key) = left.split_once('.')?;
5080        let val: i64 = right.parse().ok()?;
5081        return Some((scope.to_string(), key.to_string(), SetOp::Increment(val)));
5082    }
5083
5084    // Try -=
5085    if let Some((left, right)) = expr.split_once("-=") {
5086        let left = left.trim();
5087        let right = right.trim();
5088        let (scope, key) = left.split_once('.')?;
5089        let val: i64 = right.parse().ok()?;
5090        return Some((scope.to_string(), key.to_string(), SetOp::Decrement(val)));
5091    }
5092
5093    // Try = (assignment)
5094    if let Some((left, right)) = expr.split_once('=') {
5095        let left = left.trim();
5096        let right = right.trim();
5097        let (scope, key) = left.split_once('.')?;
5098        // Try integer first
5099        if let Ok(val) = right.parse::<i64>() {
5100            return Some((scope.to_string(), key.to_string(), SetOp::SetInt(val)));
5101        }
5102        // Try quoted string: 'value' or "value"
5103        let s = right.trim_matches(|c| c == '\'' || c == '"');
5104        return Some((
5105            scope.to_string(),
5106            key.to_string(),
5107            SetOp::SetStr(s.to_string()),
5108        ));
5109    }
5110
5111    None
5112}
5113
5114/// Apply a SetOp to a current value, returning the new value
5115fn apply_set_op(current: Option<&Value>, op: &SetOp) -> Value {
5116    match op {
5117        SetOp::Increment(delta) => {
5118            let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5119            json!(cur + delta)
5120        }
5121        SetOp::Decrement(delta) => {
5122            let cur = current.and_then(|v| v.as_i64()).unwrap_or(0);
5123            json!(cur - delta)
5124        }
5125        SetOp::SetInt(val) => json!(val),
5126        SetOp::SetStr(val) => json!(val),
5127    }
5128}
5129
5130/// Convert a Value to its display string for OOB updates
5131fn value_display_string(v: &Value) -> String {
5132    match v {
5133        Value::String(s) => s.clone(),
5134        Value::Null => String::new(),
5135        other => other.to_string(),
5136    }
5137}
5138
5139/// Handle w-set attribute — declarative state mutations from HTML.
5140/// Supports multiple expressions separated by semicolons:
5141///   w-set="session.counter += 1; app.total += 1"
5142async fn handle_w_set(
5143    State(state): State<AppState>,
5144    headers: HeaderMap,
5145    Form(params): Form<HashMap<String, String>>,
5146) -> impl IntoResponse {
5147    let expr_str = match params.get("expr") {
5148        Some(e) => e.as_str(),
5149        None => return (StatusCode::BAD_REQUEST, "Missing expr").into_response(),
5150    };
5151
5152    // Parse all expressions upfront (fail fast on any invalid)
5153    let mut parsed: Vec<(String, String, SetOp)> = Vec::new();
5154    for part in expr_str.split(';') {
5155        let part = part.trim();
5156        if part.is_empty() {
5157            continue;
5158        }
5159        match parse_w_set_expr(part) {
5160            Some((scope, key, op)) => {
5161                if scope != "session" && scope != "app" && scope != "wired" {
5162                    return (
5163                        StatusCode::BAD_REQUEST,
5164                        "Invalid scope (use session, app, or wired)",
5165                    )
5166                        .into_response();
5167                }
5168                parsed.push((scope, key, op));
5169            }
5170            None => {
5171                return (
5172                    StatusCode::BAD_REQUEST,
5173                    format!("Invalid expression: {}", part),
5174                )
5175                    .into_response();
5176            }
5177        }
5178    }
5179
5180    if parsed.is_empty() {
5181        return (StatusCode::BAD_REQUEST, "Empty expression").into_response();
5182    }
5183
5184    let mut oob_map = serde_json::Map::new();
5185    let mut wired_updates: Vec<(String, String)> = Vec::new(); // (key, display_value)
5186    let mut session_mutations: Vec<(String, SetOp)> = Vec::new();
5187
5188    // Extract user_id for [user] scoped wired variables
5189    let cookie_header_str = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5190    let mutator_user_id: Option<String> = if state.auth.is_enabled() {
5191        state
5192            .auth
5193            .parse_jwt_cookie(cookie_header_str)
5194            .and_then(|token| {
5195                state.auth.decode_jwt(&token).ok().and_then(|claims| {
5196                    if claims.is_expired() {
5197                        return None;
5198                    }
5199                    claims.sub.clone()
5200                })
5201            })
5202    } else {
5203        None
5204    };
5205
5206    // Require authentication for app.* and wired.* mutations (shared state)
5207    // Session mutations are always allowed (per-user scope)
5208    let has_shared_mutation = parsed.iter().any(|(s, _, _)| s == "app" || s == "wired");
5209    if has_shared_mutation {
5210        let cookie_header_for_auth = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5211        let is_authenticated = if state.auth.is_enabled() {
5212            state
5213                .auth
5214                .parse_jwt_cookie(cookie_header_for_auth)
5215                .and_then(|token| state.auth.decode_jwt(&token).ok())
5216                .map(|claims| !claims.is_expired())
5217                .unwrap_or(false)
5218        } else {
5219            // If auth is not enabled, require at least a valid session
5220            let session_id = sessions::parse_session_cookie(
5221                cookie_header_for_auth,
5222                &state.config.session.cookie_name,
5223            );
5224            session_id.is_some()
5225        };
5226        if !is_authenticated {
5227            return (
5228                StatusCode::FORBIDDEN,
5229                "Authentication required for app/wired mutations",
5230            )
5231                .into_response();
5232        }
5233
5234        // Role/user scope gating: a `wired.x [admin]` or `app.x [admin]`
5235        // declaration restricts WHO may write the key, not just who receives it.
5236        let mutator_roles = extract_user_context(&state, &headers).roles();
5237        for (mscope, mkey, _) in &parsed {
5238            let var_scope = match mscope.as_str() {
5239                "wired" => state.get_wired_scope(mkey).await,
5240                "app" => state.get_app_scope(mkey).await,
5241                _ => continue,
5242            };
5243            let allowed = match &var_scope {
5244                WiredScope::Public => true,
5245                // Role scope: mutator must hold a matching role. Requires auth —
5246                // with auth disabled there are no roles, so this fails closed.
5247                WiredScope::Roles(_) => {
5248                    state.auth.is_enabled()
5249                        && var_scope.allows(&mutator_roles, mutator_user_id.as_deref())
5250                }
5251                // [user] scope on a write means any authenticated user (the
5252                // value is shared; delivery is already per-user).
5253                WiredScope::User(_) => state.auth.is_enabled() && mutator_user_id.is_some(),
5254            };
5255            if !allowed {
5256                tracing::info!(
5257                    target: "what::policy",
5258                    "w-set denied: {}.{} requires scope {:?}",
5259                    mscope, mkey, var_scope
5260                );
5261                let mut resp = (
5262                    StatusCode::FORBIDDEN,
5263                    format!("Insufficient permissions for {}.{}", mscope, mkey),
5264                )
5265                    .into_response();
5266                if state.dev_mode {
5267                    if let Ok(hv) = format!("deny; {}.{}; scope={:?}", mscope, mkey, var_scope).parse()
5268                    {
5269                        resp.headers_mut()
5270                            .insert(header::HeaderName::from_static("x-what-policy"), hv);
5271                    }
5272                }
5273                return resp;
5274            }
5275        }
5276    }
5277
5278    // Process each expression — app/wired mutations atomically, collect session mutations
5279    for (scope, key, op) in parsed {
5280        if scope == "app" || scope == "wired" {
5281            match state
5282                .store
5283                .atomic_modify(&key, move |current| apply_set_op(current, &op))
5284                .await
5285            {
5286                Ok(val) => {
5287                    let display = value_display_string(&val);
5288                    if scope == "wired" {
5289                        oob_map.insert(format!("wired.{}", key), Value::String(display.clone()));
5290                        wired_updates.push((key, display));
5291                    } else {
5292                        oob_map.insert(format!("app.{}", key), Value::String(display));
5293                    }
5294                }
5295                Err(e) => {
5296                    tracing::error!("w-set {} mutation failed: {}", scope, e);
5297                    return (StatusCode::INTERNAL_SERVER_ERROR, "Mutation failed").into_response();
5298                }
5299            }
5300        } else {
5301            // Block mutations to reserved session keys (prefix _)
5302            if key.starts_with('_') {
5303                tracing::warn!("w-set blocked: reserved session key '{}'", key);
5304                continue;
5305            }
5306            session_mutations.push((key, op));
5307        }
5308    }
5309
5310    // Broadcast wired updates with scope filtering
5311    for (key, display) in wired_updates {
5312        let mut wired_map = serde_json::Map::new();
5313        wired_map.insert(format!("wired.{}", key), Value::String(display));
5314        let json = serde_json::to_string(&Value::Object(wired_map)).unwrap_or_default();
5315        let mut scope = state.get_wired_scope(&key).await;
5316        // For [user] scope, fill in the mutator's user_id
5317        if matches!(scope, WiredScope::User(ref uid) if uid.is_empty()) {
5318            scope = WiredScope::User(mutator_user_id.clone().unwrap_or_default());
5319        }
5320        let _ = state.wired_tx.send(WiredMessage { json, scope });
5321    }
5322
5323    // Apply session mutations atomically (each uses SQL-level json_set, no read-modify-write race)
5324    if !session_mutations.is_empty() {
5325        let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5326
5327        if let Some(ref sessions) = state.sessions {
5328            let session_id =
5329                sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5330
5331            if let Some(id) = session_id {
5332                for (key, op) in &session_mutations {
5333                    let atomic_op = match op {
5334                        SetOp::Increment(n) => sessions::AtomicMutation::Increment {
5335                            key: key.clone(),
5336                            value: *n,
5337                        },
5338                        SetOp::Decrement(n) => sessions::AtomicMutation::Increment {
5339                            key: key.clone(),
5340                            value: -*n,
5341                        },
5342                        SetOp::SetInt(n) => sessions::AtomicMutation::Set {
5343                            key: key.clone(),
5344                            value: serde_json::json!(*n),
5345                        },
5346                        SetOp::SetStr(s) => sessions::AtomicMutation::Set {
5347                            key: key.clone(),
5348                            value: serde_json::json!(s),
5349                        },
5350                    };
5351
5352                    match sessions.apply_mutation(&id, &atomic_op).await {
5353                        Ok(data) => {
5354                            if let Some(val) = data.get(key) {
5355                                oob_map.insert(
5356                                    format!("session.{}", key),
5357                                    Value::String(value_display_string(val)),
5358                                );
5359                            }
5360                        }
5361                        Err(e) => {
5362                            tracing::error!("w-set session mutation failed: {}", e);
5363                        }
5364                    }
5365                }
5366            }
5367        }
5368    }
5369
5370    // Check if this is a partial (AJAX) request
5371    let is_partial = headers
5372        .get("X-Requested-With")
5373        .and_then(|v| v.to_str().ok())
5374        .map(|v| v == "What")
5375        .unwrap_or(false);
5376
5377    if is_partial {
5378        let json_str = serde_json::to_string(&Value::Object(oob_map)).unwrap_or_default();
5379        let oob = format!(r##"<template data-what-updates>{}</template>"##, json_str);
5380        return Html(oob).into_response();
5381    }
5382
5383    // Full-page request: redirect back (extract path only to prevent open redirect)
5384    let redirect_url = headers
5385        .get(header::REFERER)
5386        .and_then(|v| v.to_str().ok())
5387        .and_then(|url| {
5388            // Extract path component from full URL to prevent open redirect
5389            if let Some(idx) = url.find("://") {
5390                // Full URL: find path after host
5391                url[idx + 3..].find('/').map(|i| &url[idx + 3 + i..])
5392            } else {
5393                Some(url)
5394            }
5395        })
5396        .map(|path| sanitize_redirect(path, "/"))
5397        .unwrap_or_else(|| "/".to_string());
5398
5399    Redirect::to(&redirect_url).into_response()
5400}
5401
5402/// Handle clearing session data (keeps session, clears data)
5403async fn handle_session_clear_data(
5404    State(state): State<AppState>,
5405    headers: HeaderMap,
5406) -> impl IntoResponse {
5407    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5408
5409    let referer = headers
5410        .get(header::REFERER)
5411        .and_then(|v| v.to_str().ok())
5412        .unwrap_or("/");
5413    let redirect_to = sanitize_redirect(referer, "/");
5414
5415    if let Some(ref sessions) = state.sessions {
5416        let session_id =
5417            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5418
5419        if let Some(id) = session_id {
5420            // Clear session data by updating with empty HashMap
5421            if let Err(e) = sessions.update(&id, std::collections::HashMap::new()).await {
5422                tracing::error!("Failed to clear session data: {}", e);
5423            }
5424        }
5425    }
5426
5427    Redirect::to(&redirect_to).into_response()
5428}
5429
5430/// Handle injection notification demo - increments session counter and returns HTML
5431async fn handle_inject_notification(
5432    State(state): State<AppState>,
5433    headers: HeaderMap,
5434) -> impl IntoResponse {
5435    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5436
5437    let mut count: i64 = 1;
5438
5439    if let Some(ref sessions) = state.sessions {
5440        let session_id =
5441            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5442
5443        if let Some(id) = session_id {
5444            if let Ok(Some(mut session)) = sessions.get(&id).await {
5445                // Get current injection count
5446                let current = session
5447                    .data
5448                    .get("inject_count")
5449                    .and_then(|v| v.as_i64())
5450                    .unwrap_or(0);
5451
5452                // Increment
5453                count = current + 1;
5454                session
5455                    .data
5456                    .insert("inject_count".to_string(), json!(count));
5457
5458                // Save session data
5459                if let Err(e) = sessions.update(&id, session.data).await {
5460                    tracing::error!("Failed to update inject count: {}", e);
5461                }
5462            }
5463        }
5464    }
5465
5466    // Return HTML partial with the counter
5467    let html = format!(
5468        r#"<div class="p-4 bg-blue-100 border border-blue-300 rounded flex items-center gap-3">
5469  <span class="text-2xl">&#x1F514;</span>
5470  <div>
5471    <strong>Notification {}</strong>
5472    <p class="text-sm text-gray-600">Injected without reloading the page</p>
5473  </div>
5474</div>"#,
5475        count
5476    );
5477
5478    axum::response::Html(html).into_response()
5479}
5480
5481/// Handle login form submission
5482/// Receives form data, calls the backend login endpoint, and sets the JWT cookie
5483async fn handle_login(
5484    State(state): State<AppState>,
5485    _headers: HeaderMap,
5486    Form(form_data): Form<HashMap<String, String>>,
5487) -> impl IntoResponse {
5488    // Get login endpoint from config
5489    let login_endpoint = match state.auth.login_endpoint() {
5490        Some(url) => url.to_string(),
5491        None => {
5492            tracing::error!("Login endpoint not configured");
5493            let msg = if state.dev_mode {
5494                "Login not configured"
5495            } else {
5496                "Something went wrong"
5497            };
5498            return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
5499        }
5500    };
5501
5502    // Get redirect URL from form or use default (validated against open redirect)
5503    let fallback = state.auth.after_login_path().to_string();
5504    let redirect_url = form_data
5505        .get("redirect")
5506        .or_else(|| form_data.get("w-redirect"))
5507        .map(|s| sanitize_redirect(s, &fallback))
5508        .unwrap_or(fallback);
5509
5510    // Filter out internal fields and prepare login request
5511    let login_data: HashMap<String, String> = form_data
5512        .into_iter()
5513        .filter(|(k, _)| !k.starts_with("w-") && k != "redirect")
5514        .collect();
5515
5516    // Call backend login endpoint
5517    let response = match state
5518        .http_client
5519        .post(&login_endpoint)
5520        .json(&login_data)
5521        .send()
5522        .await
5523    {
5524        Ok(resp) => resp,
5525        Err(e) => {
5526            tracing::error!("Login request failed: {}", e);
5527            return Redirect::to(&format!("{}?error=connection", state.auth.login_path()))
5528                .into_response();
5529        }
5530    };
5531
5532    if !response.status().is_success() {
5533        tracing::warn!("Login failed with status: {}", response.status());
5534        return Redirect::to(&format!("{}?error=invalid", state.auth.login_path())).into_response();
5535    }
5536
5537    // Extract token from response
5538    // Try to get token from JSON response body
5539    let token = match response.json::<serde_json::Value>().await {
5540        Ok(json) => {
5541            // Try common field names: token, access_token, jwt
5542            json.get("token")
5543                .or_else(|| json.get("access_token"))
5544                .or_else(|| json.get("jwt"))
5545                .and_then(|v| v.as_str())
5546                .map(|s| s.to_string())
5547        }
5548        Err(e) => {
5549            tracing::error!("Failed to parse login response: {}", e);
5550            None
5551        }
5552    };
5553
5554    match token {
5555        Some(jwt) => {
5556            // Build JWT cookie
5557            let cookie = state.auth.build_jwt_cookie(
5558                &jwt,
5559                state.config.session.max_age,
5560                state.config.session.secure,
5561            );
5562
5563            let mut response_headers = vec![
5564                (header::LOCATION, redirect_url),
5565                (header::SET_COOKIE, cookie),
5566            ];
5567
5568            // Session fixation prevention: regenerate session ID after successful login
5569            if let Some(ref sessions) = state.sessions {
5570                let cookie_header = _headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
5571                let old_id = sessions::parse_session_cookie(
5572                    cookie_header,
5573                    &state.config.session.cookie_name,
5574                );
5575
5576                if let Some(ref old) = old_id {
5577                    // Get old session data, create new session, copy data, delete old
5578                    if let Ok(Some(old_session)) = sessions.get(old).await {
5579                        if let Ok(new_session) = sessions.create().await {
5580                            let mut data = old_session.data;
5581                            // Preserve CSRF token (regenerate for new session)
5582                            data.insert(
5583                                sessions::CSRF_TOKEN_KEY.to_string(),
5584                                serde_json::json!(sessions::generate_csrf_token()),
5585                            );
5586                            let _ = sessions.update(&new_session.id, data).await;
5587                            let _ = sessions.delete(old).await;
5588                            response_headers.push((
5589                                header::SET_COOKIE,
5590                                sessions::build_session_cookie(
5591                                    &new_session.id,
5592                                    &state.config.session.cookie_name,
5593                                    state.config.session.max_age,
5594                                    state.config.session.secure,
5595                                ),
5596                            ));
5597                        }
5598                    }
5599                }
5600            }
5601
5602            build_response(StatusCode::SEE_OTHER, response_headers, String::new())
5603        }
5604        None => {
5605            Redirect::to(&format!("{}?error=no_token", state.auth.login_path())).into_response()
5606        }
5607    }
5608}
5609
5610/// Handle logout - clears the JWT cookie and optionally calls backend logout
5611async fn handle_logout(
5612    State(state): State<AppState>,
5613    request_headers: HeaderMap,
5614    Form(form_data): Form<HashMap<String, String>>,
5615) -> impl IntoResponse {
5616    // Get redirect URL (validated against open redirect)
5617    let redirect_url = form_data
5618        .get("redirect")
5619        .or_else(|| form_data.get("w-redirect"))
5620        .map(|s| sanitize_redirect(s, "/"))
5621        .unwrap_or_else(|| "/".to_string());
5622
5623    // Optionally call backend logout endpoint
5624    if let Some(logout_endpoint) = state.auth.logout_endpoint() {
5625        // Extract JWT to send with logout request
5626        let cookie_header = request_headers
5627            .get(header::COOKIE)
5628            .and_then(|v| v.to_str().ok());
5629
5630        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
5631            let _ = state
5632                .http_client
5633                .post(logout_endpoint)
5634                .bearer_auth(&token)
5635                .send()
5636                .await;
5637        }
5638    }
5639
5640    // Clear the JWT cookie
5641    let clear_cookie = state.auth.build_clear_cookie();
5642
5643    // Redirect with cleared cookie
5644    build_response(
5645        StatusCode::SEE_OTHER,
5646        vec![
5647            (header::LOCATION, redirect_url),
5648            (header::SET_COOKIE, clear_cookie),
5649        ],
5650        String::new(),
5651    )
5652}
5653
5654/// Handle clearing all server-side caches (dev mode only)
5655async fn handle_cache_clear_all(State(state): State<AppState>) -> impl IntoResponse {
5656    // Clear all caches
5657    state.cache.clear_all().await;
5658
5659    tracing::info!("All server caches cleared");
5660
5661    // Return JSON response
5662    axum::Json(serde_json::json!({
5663        "success": true,
5664        "message": "All caches cleared"
5665    }))
5666}
5667
5668/// Handle listing all sessions (dev mode only)
5669async fn handle_sessions_list(State(state): State<AppState>) -> impl IntoResponse {
5670    match &state.sessions {
5671        Some(sessions) => {
5672            let count = sessions.count().await.unwrap_or(0);
5673            let ids = sessions.list_session_ids().await.unwrap_or_default();
5674
5675            axum::Json(serde_json::json!({
5676                "count": count,
5677                "ids": ids
5678            }))
5679        }
5680        None => axum::Json(serde_json::json!({
5681            "error": "Sessions not enabled",
5682            "count": 0,
5683            "ids": []
5684        })),
5685    }
5686}
5687
5688/// Handle data info request (dev mode only)
5689async fn handle_data_info(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
5690    // Get session data if available
5691    let session_data = if let Some(ref sessions) = state.sessions {
5692        let cookie_header = headers.get(header::COOKIE).and_then(|h| h.to_str().ok());
5693        let session_id =
5694            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
5695
5696        if let Some(id) = session_id {
5697            if let Ok(Some(session)) = sessions.get(&id).await {
5698                session.data.clone()
5699            } else {
5700                std::collections::HashMap::new()
5701            }
5702        } else {
5703            std::collections::HashMap::new()
5704        }
5705    } else {
5706        std::collections::HashMap::new()
5707    };
5708
5709    // Get application data from store
5710    let application_data = state.store.as_context().await;
5711
5712    axum::Json(serde_json::json!({
5713        "application": application_data,
5714        "session": session_data
5715    }))
5716}
5717
5718// ---------------------------------------------------------------------------
5719// Dev Inspector (dev-mode only, /w-inspector)
5720// ---------------------------------------------------------------------------
5721
5722/// Resolved metadata for one route, for the inspector's Routes panel.
5723struct RouteMeta {
5724    url: String,
5725    dynamic: bool,
5726    auth: String,
5727    layout: String,
5728    missing: bool,
5729}
5730
5731/// The auth a directive set imposes, or None if it leaves the page public.
5732/// Accounts for legacy `protected`/`roles`.
5733fn inspector_auth_display(d: &PageDirectives) -> Option<String> {
5734    use crate::parser::AuthLevel;
5735    match &d.auth {
5736        AuthLevel::User => Some("user".to_string()),
5737        AuthLevel::Roles(v) => Some(format!("roles: {}", v.join(", "))),
5738        AuthLevel::All => {
5739            if d.protected {
5740                if d.roles.is_empty() {
5741                    Some("user (protected)".to_string())
5742                } else {
5743                    Some(format!("roles: {} (legacy)", d.roles.join(", ")))
5744                }
5745            } else {
5746                None
5747            }
5748        }
5749    }
5750}
5751
5752/// Resolve a route's effective auth + layout the way the render path does:
5753/// the page's own `<what>` wins; `application.what` applies only when the page
5754/// is public.
5755fn resolve_route_meta(root: &PathBuf, url_path: &str, dynamic: bool) -> RouteMeta {
5756    let Some(resolved) = resolve_page_path(root, url_path) else {
5757        return RouteMeta {
5758            url: url_path.to_string(),
5759            dynamic,
5760            auth: String::new(),
5761            layout: String::new(),
5762            missing: true,
5763        };
5764    };
5765    let raw = std::fs::read_to_string(&resolved.path).unwrap_or_default();
5766    let (directives, _) = parse_page_directives(&raw);
5767    let app_config = load_application_config(root, url_path);
5768
5769    let auth = inspector_auth_display(&directives)
5770        .or_else(|| inspector_auth_display(&app_config.directives))
5771        .unwrap_or_else(|| "all".to_string());
5772
5773    let layout = directives.layout.clone().or_else(|| app_config.layout.clone());
5774    let layout = match layout.as_deref() {
5775        Some("none") => "none (disabled)".to_string(),
5776        Some(l) => l.to_string(),
5777        None => "(default)".to_string(),
5778    };
5779
5780    RouteMeta { url: url_path.to_string(), dynamic, auth, layout, missing: false }
5781}
5782
5783/// Recursively collect `application.what` files under a directory (relative
5784/// paths + parsed config), for the inspector's inheritance panel.
5785fn collect_application_what_files(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<(String, WhatConfig)>) {
5786    let cfg = dir.join("application.what");
5787    if cfg.exists() {
5788        if let Ok(content) = std::fs::read_to_string(&cfg) {
5789            let rel = cfg.strip_prefix(root).unwrap_or(&cfg).to_string_lossy().to_string();
5790            out.push((rel, parse_what_file(&content)));
5791        }
5792    }
5793    if let Ok(entries) = std::fs::read_dir(dir) {
5794        let mut dirs: Vec<_> = entries
5795            .flatten()
5796            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
5797            .map(|e| e.path())
5798            .collect();
5799        dirs.sort();
5800        for d in dirs {
5801            collect_application_what_files(&d, root, out);
5802        }
5803    }
5804}
5805
5806/// Dev-mode inspector dashboard — a single self-contained, zero-JS HTML page
5807/// that surfaces the app's routes, collections/policies, config inheritance,
5808/// sessions, scopes, template lints, and recent activity. Registered only in
5809/// dev mode. `?refresh=N` (1–60 seconds) opts into meta-refresh auto-reload.
5810async fn handle_inspector(
5811    State(state): State<AppState>,
5812    Query(params): Query<HashMap<String, String>>,
5813) -> axum::response::Response {
5814    let esc = engine::escape_html;
5815    let refresh: Option<u32> = params
5816        .get("refresh")
5817        .and_then(|v| v.parse().ok())
5818        .filter(|n| (1..=60).contains(n));
5819    let mut b = String::new();
5820
5821    // ---- Panel 1: Overview ----
5822    b.push_str("<section id=\"overview\"><h2>Overview</h2><table>");
5823    let cfg = &state.config;
5824    let rows = [
5825        ("Framework version", env!("CARGO_PKG_VERSION").to_string()),
5826        ("Mode", if state.dev_mode { "development".into() } else { "production".into() }),
5827        ("Host : Port", format!("{}:{}", cfg.server.host, cfg.server.port)),
5828        ("CSS mode", format!("{:?}", state.css_mode).to_lowercase()),
5829        ("Sessions", if cfg.session.enabled { "enabled".into() } else { "disabled".into() }),
5830        ("Auth", if cfg.auth.enabled { "enabled".into() } else { "disabled".into() }),
5831        ("Uploads", if cfg.uploads.enabled { "enabled".into() } else { "disabled".into() }),
5832        ("Source viewer", if cfg.server.source_viewer { "on".into() } else { "off".into() }),
5833        ("Strict mode", if cfg.strict { "on".into() } else { "off".into() }),
5834    ];
5835    for (k, v) in rows {
5836        b.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(&v)));
5837    }
5838    b.push_str("</table></section>");
5839
5840    // ---- Panel 2: Routes ----
5841    b.push_str("<section id=\"routes\"><h2>Routes</h2><table><tr><th>Path</th><th>Auth</th><th>Layout</th><th></th></tr>");
5842    let routes = discover_routes(&state.root);
5843    for (url, dynamic) in &routes {
5844        let m = resolve_route_meta(&state.root, url, *dynamic);
5845        let tags = format!(
5846            "{}{}",
5847            if m.dynamic { "<span class=\"tag\">dynamic</span>" } else { "" },
5848            if m.missing { "<span class=\"tag warn\">missing file</span>" } else { "" },
5849        );
5850        b.push_str(&format!(
5851            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
5852            esc(&m.url), esc(&m.auth), esc(&m.layout), tags
5853        ));
5854    }
5855    b.push_str(&format!("</table><p class=\"muted\">{} route(s).</p></section>", routes.len()));
5856
5857    // ---- Panel 3: Collections & Policies ----
5858    b.push_str("<section id=\"collections\"><h2>Collections &amp; Policies</h2>");
5859    b.push_str("<table><tr><th>Collection</th><th>Rows</th><th>Policy</th></tr>");
5860    let ctx = state.store.as_context().await;
5861    let mut names: std::collections::BTreeSet<String> = ctx.keys().cloned().collect();
5862    for (name, _) in state.policies.configured() {
5863        names.insert(name.to_string());
5864    }
5865    if names.is_empty() {
5866        b.push_str("<tr><td colspan=\"3\" class=\"muted\">No collections yet.</td></tr>");
5867    }
5868    for name in &names {
5869        let count = ctx.get(name).and_then(|v| v.as_array()).map(|a| a.len());
5870        let count_str = count.map(|c| c.to_string()).unwrap_or_else(|| "&mdash;".to_string());
5871        let p = state.policies.get(name);
5872        let badge = if p.explicit { "<span class=\"tag\">explicit</span>" } else { "<span class=\"tag muted-tag\">default</span>" };
5873        let mut policy = format!(
5874            "create=<b>{}</b> · read=<b>{}</b> · update=<b>{}</b> · delete=<b>{}</b> · owner={}",
5875            esc(&p.create.to_string()), esc(&p.read.to_string()),
5876            esc(&p.update.to_string()), esc(&p.delete.to_string()), esc(&p.owner_mode.to_string()),
5877        );
5878        if let Some(f) = &p.filter {
5879            policy.push_str(&format!(" · filter=<code>{}</code>", esc(f)));
5880        }
5881        if !p.readonly_fields.is_empty() {
5882            policy.push_str(&format!(" · readonly=[{}]", esc(&p.readonly_fields.join(", "))));
5883        }
5884        if !p.private_fields.is_empty() {
5885            policy.push_str(&format!(" · private=[{}]", esc(&p.private_fields.join(", "))));
5886        }
5887        b.push_str(&format!(
5888            "<tr><td><code>{}</code> {}</td><td>{}</td><td class=\"policy\">{}</td></tr>",
5889            esc(name), badge, count_str, policy
5890        ));
5891    }
5892    b.push_str("</table></section>");
5893
5894    // ---- Panel 4: Config & inheritance ----
5895    b.push_str("<section id=\"config\"><h2>application.what Inheritance</h2>");
5896    let mut app_files = Vec::new();
5897    collect_application_what_files(&state.content_dir, &state.root, &mut app_files);
5898    if app_files.is_empty() {
5899        b.push_str("<p class=\"muted\">No application.what files.</p>");
5900    } else {
5901        b.push_str("<table><tr><th>File</th><th>Declares</th></tr>");
5902        for (rel, wc) in &app_files {
5903            let mut parts = Vec::new();
5904            if let Some(a) = inspector_auth_display(&wc.directives) {
5905                parts.push(format!("auth={}", a));
5906            }
5907            let layout = wc.directives.layout.clone().or_else(|| wc.layout.clone());
5908            if let Some(l) = layout {
5909                parts.push(format!("layout={}", l));
5910            }
5911            if !wc.data_application.is_empty() {
5912                let ns: Vec<&str> = wc.data_application.iter().map(|d| d.name.as_str()).collect();
5913                parts.push(format!("data.application=[{}]", ns.join(", ")));
5914            }
5915            if !wc.data_wired.is_empty() {
5916                let ns: Vec<&str> = wc.data_wired.iter().map(|d| d.name.as_str()).collect();
5917                parts.push(format!("data.wired=[{}]", ns.join(", ")));
5918            }
5919            if !wc.data_session.is_empty() {
5920                parts.push(format!("data.session=[{}]", wc.data_session.join(", ")));
5921            }
5922            let desc = if parts.is_empty() { "(nothing auth/layout/data)".to_string() } else { parts.join(" · ") };
5923            b.push_str(&format!("<tr><td><code>{}</code></td><td>{}</td></tr>", esc(rel), esc(&desc)));
5924        }
5925        b.push_str("</table><p class=\"muted\">Child directories override parents.</p>");
5926    }
5927    b.push_str("</section>");
5928
5929    // ---- Panel 5: Sessions ----
5930    b.push_str("<section id=\"sessions\"><h2>Sessions</h2>");
5931    match &state.sessions {
5932        Some(s) => {
5933            let count = s.count().await.unwrap_or(0);
5934            let ids = s.list_session_ids().await.unwrap_or_default();
5935            b.push_str(&format!("<p><b>{}</b> active session(s).</p>", count));
5936            if !ids.is_empty() {
5937                b.push_str("<details><summary>Session IDs</summary><ul>");
5938                for id in ids.iter().take(200) {
5939                    let short: String = id.chars().take(16).collect();
5940                    b.push_str(&format!("<li><code>{}…</code></li>", esc(&short)));
5941                }
5942                b.push_str("</ul></details>");
5943            }
5944        }
5945        None => b.push_str("<p class=\"muted\">Sessions disabled.</p>"),
5946    }
5947    b.push_str("</section>");
5948
5949    // ---- Panel 6: Scopes ----
5950    b.push_str("<section id=\"scopes\"><h2>Write Scopes</h2>");
5951    let wired = state.wired_scopes.read().await;
5952    let app = state.app_scopes.read().await;
5953    if wired.is_empty() && app.is_empty() {
5954        b.push_str("<p class=\"muted\">No scoped wired/application variables.</p>");
5955    } else {
5956        b.push_str("<table><tr><th>Variable</th><th>Kind</th><th>Scope</th></tr>");
5957        let mut wk: Vec<_> = wired.iter().collect();
5958        wk.sort_by_key(|(k, _)| k.clone());
5959        for (k, v) in wk {
5960            b.push_str(&format!("<tr><td><code>wired.{}</code></td><td>wired</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
5961        }
5962        let mut ak: Vec<_> = app.iter().collect();
5963        ak.sort_by_key(|(k, _)| k.clone());
5964        for (k, v) in ak {
5965            b.push_str(&format!("<tr><td><code>app.{}</code></td><td>application</td><td>{}</td></tr>", esc(k), esc(&v.to_string())));
5966        }
5967        b.push_str("</table>");
5968    }
5969    b.push_str("</section>");
5970
5971    // ---- Panel 7: Template lints ----
5972    b.push_str("<section id=\"lints\"><h2>Template Lints</h2>");
5973    let mut total = 0;
5974    let mut lint_rows = String::new();
5975    for (url, dynamic) in &routes {
5976        if let Some(resolved) = resolve_page_path(&state.root, url) {
5977            let _ = dynamic;
5978            if let Ok(raw) = std::fs::read_to_string(&resolved.path) {
5979                let lints = engine::collect_template_lints(&raw);
5980                for l in lints {
5981                    total += 1;
5982                    lint_rows.push_str(&format!(
5983                        "<tr><td><code>{}</code></td><td><span class=\"tag warn\">{}</span></td><td>{}</td></tr>",
5984                        esc(url), esc(l.kind), esc(&l.message)
5985                    ));
5986                }
5987            }
5988        }
5989    }
5990    if total == 0 {
5991        b.push_str("<p class=\"ok\">✓ No template issues found.</p>");
5992    } else {
5993        b.push_str(&format!("<table><tr><th>Page</th><th>Kind</th><th>Detail</th></tr>{}</table>", lint_rows));
5994    }
5995    b.push_str("</section>");
5996
5997    // ---- Panel 8: Activity feed ----
5998    b.push_str("<section id=\"activity\"><h2>Activity");
5999    if refresh.is_some() {
6000        b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector#activity\">⏸ stop auto-refresh</a>");
6001    } else {
6002        b.push_str(" <a class=\"refresh-link\" href=\"/w-inspector?refresh=2#activity\">↻ auto-refresh (2s)</a>");
6003    }
6004    b.push_str("</h2>");
6005    let events: Vec<ActivityEvent> = {
6006        let log = state.activity_log.lock().unwrap();
6007        log.iter().rev().cloned().collect()
6008    };
6009    if events.is_empty() {
6010        b.push_str("<p class=\"muted\">No activity yet — make some requests.</p>");
6011    } else {
6012        b.push_str("<table><tr><th>Time</th><th>Kind</th><th>Detail</th></tr>");
6013        for ev in &events {
6014            match ev {
6015                ActivityEvent::Request { time, method, path, status, duration_ms } => {
6016                    let tag_class = if *status >= 400 { "tag warn" } else { "tag" };
6017                    b.push_str(&format!(
6018                        "<tr><td class=\"muted\">{}</td><td><span class=\"{}\">request</span></td><td><code>{} {}</code> → {} ({}ms)</td></tr>",
6019                        time.format("%H:%M:%S"), tag_class, esc(method), esc(path), status, duration_ms
6020                    ));
6021                }
6022                ActivityEvent::PolicyDenial { time, detail } => {
6023                    b.push_str(&format!(
6024                        "<tr><td class=\"muted\">{}</td><td><span class=\"tag warn\">deny</span></td><td>{}</td></tr>",
6025                        time.format("%H:%M:%S"), esc(detail)
6026                    ));
6027                }
6028                ActivityEvent::Fetch { time, key, url, elapsed_ms, result } => {
6029                    b.push_str(&format!(
6030                        "<tr><td class=\"muted\">{}</td><td><span class=\"tag muted-tag\">fetch</span></td><td><code>{}</code> ← <code>{}</code> → {} ({}ms)</td></tr>",
6031                        time.format("%H:%M:%S"), esc(key), esc(url), esc(result), elapsed_ms
6032                    ));
6033                }
6034            }
6035        }
6036        b.push_str("</table>");
6037    }
6038    b.push_str("</section>");
6039
6040    let html = render_inspector_shell(&b, refresh);
6041    build_response(
6042        StatusCode::OK,
6043        vec![(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
6044        html,
6045    )
6046}
6047
6048/// Wrap the inspector panels in a self-contained, zero-JS HTML page.
6049/// `refresh` injects a meta-refresh tag for opt-in auto-reload (`?refresh=N`).
6050fn render_inspector_shell(body: &str, refresh: Option<u32>) -> String {
6051    let refresh_meta = refresh
6052        .map(|n| format!("<meta http-equiv=\"refresh\" content=\"{n}\">"))
6053        .unwrap_or_default();
6054    format!(
6055        r##"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">{refresh_meta}<title>What Inspector</title><style>
6056:root {{ color-scheme: light; }}
6057* {{ box-sizing: border-box; }}
6058body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: #1f2937; background: #f8fafc; }}
6059header {{ background: #0f172a; color: #e2e8f0; padding: 14px 20px; position: sticky; top: 0; z-index: 10; }}
6060header b {{ color: #fff; font-size: 15px; }}
6061header .badge {{ background: #334155; color: #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 11px; margin-left: 8px; }}
6062nav {{ padding: 8px 20px; background: #1e293b; position: sticky; top: 46px; z-index: 9; }}
6063nav a {{ color: #93c5fd; text-decoration: none; margin-right: 14px; font-size: 12px; }}
6064nav a:hover {{ text-decoration: underline; }}
6065main {{ padding: 20px; max-width: 1100px; }}
6066section {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 18px; margin-bottom: 18px; }}
6067h2 {{ margin: 0 0 12px; font-size: 15px; color: #0f172a; }}
6068table {{ width: 100%; border-collapse: collapse; }}
6069th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }}
6070th {{ color: #64748b; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .03em; }}
6071td code, .policy code {{ background: #f1f5f9; padding: 1px 5px; border-radius: 3px; }}
6072.policy {{ font-size: 12px; }}
6073.muted {{ color: #94a3b8; }}
6074.ok {{ color: #16a34a; }}
6075.tag {{ display: inline-block; background: #dbeafe; color: #1e40af; border-radius: 3px; padding: 1px 6px; font-size: 11px; margin-left: 4px; }}
6076.tag.warn {{ background: #fef3c7; color: #92400e; }}
6077.tag.muted-tag {{ background: #f1f5f9; color: #64748b; }}
6078details summary {{ cursor: pointer; color: #2563eb; }}
6079ul {{ margin: 8px 0; padding-left: 20px; }}
6080.refresh-link {{ font-size: 11px; font-weight: 400; color: #2563eb; text-decoration: none; margin-left: 8px; }}
6081.refresh-link:hover {{ text-decoration: underline; }}
6082</style></head><body>
6083<header><b>What Inspector</b><span class="badge">dev</span></header>
6084<nav><a href="#overview">Overview</a><a href="#routes">Routes</a><a href="#collections">Collections</a><a href="#config">Config</a><a href="#sessions">Sessions</a><a href="#scopes">Scopes</a><a href="#lints">Lints</a><a href="#activity">Activity</a></nav>
6085<main>{body}</main>
6086</body></html>"##,
6087        body = body,
6088        refresh_meta = refresh_meta
6089    )
6090}
6091
6092/// Serve a partial HTML fragment from the partials/ directory.
6093///
6094/// Partials are rendered through the template engine but skip layout wrapping,
6095/// SEO injection, and what.js injection. They include OOB session updates for
6096/// reactive variables and are tagged with noindex headers.
6097async fn handle_partial(
6098    State(state): State<AppState>,
6099    headers: HeaderMap,
6100    axum::extract::Path(path): axum::extract::Path<String>,
6101    Query(query_params): Query<HashMap<String, String>>,
6102) -> Response {
6103    let clean_path = path.trim_start_matches('/');
6104
6105    // Resolve partial file: {content_dir}/partials/{path}.html
6106    let partials_dir = state.content_dir.join("partials");
6107    let file_path = if clean_path.is_empty() {
6108        partials_dir.join("index.html")
6109    } else {
6110        let with_ext = partials_dir.join(format!("{}.html", clean_path));
6111        if with_ext.exists() {
6112            with_ext
6113        } else {
6114            partials_dir.join(clean_path).join("index.html")
6115        }
6116    };
6117
6118    // Path traversal protection
6119    let canonical = match file_path.canonicalize() {
6120        Ok(p) => p,
6121        Err(_) => {
6122            return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6123        }
6124    };
6125    let partials_canonical = match partials_dir.canonicalize() {
6126        Ok(p) => p,
6127        Err(_) => {
6128            return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6129        }
6130    };
6131    if !canonical.starts_with(&partials_canonical) {
6132        return (StatusCode::FORBIDDEN, "Access denied").into_response();
6133    }
6134
6135    // Read partial file
6136    let raw_content = match tokio::fs::read_to_string(&canonical).await {
6137        Ok(c) => c,
6138        Err(_) => {
6139            return (StatusCode::NOT_FOUND, "Partial not found").into_response();
6140        }
6141    };
6142
6143    if state.dev_mode {
6144        engine::warn_template_lints_once(&canonical, &raw_content);
6145    }
6146
6147    // Parse directives and extract content
6148    let (directives, content) = parse_page_directives(&raw_content);
6149
6150    // Load session
6151    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6152
6153    let (session, is_new_session) = if let Some(ref sessions) = state.sessions {
6154        let session_id =
6155            sessions::parse_session_cookie(cookie_header, &state.config.session.cookie_name);
6156        match sessions.get_or_create(session_id.as_deref()).await {
6157            Ok(session) => {
6158                let is_new = session_id.is_none() || session_id.as_deref() != Some(session.id.as_str());
6159                (Some(session), is_new)
6160            }
6161            Err(_) => (None, false),
6162        }
6163    } else {
6164        (None, false)
6165    };
6166
6167    // Extract user context
6168    let user_context = if state.auth.is_enabled() {
6169        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6170            match state.auth.decode_jwt(&token) {
6171                Ok(claims) if !claims.is_expired() => {
6172                    UserContext::from_claims(claims.to_context(state.auth.jwt_claims()))
6173                }
6174                _ => UserContext::unauthenticated(),
6175            }
6176        } else {
6177            UserContext::unauthenticated()
6178        }
6179    } else {
6180        UserContext::unauthenticated()
6181    };
6182
6183    // Render as partial (reactive mode, no layout)
6184    match render_content_internal(
6185        &state,
6186        &content,
6187        session.as_ref(),
6188        &user_context,
6189        None,
6190        Some(&directives),
6191        Some(&query_params),
6192        &HashMap::new(),
6193        true,
6194        None,
6195    )
6196    .await
6197    {
6198        Ok(render_result) => {
6199            let mut html = render_result.html;
6200
6201            // Append OOB session updates
6202            if !render_result.session_keys.is_empty() {
6203                if let Some(ref s) = session {
6204                    let mut updates = serde_json::Map::new();
6205                    for key in &render_result.session_keys {
6206                        if let Some(value) = s.data.get(key) {
6207                            updates.insert(format!("session.{}", key), value.clone());
6208                        }
6209                    }
6210                    if !updates.is_empty() {
6211                        let json_str = serde_json::to_string(&updates).unwrap_or_default();
6212                        html.push_str(&format!(
6213                            r#"<template data-what-updates>{}</template>"#,
6214                            json_str
6215                        ));
6216                    }
6217                }
6218            }
6219
6220            // Inject CSRF tokens into forms
6221            if let Some(ref s) = session {
6222                if let Some(csrf) = s.data.get(CSRF_SESSION_KEY).and_then(|v| v.as_str()) {
6223                    html = inject_csrf_tokens(&html, csrf);
6224                }
6225            }
6226
6227            // Build response headers
6228            let mut resp_headers: Vec<(header::HeaderName, String)> = vec![
6229                (header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
6230                (
6231                    header::HeaderName::from_static("x-robots-tag"),
6232                    "noindex, nofollow".to_string(),
6233                ),
6234            ];
6235
6236            // Set session cookie if new
6237            if is_new_session {
6238                if let Some(ref s) = session {
6239                    let cookie = sessions::build_session_cookie(
6240                        &s.id,
6241                        &state.config.session.cookie_name,
6242                        state.config.session.max_age,
6243                        state.config.session.secure,
6244                    );
6245                    resp_headers.push((header::SET_COOKIE, cookie));
6246                }
6247            }
6248
6249            build_response(StatusCode::OK, resp_headers, html)
6250        }
6251        Err(e) => {
6252            tracing::error!("Partial render error: {}", e);
6253            (StatusCode::INTERNAL_SERVER_ERROR, "Render error").into_response()
6254        }
6255    }
6256}
6257
6258// ---------------------------------------------------------------------------
6259// Embedded Framework Assets (what.js, what.css)
6260// ---------------------------------------------------------------------------
6261
6262/// Which slice of the embedded framework stylesheet an app serves.
6263/// Configured via `[server] css` in what.toml.
6264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6265pub enum CssMode {
6266    /// The whole design system (default).
6267    Full,
6268    /// Reset, theme variables, and utilities only — component styles are cut.
6269    Minimal,
6270    /// what.css is not auto-injected; /static/what.css stays available for manual linking.
6271    None,
6272}
6273
6274impl CssMode {
6275    pub fn from_config(value: &str) -> crate::Result<Self> {
6276        match value {
6277            "full" => Ok(Self::Full),
6278            "minimal" => Ok(Self::Minimal),
6279            "none" => Ok(Self::None),
6280            other => Err(crate::Error::Config(format!(
6281                "[server] css must be \"full\", \"minimal\", or \"none\" (got \"{}\")",
6282                other
6283            ))),
6284        }
6285    }
6286}
6287
6288/// Embedded framework CSS — served at /static/what.css without requiring the file on disk.
6289const EMBEDDED_WHAT_CSS: &str = include_str!("../../assets/client/what.css");
6290
6291/// Cut markers in what.css around the components + interactions layers.
6292/// "minimal" mode drops everything between them, keeping base, utilities,
6293/// and the source-viewer styles.
6294const MINIMAL_CUT_START: &str = "/*! what:minimal-cut-start */";
6295const MINIMAL_CUT_END: &str = "/*! what:minimal-cut-end */";
6296
6297static MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6298    match (
6299        EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_START),
6300        EMBEDDED_WHAT_CSS.find(MINIMAL_CUT_END),
6301    ) {
6302        (Some(start), Some(end)) if end > start => format!(
6303            "{}{}",
6304            &EMBEDDED_WHAT_CSS[..start],
6305            &EMBEDDED_WHAT_CSS[end + MINIMAL_CUT_END.len()..]
6306        ),
6307        _ => EMBEDDED_WHAT_CSS.to_string(),
6308    }
6309});
6310
6311/// Embedded framework JS — served at /static/what.js without requiring the file on disk.
6312const EMBEDDED_WHAT_JS: &str = include_str!("../../assets/client/what.js");
6313
6314/// The framework CSS embedded in this crate (crates/what-core/assets/client/what.css).
6315/// Exposed so downstream crates that vendor their own copy can assert it hasn't drifted.
6316pub fn embedded_what_css() -> &'static str {
6317    EMBEDDED_WHAT_CSS
6318}
6319
6320/// The framework JS embedded in this crate (crates/what-core/assets/client/what.js).
6321/// Exposed so downstream crates that vendor their own copy can assert it hasn't drifted.
6322pub fn embedded_what_js() -> &'static str {
6323    EMBEDDED_WHAT_JS
6324}
6325
6326/// Minified versions of embedded assets — computed once at first access.
6327static MINIFIED_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6328    let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6329    let wrapped = format!("<style>{}</style>", EMBEDDED_WHAT_CSS);
6330    let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6331    String::from_utf8(minified)
6332        .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6333        .unwrap_or_else(|_| EMBEDDED_WHAT_CSS.to_string())
6334});
6335
6336static MINIFIED_MINIMAL_WHAT_CSS: LazyLock<String> = LazyLock::new(|| {
6337    let cfg = minify_html::Cfg { minify_css: true, ..minify_html::Cfg::default() };
6338    let wrapped = format!("<style>{}</style>", MINIMAL_WHAT_CSS.as_str());
6339    let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6340    String::from_utf8(minified)
6341        .map(|s| s.strip_prefix("<style>").unwrap_or(&s).strip_suffix("</style>").unwrap_or(&s).to_string())
6342        .unwrap_or_else(|_| MINIMAL_WHAT_CSS.clone())
6343});
6344
6345static MINIFIED_WHAT_JS: LazyLock<String> = LazyLock::new(|| {
6346    let cfg = minify_html::Cfg { minify_js: true, ..minify_html::Cfg::default() };
6347    let wrapped = format!("<script>{}</script>", EMBEDDED_WHAT_JS);
6348    let minified = minify_html::minify(wrapped.as_bytes(), &cfg);
6349    String::from_utf8(minified)
6350        .map(|s| s.strip_prefix("<script>").unwrap_or(&s).strip_suffix("</script>").unwrap_or(&s).to_string())
6351        .unwrap_or_else(|_| EMBEDDED_WHAT_JS.to_string())
6352});
6353
6354/// Cache-busted embedded asset paths so browsers fetch fresh framework assets after deploys.
6355/// Hash includes the crate version so recompiles after version bumps bust the cache.
6356static WHAT_CSS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6357    let hash = embedded_asset_hash(EMBEDDED_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6358    format!("/static/what.css?v={:08x}", hash)
6359});
6360static WHAT_CSS_MINIMAL_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6361    let hash =
6362        embedded_asset_hash(&MINIMAL_WHAT_CSS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6363    format!("/static/what.css?v={:08x}", hash)
6364});
6365static WHAT_JS_ASSET_PATH: LazyLock<String> = LazyLock::new(|| {
6366    let hash = embedded_asset_hash(EMBEDDED_WHAT_JS) ^ embedded_asset_hash(env!("CARGO_PKG_VERSION"));
6367    format!("/static/what.js?v={:08x}", hash)
6368});
6369
6370/// The embedded framework CSS for a given mode — used by the CLI's static build.
6371pub fn embedded_what_css_for_mode(mode: CssMode) -> &'static str {
6372    match mode {
6373        CssMode::Minimal => MINIMAL_WHAT_CSS.as_str(),
6374        _ => EMBEDDED_WHAT_CSS,
6375    }
6376}
6377
6378fn embedded_asset_hash(content: &str) -> u32 {
6379    let mut hash: u32 = 0x811c9dc5;
6380    for byte in content.as_bytes() {
6381        hash ^= *byte as u32;
6382        hash = hash.wrapping_mul(0x0100_0193);
6383    }
6384    hash
6385}
6386
6387/// Serve the embedded what.css framework stylesheet.
6388/// In production mode, serves the minified version.
6389async fn handle_embedded_css(State(state): State<AppState>) -> impl IntoResponse {
6390    let minimal = state.css_mode == CssMode::Minimal;
6391    let (cache, content) = if state.dev_mode {
6392        let raw = if minimal { MINIMAL_WHAT_CSS.as_str() } else { EMBEDDED_WHAT_CSS };
6393        ("no-cache, no-store, must-revalidate", raw.to_string())
6394    } else {
6395        let min = if minimal { MINIFIED_MINIMAL_WHAT_CSS.clone() } else { MINIFIED_WHAT_CSS.clone() };
6396        ("public, max-age=31536000, immutable", min)
6397    };
6398    (
6399        StatusCode::OK,
6400        [
6401            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
6402            (header::CACHE_CONTROL, cache),
6403        ],
6404        content,
6405    )
6406}
6407
6408/// Serve the embedded what.js framework script.
6409/// In production mode, serves the minified version.
6410async fn handle_embedded_js(State(state): State<AppState>) -> impl IntoResponse {
6411    let (cache, content) = if state.dev_mode {
6412        ("no-cache, no-store, must-revalidate", EMBEDDED_WHAT_JS.to_string())
6413    } else {
6414        ("public, max-age=31536000, immutable", MINIFIED_WHAT_JS.clone())
6415    };
6416    (
6417        StatusCode::OK,
6418        [
6419            (
6420                header::CONTENT_TYPE,
6421                "application/javascript; charset=utf-8",
6422            ),
6423            (header::CACHE_CONTROL, cache),
6424        ],
6425        content,
6426    )
6427}
6428
6429/// Serve page source plus referenced components as JSON (for "view source" modal)
6430async fn handle_page_source(
6431    State(state): State<AppState>,
6432    axum::extract::Path(path): axum::extract::Path<String>,
6433) -> impl IntoResponse {
6434    let not_available = axum::Json(json!({
6435        "files": [{ "label": "Page", "content": "Source not available" }]
6436    }));
6437
6438    // Strip leading slash if present
6439    let clean_path = path.trim_start_matches('/');
6440    let pages_dir = state.content_dir.clone();
6441    let file_path = pages_dir.join(clean_path);
6442
6443    // Try with .html extension, or index.html for directories
6444    let file_path = if file_path.extension().is_some() {
6445        file_path
6446    } else {
6447        let with_ext = file_path.with_extension("html");
6448        if with_ext.exists() {
6449            with_ext
6450        } else {
6451            file_path.join("index.html")
6452        }
6453    };
6454
6455    // Path traversal protection: canonicalize and verify within content dir
6456    let canonical = match file_path.canonicalize() {
6457        Ok(p) => p,
6458        Err(_) => return not_available,
6459    };
6460    let pages_canonical = match pages_dir.canonicalize() {
6461        Ok(p) => p,
6462        Err(_) => return not_available,
6463    };
6464    if !canonical.starts_with(&pages_canonical) {
6465        return not_available;
6466    }
6467
6468    // Read once to verify the page exists and is readable (push_source_file re-reads).
6469    if std::fs::read_to_string(&canonical).is_err() {
6470        return not_available;
6471    }
6472
6473    // Build list of source files: page, inherited config/layout, then referenced components/includes
6474    let mut files: Vec<Value> = Vec::new();
6475    let mut scan_queue: Vec<(PathBuf, String)> = Vec::new();
6476
6477    // Scan for <what-*> component tags and <include> tags
6478    static COMPONENT_RE: LazyLock<Regex> =
6479        LazyLock::new(|| Regex::new(r"<what-([a-zA-Z][a-zA-Z0-9_-]*)[\s/>]").unwrap());
6480    static INCLUDE_RE: LazyLock<Regex> =
6481        LazyLock::new(|| Regex::new(r#"<include\s+src="([^"]+)""#).unwrap());
6482    static PARTIAL_ROUTE_RE: LazyLock<Regex> =
6483        LazyLock::new(|| Regex::new(r#"w-get="/partials/([^"?#]+)""#).unwrap());
6484    static PARTIAL_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
6485        Regex::new(r#"name="w-partial"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="w-partial""#)
6486            .unwrap()
6487    });
6488
6489    let project_root = pages_dir.parent().unwrap_or(&pages_dir);
6490    let project_root_canonical = match project_root.canonicalize() {
6491        Ok(p) => p,
6492        Err(_) => return not_available,
6493    };
6494    let components_dir = project_root.join("components");
6495    let mut seen_paths = std::collections::HashSet::new();
6496
6497    push_source_file(
6498        canonical.clone(),
6499        &project_root_canonical,
6500        &mut seen_paths,
6501        &mut files,
6502        &mut scan_queue,
6503    );
6504
6505    // Show "your code" only: the page plus the components/includes it uses.
6506    // The layout wrapper (with its <html>/<head>) and inherited application.what
6507    // config are framework plumbing and are intentionally not listed here.
6508
6509    while let Some((current_path, current_content)) = scan_queue.pop() {
6510        for cap in COMPONENT_RE.captures_iter(&current_content) {
6511            let name = cap[1].to_string();
6512            push_source_file(
6513                components_dir.join(format!("{}.html", name)),
6514                &project_root_canonical,
6515                &mut seen_paths,
6516                &mut files,
6517                &mut scan_queue,
6518            );
6519        }
6520
6521        for cap in INCLUDE_RE.captures_iter(&current_content) {
6522            let src = cap[1].to_string();
6523            let file_dir = current_path.parent().unwrap_or(project_root);
6524            let candidates = [
6525                project_root.join(&src),
6526                pages_dir.join(&src),
6527                file_dir.join(&src),
6528            ];
6529            for candidate in candidates {
6530                if push_source_file(
6531                    candidate,
6532                    &project_root_canonical,
6533                    &mut seen_paths,
6534                    &mut files,
6535                    &mut scan_queue,
6536                ) {
6537                    break;
6538                }
6539            }
6540        }
6541
6542        for cap in PARTIAL_ROUTE_RE.captures_iter(&current_content) {
6543            let partial = format!("{}.html", &cap[1]);
6544            push_source_file(
6545                pages_dir.join("partials").join(partial),
6546                &project_root_canonical,
6547                &mut seen_paths,
6548                &mut files,
6549                &mut scan_queue,
6550            );
6551        }
6552
6553        for cap in PARTIAL_NAME_RE.captures_iter(&current_content) {
6554            if let Some(name) = cap.get(1).or_else(|| cap.get(2)) {
6555                push_source_file(
6556                    pages_dir.join("partials").join(format!("{}.html", name.as_str())),
6557                    &project_root_canonical,
6558                    &mut seen_paths,
6559                    &mut files,
6560                    &mut scan_queue,
6561                );
6562            }
6563        }
6564    }
6565
6566    axum::Json(json!({ "files": files }))
6567}
6568
6569/// Handle WebSocket connection for live reload
6570async fn handle_livereload_ws(
6571    ws: WebSocketUpgrade,
6572    State(state): State<AppState>,
6573) -> impl IntoResponse {
6574    ws.on_upgrade(|socket| handle_livereload_socket(socket, state))
6575}
6576
6577/// Handle an individual live reload WebSocket connection
6578async fn handle_livereload_socket(socket: WebSocket, state: AppState) {
6579    let (mut sender, mut receiver) = socket.split();
6580
6581    // Get a receiver for live reload messages
6582    let mut reload_rx = match state.live_reload_receiver() {
6583        Some(rx) => rx,
6584        None => {
6585            tracing::warn!("Live reload not enabled");
6586            return;
6587        }
6588    };
6589
6590    tracing::debug!("Live reload client connected");
6591
6592    // Send initial connected message
6593    let _ = sender
6594        .send(Message::Text("{\"type\":\"connected\"}".to_string()))
6595        .await;
6596
6597    // Create a channel to signal when the receiver task should stop
6598    let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6599
6600    // Spawn task to handle incoming messages (mainly for ping/pong and close)
6601    let recv_task = tokio::spawn(async move {
6602        while let Some(msg) = receiver.next().await {
6603            match msg {
6604                Ok(Message::Close(_)) => break,
6605                Ok(Message::Ping(_)) => {
6606                    // Pong is handled automatically by axum
6607                }
6608                Err(e) => {
6609                    tracing::debug!("WebSocket receive error: {}", e);
6610                    break;
6611                }
6612                _ => {}
6613            }
6614        }
6615        // Signal that we should stop
6616        let _ = stop_tx.send(());
6617    });
6618
6619    // Send reload messages to client
6620    loop {
6621        tokio::select! {
6622            result = reload_rx.recv() => {
6623                match result {
6624                    Ok(LiveReloadMessage::Reload) => {
6625                        if sender.send(Message::Text("{\"type\":\"reload\"}".to_string())).await.is_err() {
6626                            break;
6627                        }
6628                    }
6629                    Ok(LiveReloadMessage::CacheCleared) => {
6630                        if sender.send(Message::Text("{\"type\":\"cache_cleared\"}".to_string())).await.is_err() {
6631                            break;
6632                        }
6633                    }
6634                    Err(broadcast::error::RecvError::Closed) => break,
6635                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
6636                }
6637            }
6638            _ = &mut stop_rx => {
6639                // Receiver task finished (client disconnected)
6640                break;
6641            }
6642        }
6643    }
6644
6645    // Clean up the receiver task
6646    recv_task.abort();
6647
6648    tracing::debug!("Live reload client disconnected");
6649}
6650
6651/// Handle WebSocket upgrade for wired state (with scope filtering)
6652async fn handle_wire_ws(
6653    ws: WebSocketUpgrade,
6654    headers: HeaderMap,
6655    State(state): State<AppState>,
6656) -> impl IntoResponse {
6657    // Extract client roles and user_id from JWT cookie at connection time
6658    let cookie_header = headers.get(header::COOKIE).and_then(|v| v.to_str().ok());
6659    let (client_roles, client_user_id) = if state.auth.is_enabled() {
6660        if let Some(token) = state.auth.parse_jwt_cookie(cookie_header) {
6661            match state.auth.decode_jwt(&token) {
6662                Ok(claims) if !claims.is_expired() => {
6663                    let user =
6664                        UserContext::from_claims(claims.to_context(state.auth.jwt_claims()));
6665                    (user.roles(), claims.sub.clone())
6666                }
6667                _ => (Vec::new(), None),
6668            }
6669        } else {
6670            (Vec::new(), None)
6671        }
6672    } else {
6673        (Vec::new(), None)
6674    };
6675
6676    ws.on_upgrade(move |socket| handle_wire_socket(socket, state, client_roles, client_user_id))
6677}
6678
6679/// Handle an individual wired state WebSocket connection (with scope filtering)
6680async fn handle_wire_socket(
6681    socket: WebSocket,
6682    state: AppState,
6683    client_roles: Vec<String>,
6684    client_user_id: Option<String>,
6685) {
6686    let (mut sender, mut receiver) = socket.split();
6687    let mut wired_rx = state.wired_tx.subscribe();
6688
6689    // Track connected client count
6690    let count = state
6691        .wired_client_count
6692        .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6693        + 1;
6694    tracing::debug!(
6695        "Wired client connected (roles: {:?}, user: {:?}, total: {})",
6696        client_roles,
6697        client_user_id,
6698        count
6699    );
6700
6701    // Send connected message with current client count
6702    let connected_msg = format!(r#"{{"type":"connected","clients":{}}}"#, count);
6703    let _ = sender.send(Message::Text(connected_msg)).await;
6704
6705    // Broadcast updated client count to all connected clients
6706    let _ = state.wired_tx.send(WiredMessage {
6707        json: format!(r#"{{"wired._clients":"{}"}}"#, count),
6708        scope: WiredScope::Public,
6709    });
6710
6711    let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel::<()>();
6712
6713    let recv_task = tokio::spawn(async move {
6714        while let Some(msg) = receiver.next().await {
6715            match msg {
6716                Ok(Message::Close(_)) => break,
6717                Err(_) => break,
6718                _ => {}
6719            }
6720        }
6721        let _ = stop_tx.send(());
6722    });
6723
6724    loop {
6725        tokio::select! {
6726            result = wired_rx.recv() => {
6727                match result {
6728                    Ok(msg) => {
6729                        // Filter by scope — only send if the client is authorized
6730                        if msg.scope.allows(&client_roles, client_user_id.as_deref()) {
6731                            if sender.send(Message::Text(msg.json)).await.is_err() {
6732                                break;
6733                            }
6734                        }
6735                    }
6736                    Err(broadcast::error::RecvError::Closed) => break,
6737                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
6738                }
6739            }
6740            _ = &mut stop_rx => {
6741                break;
6742            }
6743        }
6744    }
6745
6746    recv_task.abort();
6747
6748    // Decrement connected client count and broadcast
6749    let count = state
6750        .wired_client_count
6751        .fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
6752        - 1;
6753    let _ = state.wired_tx.send(WiredMessage {
6754        json: format!(r#"{{"wired._clients":"{}"}}"#, count),
6755        scope: WiredScope::Public,
6756    });
6757    tracing::debug!("Wired client disconnected (total: {})", count);
6758}
6759
6760/// Start the What server
6761pub async fn serve(state: AppState) -> Result<()> {
6762    let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
6763    let listener = tokio::net::TcpListener::bind(&addr).await?;
6764
6765    tracing::info!("What server running at http://{}", addr);
6766
6767    let router = create_router(state);
6768    axum::serve(listener, router).await?;
6769
6770    Ok(())
6771}
6772
6773/// Start the server with graceful shutdown support.
6774/// The `shutdown` future resolves when the server should stop accepting new connections.
6775pub async fn serve_with_shutdown(
6776    state: AppState,
6777    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
6778) -> Result<()> {
6779    let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
6780    let listener = tokio::net::TcpListener::bind(&addr).await?;
6781    serve_on_listener(state, listener, shutdown).await
6782}
6783
6784/// Serve on an already-bound listener. Lets callers bind first — surfacing
6785/// port-in-use errors before printing any success banner — then serve.
6786pub async fn serve_on_listener(
6787    state: AppState,
6788    listener: tokio::net::TcpListener,
6789    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
6790) -> Result<()> {
6791    tracing::info!("What server running at http://{}", listener.local_addr()?);
6792
6793    let router = create_router(state);
6794    axum::serve(listener, router)
6795        .with_graceful_shutdown(shutdown)
6796        .await?;
6797
6798    Ok(())
6799}
6800
6801#[cfg(test)]
6802mod tests {
6803    use super::*;
6804    use std::fs;
6805    use tempfile::TempDir;
6806
6807    /// Create a test project structure in a temporary directory
6808    fn create_test_project() -> TempDir {
6809        let temp_dir = TempDir::new().unwrap();
6810        let root = temp_dir.path();
6811        let cd = content_dir_name(root);
6812
6813        // Create content directory structure
6814        fs::create_dir_all(root.join(cd).join("admin")).unwrap();
6815        fs::create_dir_all(root.join(cd).join("blog")).unwrap();
6816
6817        // Create page files
6818        fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
6819        fs::write(root.join(cd).join("about.html"), "<h1>About</h1>").unwrap();
6820        fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
6821        fs::write(root.join(cd).join("admin/users.html"), "<h1>Users</h1>").unwrap();
6822        fs::write(root.join(cd).join("blog/[id].html"), "<h1>Blog Post</h1>").unwrap();
6823
6824        temp_dir
6825    }
6826
6827    #[test]
6828    fn test_resolve_page_path_exact_match() {
6829        let temp_dir = create_test_project();
6830        let root = temp_dir.path().to_path_buf();
6831
6832        // /about -> site/about.html
6833        let result = resolve_page_path(&root, "/about");
6834        assert!(result.is_some());
6835        let resolved = result.unwrap();
6836        assert!(resolved.path.ends_with("about.html"));
6837        assert!(resolved.params.is_empty());
6838    }
6839
6840    #[test]
6841    fn test_resolve_page_path_index() {
6842        let temp_dir = create_test_project();
6843        let root = temp_dir.path().to_path_buf();
6844
6845        // / -> site/index.html
6846        let result = resolve_page_path(&root, "/");
6847        assert!(result.is_some());
6848        assert!(result.unwrap().path.ends_with("index.html"));
6849
6850        // /admin -> site/admin/index.html
6851        let result = resolve_page_path(&root, "/admin");
6852        assert!(result.is_some());
6853        let resolved = result.unwrap();
6854        assert!(resolved.path.to_string_lossy().contains("admin"));
6855        assert!(resolved.path.ends_with("index.html"));
6856    }
6857
6858    #[test]
6859    fn test_resolve_page_path_nested() {
6860        let temp_dir = create_test_project();
6861        let root = temp_dir.path().to_path_buf();
6862
6863        // /admin/users -> site/admin/users.html
6864        let result = resolve_page_path(&root, "/admin/users");
6865        assert!(result.is_some());
6866        assert!(result.unwrap().path.ends_with("users.html"));
6867    }
6868
6869    #[test]
6870    fn test_resolve_page_path_dynamic() {
6871        let temp_dir = create_test_project();
6872        let root = temp_dir.path().to_path_buf();
6873
6874        // /blog/123 -> site/blog/[id].html
6875        let result = resolve_page_path(&root, "/blog/123");
6876        assert!(result.is_some());
6877        let resolved = result.unwrap();
6878        assert!(resolved.path.ends_with("[id].html"));
6879        assert_eq!(resolved.params.get("id"), Some(&"123".to_string()));
6880    }
6881
6882    #[test]
6883    fn test_resolve_page_path_not_found() {
6884        let temp_dir = create_test_project();
6885        let root = temp_dir.path().to_path_buf();
6886
6887        // /nonexistent should return None
6888        let result = resolve_page_path(&root, "/nonexistent");
6889        assert!(result.is_none());
6890    }
6891
6892    /// Create a test project with application.what files
6893    fn create_test_project_with_config() -> TempDir {
6894        let temp_dir = TempDir::new().unwrap();
6895        let root = temp_dir.path();
6896        let cd = content_dir_name(root);
6897
6898        // Create content directory structure
6899        fs::create_dir_all(root.join(cd).join("admin")).unwrap();
6900        fs::create_dir_all(root.join(cd).join("admin/settings")).unwrap();
6901
6902        // Create root application.what
6903        fs::write(
6904            root.join(cd).join("application.what"),
6905            r#"
6906title = "My App"
6907theme = "light"
6908auth = "all"
6909"#,
6910        )
6911        .unwrap();
6912
6913        // Create admin application.what (overrides)
6914        fs::write(
6915            root.join(cd).join("admin/application.what"),
6916            r#"
6917title = "Admin Panel"
6918auth = "admin"
6919"#,
6920        )
6921        .unwrap();
6922
6923        // Create admin/settings application.what (further overrides)
6924        fs::write(
6925            root.join(cd).join("admin/settings/application.what"),
6926            r#"
6927title = "Settings"
6928debug = true
6929"#,
6930        )
6931        .unwrap();
6932
6933        // Create page files
6934        fs::write(root.join(cd).join("index.html"), "<h1>Home</h1>").unwrap();
6935        fs::write(root.join(cd).join("admin/index.html"), "<h1>Admin</h1>").unwrap();
6936        fs::write(
6937            root.join(cd).join("admin/settings/index.html"),
6938            "<h1>Settings</h1>",
6939        )
6940        .unwrap();
6941
6942        temp_dir
6943    }
6944
6945    #[test]
6946    fn test_load_application_config_root() {
6947        let temp_dir = create_test_project_with_config();
6948        let root = temp_dir.path().to_path_buf();
6949
6950        // Load config for root page
6951        let config = load_application_config(&root, "/");
6952
6953        assert_eq!(config.get_string("title"), Some("My App"));
6954        assert_eq!(config.get_string("theme"), Some("light"));
6955        assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
6956    }
6957
6958    #[test]
6959    fn test_load_application_config_inheritance() {
6960        let temp_dir = create_test_project_with_config();
6961        let root = temp_dir.path().to_path_buf();
6962
6963        // Load config for admin page - should have admin's title but root's theme
6964        let config = load_application_config(&root, "/admin");
6965
6966        assert_eq!(config.get_string("title"), Some("Admin Panel"));
6967        assert_eq!(config.get_string("theme"), Some("light")); // Inherited from root
6968        assert_eq!(
6969            config.directives.auth,
6970            crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
6971        );
6972    }
6973
6974    #[test]
6975    fn test_load_application_config_deep_inheritance() {
6976        let temp_dir = create_test_project_with_config();
6977        let root = temp_dir.path().to_path_buf();
6978
6979        // Load config for admin/settings - should inherit from root and admin
6980        let config = load_application_config(&root, "/admin/settings");
6981
6982        assert_eq!(config.get_string("title"), Some("Settings"));
6983        assert_eq!(config.get_string("theme"), Some("light")); // From root
6984        assert_eq!(config.get_bool("debug"), Some(true)); // From settings
6985        // Auth should be from admin (child overrides but settings doesn't set auth)
6986        assert_eq!(
6987            config.directives.auth,
6988            crate::parser::AuthLevel::Roles(vec!["admin".to_string()])
6989        );
6990    }
6991
6992    #[test]
6993    fn test_load_application_config_no_config() {
6994        let temp_dir = create_test_project();
6995        let root = temp_dir.path().to_path_buf();
6996
6997        // Load config for project without application.what files
6998        let config = load_application_config(&root, "/about");
6999
7000        // Should return default empty config
7001        assert!(config.values.is_empty());
7002        assert_eq!(config.directives.auth, crate::parser::AuthLevel::All);
7003    }
7004
7005    #[test]
7006    fn test_content_dir_name_prefers_site() {
7007        let temp = TempDir::new().unwrap();
7008        let root = temp.path();
7009
7010        // Neither exists -> defaults to "site"
7011        assert_eq!(content_dir_name(root), "site");
7012
7013        // Only pages/ exists -> uses "pages"
7014        fs::create_dir_all(root.join("pages")).unwrap();
7015        assert_eq!(content_dir_name(root), "pages");
7016
7017        // Both exist -> prefers "site"
7018        fs::create_dir_all(root.join("site")).unwrap();
7019        assert_eq!(content_dir_name(root), "site");
7020    }
7021
7022    #[test]
7023    fn test_content_dir_name_only_site() {
7024        let temp = TempDir::new().unwrap();
7025        let root = temp.path();
7026        fs::create_dir_all(root.join("site")).unwrap();
7027        assert_eq!(content_dir_name(root), "site");
7028    }
7029
7030    #[test]
7031    fn test_app_state_dev_mode_disabled() {
7032        let temp_dir = create_test_project();
7033        let root = temp_dir.path().to_path_buf();
7034        let config = crate::Config::default();
7035
7036        let state = AppState::new(config, root).unwrap();
7037
7038        assert!(!state.dev_mode);
7039        assert!(state.live_reload_tx.is_none());
7040        assert!(state.live_reload_receiver().is_none());
7041    }
7042
7043    #[test]
7044    fn test_app_state_dev_mode_enabled() {
7045        let temp_dir = create_test_project();
7046        let root = temp_dir.path().to_path_buf();
7047        let config = crate::Config::default();
7048
7049        let state = AppState::with_dev_mode(config, root, true).unwrap();
7050
7051        assert!(state.dev_mode);
7052        assert!(state.live_reload_tx.is_some());
7053        assert!(state.live_reload_receiver().is_some());
7054    }
7055
7056    #[test]
7057    fn test_dev_mode_disables_secure_cookies() {
7058        let temp_dir = create_test_project();
7059        let root = temp_dir.path().to_path_buf();
7060        let config = crate::Config::default();
7061        // Default config has secure=true
7062        assert!(config.session.secure);
7063
7064        // In dev mode, secure is auto-disabled
7065        let state = AppState::with_dev_mode(config, root, true).unwrap();
7066        assert!(!state.config.session.secure);
7067    }
7068
7069    #[test]
7070    fn test_production_mode_keeps_secure_cookies() {
7071        let temp_dir = create_test_project();
7072        let root = temp_dir.path().to_path_buf();
7073        let config = crate::Config::default();
7074
7075        // In production mode (dev_mode=false), secure stays true
7076        let state = AppState::new(config, root).unwrap();
7077        assert!(state.config.session.secure);
7078    }
7079
7080    #[test]
7081    fn test_live_reload_broadcast() {
7082        let temp_dir = create_test_project();
7083        let root = temp_dir.path().to_path_buf();
7084        let config = crate::Config::default();
7085
7086        let state = AppState::with_dev_mode(config, root, true).unwrap();
7087
7088        // Subscribe before triggering
7089        let mut rx = state.live_reload_receiver().unwrap();
7090
7091        // Trigger reload
7092        if let Some(ref tx) = state.live_reload_tx {
7093            tx.send(LiveReloadMessage::Reload).unwrap();
7094        }
7095
7096        // Should receive the message
7097        let msg = rx.try_recv().unwrap();
7098        assert!(matches!(msg, LiveReloadMessage::Reload));
7099    }
7100
7101    #[test]
7102    fn test_live_reload_multiple_subscribers() {
7103        let temp_dir = create_test_project();
7104        let root = temp_dir.path().to_path_buf();
7105        let config = crate::Config::default();
7106
7107        let state = AppState::with_dev_mode(config, root, true).unwrap();
7108
7109        // Create multiple subscribers
7110        let mut rx1 = state.live_reload_receiver().unwrap();
7111        let mut rx2 = state.live_reload_receiver().unwrap();
7112
7113        // Trigger reload
7114        if let Some(ref tx) = state.live_reload_tx {
7115            tx.send(LiveReloadMessage::Reload).unwrap();
7116        }
7117
7118        // Both should receive the message
7119        assert!(matches!(rx1.try_recv().unwrap(), LiveReloadMessage::Reload));
7120        assert!(matches!(rx2.try_recv().unwrap(), LiveReloadMessage::Reload));
7121    }
7122
7123    // =========================================================================
7124    // Fetch Directive Collection Tests
7125    // =========================================================================
7126
7127    #[test]
7128    fn test_collect_fetch_directives_from_page() {
7129        let mut directives = crate::parser::PageDirectives::default();
7130        directives.custom.insert(
7131            "fetch.dog_facts".to_string(),
7132            "https://dogapi.dog/api/v2/facts?limit=3".to_string(),
7133        );
7134        directives.custom.insert(
7135            "fetch.dog_breeds".to_string(),
7136            "https://dogapi.dog/api/v2/breeds".to_string(),
7137        );
7138
7139        let fetches = collect_fetch_directives(None, Some(&directives));
7140
7141        assert_eq!(fetches.len(), 2);
7142        assert_eq!(
7143            fetches.get("dog_facts").map(|f| f.url.as_str()),
7144            Some("https://dogapi.dog/api/v2/facts?limit=3")
7145        );
7146        assert_eq!(
7147            fetches.get("dog_breeds").map(|f| f.url.as_str()),
7148            Some("https://dogapi.dog/api/v2/breeds")
7149        );
7150    }
7151
7152    #[test]
7153    fn test_collect_fetch_directives_excludes_non_fetch() {
7154        let mut directives = crate::parser::PageDirectives::default();
7155        directives
7156            .custom
7157            .insert("page".to_string(), "remote-data".to_string());
7158        directives.custom.insert(
7159            "fetch.api".to_string(),
7160            "https://example.com/api".to_string(),
7161        );
7162
7163        let fetches = collect_fetch_directives(None, Some(&directives));
7164
7165        assert_eq!(fetches.len(), 1);
7166        assert!(fetches.contains_key("api"));
7167        assert!(!fetches.contains_key("page"));
7168    }
7169
7170    #[test]
7171    fn test_collect_fetch_directives_empty() {
7172        let directives = crate::parser::PageDirectives::default();
7173        let fetches = collect_fetch_directives(None, Some(&directives));
7174        assert!(fetches.is_empty());
7175    }
7176
7177    #[test]
7178    fn test_collect_fetch_directives_enhanced() {
7179        let mut directives = crate::parser::PageDirectives::default();
7180        directives.custom.insert(
7181            "fetch.users.url".to_string(),
7182            "https://api.example.com/users".to_string(),
7183        );
7184        directives
7185            .custom
7186            .insert("fetch.users.method".to_string(), "POST".to_string());
7187        directives.custom.insert(
7188            "fetch.users.headers".to_string(),
7189            "Authorization: Bearer token".to_string(),
7190        );
7191        directives
7192            .custom
7193            .insert("fetch.users.path".to_string(), "data.results".to_string());
7194
7195        let fetches = collect_fetch_directives(None, Some(&directives));
7196        assert_eq!(fetches.len(), 1);
7197        let users = fetches.get("users").unwrap();
7198        assert_eq!(users.url, "https://api.example.com/users");
7199        assert_eq!(users.method, "POST");
7200        assert_eq!(users.headers.len(), 1);
7201        assert_eq!(
7202            users.headers[0],
7203            ("Authorization".to_string(), "Bearer token".to_string())
7204        );
7205        assert_eq!(users.path.as_deref(), Some("data.results"));
7206    }
7207
7208    #[test]
7209    fn test_extract_json_path() {
7210        let value = serde_json::json!({
7211            "data": {
7212                "results": [1, 2, 3],
7213                "meta": { "total": 100 }
7214            }
7215        });
7216        assert_eq!(
7217            extract_json_path(&value, "data.results"),
7218            serde_json::json!([1, 2, 3])
7219        );
7220        assert_eq!(
7221            extract_json_path(&value, "data.meta.total"),
7222            serde_json::json!(100)
7223        );
7224        assert_eq!(
7225            extract_json_path(&value, "data.missing"),
7226            serde_json::Value::Null
7227        );
7228    }
7229
7230    #[test]
7231    fn test_parse_header_string() {
7232        let headers = parse_header_string("Authorization: Bearer token, Accept: application/json");
7233        assert_eq!(headers.len(), 2);
7234        assert_eq!(
7235            headers[0],
7236            ("Authorization".to_string(), "Bearer token".to_string())
7237        );
7238        assert_eq!(
7239            headers[1],
7240            ("Accept".to_string(), "application/json".to_string())
7241        );
7242    }
7243
7244    // =========================================================================
7245    // w-set Expression Parsing Tests
7246    // =========================================================================
7247
7248    #[test]
7249    fn test_parse_w_set_increment() {
7250        let result = parse_w_set_expr("session.counter += 1");
7251        assert!(result.is_some());
7252        let (scope, key, op) = result.unwrap();
7253        assert_eq!(scope, "session");
7254        assert_eq!(key, "counter");
7255        assert!(matches!(op, SetOp::Increment(1)));
7256    }
7257
7258    #[test]
7259    fn test_parse_w_set_decrement() {
7260        let result = parse_w_set_expr("session.counter -= 1");
7261        assert!(result.is_some());
7262        let (scope, key, op) = result.unwrap();
7263        assert_eq!(scope, "session");
7264        assert_eq!(key, "counter");
7265        assert!(matches!(op, SetOp::Decrement(1)));
7266    }
7267
7268    #[test]
7269    fn test_parse_w_set_assign_int() {
7270        let result = parse_w_set_expr("app.app_counter = 0");
7271        assert!(result.is_some());
7272        let (scope, key, op) = result.unwrap();
7273        assert_eq!(scope, "app");
7274        assert_eq!(key, "app_counter");
7275        assert!(matches!(op, SetOp::SetInt(0)));
7276    }
7277
7278    #[test]
7279    fn test_parse_w_set_assign_string() {
7280        let result = parse_w_set_expr("session.name = 'Jorge'");
7281        assert!(result.is_some());
7282        let (scope, key, op) = result.unwrap();
7283        assert_eq!(scope, "session");
7284        assert_eq!(key, "name");
7285        match op {
7286            SetOp::SetStr(s) => assert_eq!(s, "Jorge"),
7287            _ => panic!("Expected SetStr"),
7288        }
7289    }
7290
7291    #[test]
7292    fn test_parse_w_set_large_increment() {
7293        let result = parse_w_set_expr("app.views += 100");
7294        assert!(result.is_some());
7295        let (scope, key, op) = result.unwrap();
7296        assert_eq!(scope, "app");
7297        assert_eq!(key, "views");
7298        assert!(matches!(op, SetOp::Increment(100)));
7299    }
7300
7301    #[test]
7302    fn test_parse_w_set_whitespace() {
7303        let result = parse_w_set_expr("  session.counter  +=  5  ");
7304        assert!(result.is_some());
7305        let (scope, key, op) = result.unwrap();
7306        assert_eq!(scope, "session");
7307        assert_eq!(key, "counter");
7308        assert!(matches!(op, SetOp::Increment(5)));
7309    }
7310
7311    #[test]
7312    fn test_parse_w_set_invalid_no_scope() {
7313        assert!(parse_w_set_expr("counter += 1").is_none());
7314    }
7315
7316    #[test]
7317    fn test_parse_w_set_invalid_bad_value() {
7318        assert!(parse_w_set_expr("session.counter += abc").is_none());
7319    }
7320
7321    #[test]
7322    fn test_parse_w_set_invalid_empty() {
7323        assert!(parse_w_set_expr("").is_none());
7324    }
7325
7326    #[test]
7327    fn test_parse_w_set_multi_expression() {
7328        let expr_str = "session.counter += 1; app.views += 1";
7329        let parsed: Vec<_> = expr_str
7330            .split(';')
7331            .map(|s| s.trim())
7332            .filter(|s| !s.is_empty())
7333            .filter_map(|s| parse_w_set_expr(s))
7334            .collect();
7335        assert_eq!(parsed.len(), 2);
7336        assert_eq!(parsed[0].0, "session");
7337        assert_eq!(parsed[0].1, "counter");
7338        assert!(matches!(parsed[0].2, SetOp::Increment(1)));
7339        assert_eq!(parsed[1].0, "app");
7340        assert_eq!(parsed[1].1, "views");
7341        assert!(matches!(parsed[1].2, SetOp::Increment(1)));
7342    }
7343
7344    #[test]
7345    fn test_apply_set_op_increment_from_none() {
7346        let result = apply_set_op(None, &SetOp::Increment(1));
7347        assert_eq!(result, json!(1));
7348    }
7349
7350    #[test]
7351    fn test_apply_set_op_increment_existing() {
7352        let current = json!(5);
7353        let result = apply_set_op(Some(&current), &SetOp::Increment(3));
7354        assert_eq!(result, json!(8));
7355    }
7356
7357    #[test]
7358    fn test_apply_set_op_set_string() {
7359        let result = apply_set_op(None, &SetOp::SetStr("hello".to_string()));
7360        assert_eq!(result, json!("hello"));
7361    }
7362
7363    #[test]
7364    fn test_parse_w_set_wired_scope() {
7365        let result = parse_w_set_expr("wired.total_dogs += 1");
7366        assert!(result.is_some());
7367        let (scope, key, op) = result.unwrap();
7368        assert_eq!(scope, "wired");
7369        assert_eq!(key, "total_dogs");
7370        assert!(matches!(op, SetOp::Increment(1)));
7371    }
7372
7373    #[test]
7374    fn test_parse_w_set_wired_reset() {
7375        let result = parse_w_set_expr("wired.counter = 0");
7376        assert!(result.is_some());
7377        let (scope, key, op) = result.unwrap();
7378        assert_eq!(scope, "wired");
7379        assert_eq!(key, "counter");
7380        assert!(matches!(op, SetOp::SetInt(0)));
7381    }
7382
7383    #[test]
7384    fn test_wired_broadcast_channel() {
7385        let temp_dir = create_test_project();
7386        let root = temp_dir.path().to_path_buf();
7387        let config = crate::Config::default();
7388        let state = AppState::new(config, root).unwrap();
7389
7390        let mut rx = state.wired_tx.subscribe();
7391        let _ = state.wired_tx.send(WiredMessage {
7392            json: r#"{"wired.counter":"5"}"#.to_string(),
7393            scope: WiredScope::Public,
7394        });
7395        let msg = rx.try_recv().unwrap();
7396        assert!(msg.json.contains("wired.counter"));
7397        assert!(msg.json.contains("5"));
7398    }
7399
7400    #[test]
7401    fn test_error_page_dev_mode_shows_detail() {
7402        let page = error_page_fallback(
7403            true,
7404            StatusCode::INTERNAL_SERVER_ERROR,
7405            "template parse failed at line 42",
7406        );
7407        assert!(page.contains("template parse failed at line 42"));
7408        assert!(page.contains("500"));
7409    }
7410
7411    #[test]
7412    fn test_error_page_production_hides_detail() {
7413        let page = error_page_fallback(
7414            false,
7415            StatusCode::INTERNAL_SERVER_ERROR,
7416            "template parse failed at line 42",
7417        );
7418        assert!(!page.contains("template parse failed"));
7419        assert!(!page.contains("line 42"));
7420        assert!(page.contains("Something went wrong"));
7421    }
7422
7423    #[test]
7424    fn test_error_page_404_production() {
7425        let page = error_page_fallback(false, StatusCode::NOT_FOUND, "/secret/path/file.html");
7426        assert!(!page.contains("/secret/path"));
7427        assert!(page.contains("Page Not Found"));
7428    }
7429
7430    #[test]
7431    fn test_error_page_403_production() {
7432        let page = error_page_fallback(false, StatusCode::FORBIDDEN, "user lacks admin role");
7433        assert!(!page.contains("admin role"));
7434        assert!(page.contains("Forbidden"));
7435    }
7436
7437    // ---- CSRF Protection Tests ----
7438
7439    #[test]
7440    fn test_inject_csrf_into_post_form() {
7441        let html = r##"<html><head></head><body><form method="post" action="/submit"><input name="name"></form></body></html>"##;
7442        let result = inject_csrf_tokens(html, "test_token_abc");
7443        assert!(result.contains(r#"<input type="hidden" name="_csrf" value="test_token_abc">"#));
7444        assert!(result.contains(r#"<meta name="csrf-token" content="test_token_abc">"#));
7445    }
7446
7447    #[test]
7448    fn test_inject_csrf_skips_get_form() {
7449        let html = r##"<html><head></head><body><form method="get" action="/search"><input name="q"></form></body></html>"##;
7450        let result = inject_csrf_tokens(html, "test_token");
7451        // Should NOT inject hidden input into GET form
7452        assert!(!result.contains(r#"name="_csrf""#));
7453        // But should still inject meta tag
7454        assert!(result.contains(r#"<meta name="csrf-token" content="test_token">"#));
7455    }
7456
7457    #[test]
7458    fn test_inject_csrf_multiple_forms() {
7459        let html = r##"<html><head></head><body><form method="POST"><input></form><form method="post"><input></form></body></html>"##;
7460        let result = inject_csrf_tokens(html, "tok123");
7461        // Count occurrences of the hidden input
7462        let count = result.matches(r#"name="_csrf""#).count();
7463        assert_eq!(count, 2, "Should inject into both POST forms");
7464    }
7465
7466    #[test]
7467    fn test_inject_what_js_before_body() {
7468        let html = "<html><head></head><body><p>Hello</p></body></html>";
7469        let result = inject_what_js(html);
7470        assert!(result.contains(WHAT_JS_ASSET_PATH.as_str()));
7471        assert!(result.contains("</body>"));
7472    }
7473
7474    #[test]
7475    fn test_inject_what_js_skips_if_present() {
7476        let html =
7477            r#"<html><head></head><body><script src="/static/what.js"></script></body></html>"#;
7478        let result = inject_what_js(html);
7479        assert_eq!(result.matches("what.js").count(), 1, "Should not duplicate");
7480    }
7481
7482    #[test]
7483    fn test_inject_what_js_no_body() {
7484        let html = "<p>just a fragment</p>";
7485        let result = inject_what_js(html);
7486        assert!(!result.contains("what.js"), "Should skip without </body>");
7487        assert_eq!(result, html);
7488    }
7489
7490    #[test]
7491    fn test_inject_theme_restore_after_head() {
7492        let html = "<html><head><title>T</title></head><body></body></html>";
7493        let result = inject_theme_restore(html);
7494        assert!(result.contains("data-w-theme"));
7495        // Must land right after the opening <head>, before any other head content
7496        let head_pos = result.find("<head>").unwrap();
7497        let script_pos = result.find("<script data-w-theme>").unwrap();
7498        let title_pos = result.find("<title>").unwrap();
7499        assert!(script_pos > head_pos && script_pos < title_pos);
7500    }
7501
7502    #[test]
7503    fn test_inject_theme_restore_idempotent() {
7504        let html = "<html><head></head><body></body></html>";
7505        let once = inject_theme_restore(html);
7506        let twice = inject_theme_restore(&once);
7507        assert_eq!(once, twice, "Should not duplicate");
7508        assert_eq!(twice.matches("data-w-theme").count(), 1);
7509    }
7510
7511    #[test]
7512    fn test_inject_theme_restore_head_with_attrs_and_fragment() {
7513        let html = r#"<html><head lang="en"><title>T</title></head><body></body></html>"#;
7514        let result = inject_theme_restore(html);
7515        assert!(result.contains("data-w-theme"));
7516
7517        // Fragments without a <head> stay untouched
7518        let fragment = "<p>partial</p>";
7519        assert_eq!(inject_theme_restore(fragment), fragment);
7520    }
7521
7522    #[test]
7523    fn test_inject_what_css_into_head() {
7524        let html = "<html><head><title>Test</title></head><body></body></html>";
7525        let result = inject_what_css(html, CssMode::Full);
7526        assert!(result.contains(WHAT_CSS_ASSET_PATH.as_str()));
7527        assert!(result.contains("<head>\n"));
7528    }
7529
7530    #[test]
7531    fn test_inject_what_css_skips_if_present() {
7532        let html = r#"<html><head><link rel="stylesheet" href="/static/what.css"></head><body></body></html>"#;
7533        let result = inject_what_css(html, CssMode::Full);
7534        assert_eq!(
7535            result.matches("what.css").count(),
7536            1,
7537            "Should not duplicate"
7538        );
7539    }
7540
7541    #[test]
7542    fn test_inject_what_css_no_head() {
7543        let html = "<p>just a fragment</p>";
7544        let result = inject_what_css(html, CssMode::Full);
7545        assert!(!result.contains("what.css"), "Should skip without <head>");
7546        assert_eq!(result, html);
7547    }
7548
7549    #[test]
7550    fn test_inject_what_css_none_mode_skips() {
7551        let html = "<html><head><title>Test</title></head><body></body></html>";
7552        let result = inject_what_css(html, CssMode::None);
7553        assert_eq!(result, html, "none mode must not inject anything");
7554    }
7555
7556    #[test]
7557    fn test_inject_what_css_minimal_mode_uses_minimal_path() {
7558        let html = "<html><head><title>Test</title></head><body></body></html>";
7559        let result = inject_what_css(html, CssMode::Minimal);
7560        assert!(result.contains(WHAT_CSS_MINIMAL_ASSET_PATH.as_str()));
7561        assert_ne!(
7562            WHAT_CSS_MINIMAL_ASSET_PATH.as_str(),
7563            WHAT_CSS_ASSET_PATH.as_str(),
7564            "minimal and full variants must cache-bust independently"
7565        );
7566    }
7567
7568    #[test]
7569    fn test_minimal_css_slice() {
7570        let minimal = MINIMAL_WHAT_CSS.as_str();
7571        // Cut markers must exist in the source stylesheet — the slice silently
7572        // falls back to the full sheet if an edit ever removes them.
7573        assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_START), "cut-start marker missing from what.css");
7574        assert!(EMBEDDED_WHAT_CSS.contains(MINIMAL_CUT_END), "cut-end marker missing from what.css");
7575        assert!(minimal.len() < EMBEDDED_WHAT_CSS.len());
7576        // Keeps: reset/theme layer, utilities layer, source viewer
7577        assert!(minimal.contains("@layer base"));
7578        assert!(minimal.contains("@layer utilities"));
7579        assert!(minimal.contains(".page-source"));
7580        // Drops: components + interactions layers
7581        assert!(!minimal.contains("@layer components"));
7582        assert!(!minimal.contains("@layer interactions {"));
7583        assert!(!minimal.contains(".what-pagination"));
7584    }
7585
7586    #[test]
7587    fn test_css_mode_from_config() {
7588        assert_eq!(CssMode::from_config("full").unwrap(), CssMode::Full);
7589        assert_eq!(CssMode::from_config("minimal").unwrap(), CssMode::Minimal);
7590        assert_eq!(CssMode::from_config("none").unwrap(), CssMode::None);
7591        let err = CssMode::from_config("compact").unwrap_err().to_string();
7592        assert!(err.contains("compact"), "error should echo the bad value: {err}");
7593    }
7594
7595    #[test]
7596    fn test_validate_csrf_token_valid() {
7597        let mut session = sessions::Session::new(3600);
7598        session
7599            .data
7600            .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7601        assert!(validate_csrf_token(
7602            Some(&session),
7603            Some("correct_token"),
7604            None
7605        ));
7606    }
7607
7608    #[test]
7609    fn test_validate_csrf_token_invalid() {
7610        let mut session = sessions::Session::new(3600);
7611        session
7612            .data
7613            .insert(CSRF_SESSION_KEY.to_string(), json!("correct_token"));
7614        assert!(!validate_csrf_token(
7615            Some(&session),
7616            Some("wrong_token"),
7617            None
7618        ));
7619    }
7620
7621    #[test]
7622    fn test_validate_csrf_token_from_header() {
7623        let mut session = sessions::Session::new(3600);
7624        session
7625            .data
7626            .insert(CSRF_SESSION_KEY.to_string(), json!("header_token"));
7627        assert!(validate_csrf_token(
7628            Some(&session),
7629            None,
7630            Some("header_token")
7631        ));
7632    }
7633
7634    #[test]
7635    fn test_validate_csrf_token_no_session() {
7636        assert!(!validate_csrf_token(None, Some("any_token"), None));
7637    }
7638
7639    #[test]
7640    fn test_validate_csrf_token_missing_token() {
7641        let mut session = sessions::Session::new(3600);
7642        session
7643            .data
7644            .insert(CSRF_SESSION_KEY.to_string(), json!("token"));
7645        assert!(!validate_csrf_token(Some(&session), None, None));
7646    }
7647
7648    #[test]
7649    fn test_csrf_exempt_paths() {
7650        assert!(is_csrf_exempt("/w-livereload"));
7651        assert!(is_csrf_exempt("/w-wire"));
7652        assert!(!is_csrf_exempt("/w-set"));
7653        assert!(!is_csrf_exempt("/w-action/items"));
7654        assert!(!is_csrf_exempt("/w-auth/login"));
7655    }
7656
7657    #[test]
7658    fn test_session_new_has_csrf_token() {
7659        let session = sessions::Session::new(3600);
7660        let csrf_token = session.data.get(CSRF_SESSION_KEY);
7661        assert!(csrf_token.is_some(), "New session should have a CSRF token");
7662        let token_str = csrf_token.unwrap().as_str().unwrap();
7663        assert_eq!(
7664            token_str.len(),
7665            64,
7666            "CSRF token should be 64 hex chars (32 bytes)"
7667        );
7668    }
7669
7670    // ---- Filename Sanitization Tests ----
7671
7672    #[test]
7673    fn test_sanitize_extension_normal() {
7674        assert_eq!(sanitize_extension("jpg"), ".jpg");
7675        assert_eq!(sanitize_extension("png"), ".png");
7676        assert_eq!(sanitize_extension("PDF"), ".PDF");
7677    }
7678
7679    #[test]
7680    fn test_sanitize_extension_strips_path_separators() {
7681        // Path separators and backslashes are stripped; dots are kept (harmless with UUID prefix)
7682        let result = sanitize_extension("../../../etc/passwd");
7683        assert!(!result.contains('/'));
7684        assert!(!result.contains('\\'));
7685        assert!(result.ends_with("etcpasswd"));
7686
7687        let result = sanitize_extension("..\\..\\windows");
7688        assert!(!result.contains('\\'));
7689    }
7690
7691    #[test]
7692    fn test_sanitize_extension_strips_null_bytes() {
7693        assert_eq!(sanitize_extension("jpg\0exe"), ".jpgexe");
7694    }
7695
7696    #[test]
7697    fn test_sanitize_extension_strips_non_ascii() {
7698        assert_eq!(sanitize_extension("jp\u{00e9}g"), ".jpg");
7699    }
7700
7701    #[test]
7702    fn test_sanitize_extension_empty() {
7703        assert_eq!(sanitize_extension(""), "");
7704        assert_eq!(sanitize_extension("///"), "");
7705    }
7706
7707    #[test]
7708    fn test_collect_fetch_local_directive() {
7709        let mut directives = crate::parser::PageDirectives::default();
7710        directives
7711            .custom
7712            .insert("fetch.posts".to_string(), "local:posts".to_string());
7713        directives.custom.insert(
7714            "fetch.posts.sort".to_string(),
7715            "created_at:desc".to_string(),
7716        );
7717        directives.custom.insert(
7718            "fetch.posts.filter".to_string(),
7719            "status=published".to_string(),
7720        );
7721        directives
7722            .custom
7723            .insert("fetch.posts.limit".to_string(), "10".to_string());
7724        directives
7725            .custom
7726            .insert("fetch.posts.offset".to_string(), "20".to_string());
7727        directives
7728            .custom
7729            .insert("fetch.posts.search".to_string(), "rust".to_string());
7730        directives.custom.insert(
7731            "fetch.posts.search_fields".to_string(),
7732            "title,content".to_string(),
7733        );
7734
7735        let fetches = collect_fetch_directives(None, Some(&directives));
7736        assert_eq!(fetches.len(), 1);
7737        let posts = fetches.get("posts").unwrap();
7738        assert!(posts.is_local());
7739        assert_eq!(posts.local_collection(), Some("posts"));
7740        assert_eq!(posts.sort.as_deref(), Some("created_at:desc"));
7741        assert_eq!(posts.filter.as_deref(), Some("status=published"));
7742        assert_eq!(posts.limit, Some(10));
7743        assert_eq!(posts.offset, Some(20));
7744        assert_eq!(posts.search.as_deref(), Some("rust"));
7745        assert_eq!(posts.search_fields.as_deref(), Some("title,content"));
7746    }
7747
7748    #[test]
7749    fn test_local_collection_strips_query_string() {
7750        // Regression: `local:coll?sort=...` must yield collection `coll`, not a
7751        // sanitized table name like `collsortcreated_atdesc`.
7752        let mut directives = crate::parser::PageDirectives::default();
7753        directives.custom.insert(
7754            "fetch.posts".to_string(),
7755            "local:posts?sort=created_at:desc&limit=5&filter=status=published".to_string(),
7756        );
7757        let fetches = collect_fetch_directives(None, Some(&directives));
7758        let posts = fetches.get("posts").unwrap();
7759        assert!(posts.is_local());
7760        assert_eq!(posts.local_collection(), Some("posts"));
7761        assert_eq!(
7762            posts.local_query(),
7763            Some("sort=created_at:desc&limit=5&filter=status=published")
7764        );
7765
7766        // Plain local: has no query string.
7767        let mut d2 = crate::parser::PageDirectives::default();
7768        d2.custom
7769            .insert("fetch.x".to_string(), "local:items".to_string());
7770        let f2 = collect_fetch_directives(None, Some(&d2));
7771        assert_eq!(f2.get("x").unwrap().local_collection(), Some("items"));
7772        assert_eq!(f2.get("x").unwrap().local_query(), None);
7773    }
7774
7775    #[test]
7776    fn test_is_framework_field() {
7777        // Control fields are dropped before persisting a record...
7778        assert!(is_framework_field("_csrf"));
7779        assert!(is_framework_field("w-rules"));
7780        assert!(is_framework_field("w-partial"));
7781        assert!(is_framework_field("redirect"));
7782        assert!(is_framework_field("cf-turnstile-response"));
7783        // ...but real app data (incl. _session_id) is kept.
7784        assert!(!is_framework_field("_session_id"));
7785        assert!(!is_framework_field("message"));
7786        assert!(!is_framework_field("name"));
7787    }
7788
7789    #[test]
7790    fn test_fetch_local_not_remote() {
7791        let d = FetchDirective::simple("posts".to_string(), "local:posts".to_string());
7792        assert!(d.is_local());
7793        assert_eq!(d.local_collection(), Some("posts"));
7794
7795        let d2 = FetchDirective::simple(
7796            "api".to_string(),
7797            "https://api.example.com/data".to_string(),
7798        );
7799        assert!(!d2.is_local());
7800        assert_eq!(d2.local_collection(), None);
7801    }
7802
7803    // ---- Middleware / Hooks tests (v0.9.0 feature 4.1) ----
7804
7805    #[test]
7806    fn test_redirects_config_parsing() {
7807        let toml_str = r##"
7808[redirects]
7809"/old-blog" = "/blog"
7810"/legacy/*" = "/modern"
7811"/about-us" = "/about"
7812"##;
7813        let config: crate::Config = toml::from_str(toml_str).unwrap();
7814        assert_eq!(config.redirects.len(), 3);
7815        assert_eq!(config.redirects.get("/old-blog").unwrap(), "/blog");
7816        assert_eq!(config.redirects.get("/legacy/*").unwrap(), "/modern");
7817        assert_eq!(config.redirects.get("/about-us").unwrap(), "/about");
7818    }
7819
7820    #[test]
7821    fn test_redirects_config_empty_by_default() {
7822        let config = crate::Config::default();
7823        assert!(config.redirects.is_empty());
7824    }
7825
7826    #[test]
7827    fn test_custom_headers_in_application_what() {
7828        let content = r#"header.Access-Control-Allow-Origin = "*"
7829header.Cache-Control = "no-store"
7830header.X-Custom = "hello"
7831"#;
7832        let config = crate::parser::parse_what_file(content);
7833        assert_eq!(config.directives.headers.len(), 3);
7834        assert_eq!(
7835            config
7836                .directives
7837                .headers
7838                .get("access-control-allow-origin")
7839                .unwrap(),
7840            "*"
7841        );
7842        assert_eq!(
7843            config.directives.headers.get("cache-control").unwrap(),
7844            "no-store"
7845        );
7846        assert_eq!(config.directives.headers.get("x-custom").unwrap(), "hello");
7847    }
7848
7849    #[test]
7850    fn test_custom_headers_merge_in_config() {
7851        let parent_content = r#"header.X-Frame-Options = "DENY"
7852header.X-Custom = "parent"
7853"#;
7854        let child_content = r#"header.X-Custom = "child"
7855header.X-New = "added"
7856"#;
7857        let mut parent = crate::parser::parse_what_file(parent_content);
7858        let child = crate::parser::parse_what_file(child_content);
7859        parent.merge(&child);
7860
7861        // Child overrides parent for same header
7862        assert_eq!(parent.directives.headers.get("x-custom").unwrap(), "child");
7863        // Parent headers preserved
7864        assert_eq!(
7865            parent.directives.headers.get("x-frame-options").unwrap(),
7866            "DENY"
7867        );
7868        // New child headers added
7869        assert_eq!(parent.directives.headers.get("x-new").unwrap(), "added");
7870    }
7871
7872    #[test]
7873    fn test_custom_headers_in_page_directive_content() {
7874        let html = r##"<what>
7875header.Cache-Control: no-cache
7876header.X-Robots-Tag: noindex
7877</what>
7878<h1>Hello</h1>"##;
7879        let (directives, _cleaned) = crate::parser::parse_page_directives(html);
7880        assert_eq!(directives.headers.get("cache-control").unwrap(), "no-cache");
7881        assert_eq!(directives.headers.get("x-robots-tag").unwrap(), "noindex");
7882    }
7883
7884    #[test]
7885    fn test_custom_headers_not_in_template_context() {
7886        let content = r#"title = "My Page"
7887header.X-Custom = "value"
7888"#;
7889        let config = crate::parser::parse_what_file(content);
7890        // Title should be in values, headers should NOT
7891        assert!(config.values.contains_key("title"));
7892        assert!(!config.values.contains_key("header.x-custom"));
7893    }
7894
7895    #[tokio::test]
7896    async fn test_redirect_middleware_exact_match() {
7897        use axum::body::Body;
7898        use axum::http::Request;
7899        use std::collections::HashMap;
7900        use tower::ServiceExt;
7901
7902        let mut redirects = HashMap::new();
7903        redirects.insert("/old".to_string(), "/new".to_string());
7904
7905        let mut config = crate::Config::default();
7906        config.redirects = redirects;
7907        config.session.enabled = false;
7908
7909        let temp_dir = create_test_project();
7910        let root = temp_dir.path().to_path_buf();
7911        let state = AppState::with_dev_mode(config, root, true).unwrap();
7912
7913        let app = create_router(state);
7914
7915        let request = Request::builder().uri("/old").body(Body::empty()).unwrap();
7916
7917        let response = app.oneshot(request).await.unwrap();
7918        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
7919        assert_eq!(
7920            response
7921                .headers()
7922                .get("location")
7923                .unwrap()
7924                .to_str()
7925                .unwrap(),
7926            "/new"
7927        );
7928    }
7929
7930    #[tokio::test]
7931    async fn test_redirect_middleware_wildcard() {
7932        use axum::body::Body;
7933        use axum::http::Request;
7934        use std::collections::HashMap;
7935        use tower::ServiceExt;
7936
7937        let mut redirects = HashMap::new();
7938        redirects.insert("/legacy/*".to_string(), "/modern".to_string());
7939
7940        let mut config = crate::Config::default();
7941        config.redirects = redirects;
7942        config.session.enabled = false;
7943
7944        let temp_dir = create_test_project();
7945        let root = temp_dir.path().to_path_buf();
7946        let state = AppState::with_dev_mode(config, root, true).unwrap();
7947
7948        let app = create_router(state);
7949
7950        let request = Request::builder()
7951            .uri("/legacy/old-page")
7952            .body(Body::empty())
7953            .unwrap();
7954
7955        let response = app.oneshot(request).await.unwrap();
7956        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
7957        assert_eq!(
7958            response
7959                .headers()
7960                .get("location")
7961                .unwrap()
7962                .to_str()
7963                .unwrap(),
7964            "/modern"
7965        );
7966    }
7967
7968    #[tokio::test]
7969    async fn test_redirect_middleware_no_match_passes_through() {
7970        use axum::body::Body;
7971        use axum::http::Request;
7972        use std::collections::HashMap;
7973        use tower::ServiceExt;
7974
7975        let mut redirects = HashMap::new();
7976        redirects.insert("/old".to_string(), "/new".to_string());
7977
7978        let mut config = crate::Config::default();
7979        config.redirects = redirects;
7980        config.session.enabled = false;
7981
7982        let temp_dir = create_test_project();
7983        let root = temp_dir.path().to_path_buf();
7984        let state = AppState::with_dev_mode(config, root, true).unwrap();
7985
7986        let app = create_router(state);
7987
7988        let request = Request::builder()
7989            .uri("/unrelated")
7990            .body(Body::empty())
7991            .unwrap();
7992
7993        let response = app.oneshot(request).await.unwrap();
7994        // Should NOT be a redirect — should pass through to normal routing (404 in this case)
7995        assert_ne!(response.status(), StatusCode::PERMANENT_REDIRECT);
7996    }
7997
7998    #[tokio::test]
7999    async fn test_health_endpoint_returns_ok() {
8000        use axum::body::Body;
8001        use axum::http::Request;
8002        use tower::ServiceExt;
8003
8004        let mut config = crate::Config::default();
8005        config.session.enabled = false;
8006
8007        let temp_dir = create_test_project();
8008        let root = temp_dir.path().to_path_buf();
8009        let state = AppState::with_dev_mode(config, root, true).unwrap();
8010
8011        let app = create_router(state);
8012
8013        let request = Request::builder()
8014            .uri("/health")
8015            .body(Body::empty())
8016            .unwrap();
8017
8018        let response = app.oneshot(request).await.unwrap();
8019        assert_eq!(response.status(), StatusCode::OK);
8020
8021        let body = axum::body::to_bytes(response.into_body(), 1024)
8022            .await
8023            .unwrap();
8024        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
8025        assert_eq!(json["status"], "ok");
8026        assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
8027    }
8028
8029    #[test]
8030    fn test_fetch_timeout_default() {
8031        let config = crate::Config::default();
8032        assert_eq!(config.server.fetch_timeout, 10);
8033    }
8034
8035    #[test]
8036    fn test_fetch_timeout_custom() {
8037        let toml_str = r#"
8038[server]
8039fetch_timeout = 30
8040"#;
8041        let config: crate::Config = toml::from_str(toml_str).unwrap();
8042        assert_eq!(config.server.fetch_timeout, 30);
8043    }
8044
8045    #[test]
8046    fn test_http_client_uses_configured_timeout() {
8047        let mut config = crate::Config::default();
8048        config.server.fetch_timeout = 5;
8049        config.session.enabled = false;
8050
8051        let temp_dir = create_test_project();
8052        let root = temp_dir.path().to_path_buf();
8053        let state = AppState::with_dev_mode(config, root, false).unwrap();
8054
8055        // Verify the client was created (no panic) with the custom timeout
8056        // The reqwest::Client doesn't expose timeout publicly, but we verify it was built
8057        let _client = &state.http_client;
8058    }
8059
8060    // ---- Wired Scope Filtering Tests ----
8061
8062    #[test]
8063    fn wired_public_reaches_all() {
8064        let temp_dir = create_test_project();
8065        let root = temp_dir.path().to_path_buf();
8066        let config = crate::Config::default();
8067        let state = AppState::new(config, root).unwrap();
8068
8069        let mut rx = state.wired_tx.subscribe();
8070        let _ = state.wired_tx.send(WiredMessage {
8071            json: r#"{"wired.counter":"5"}"#.to_string(),
8072            scope: WiredScope::Public,
8073        });
8074        let msg = rx.try_recv().unwrap();
8075        // Public messages reach all clients (no filtering needed)
8076        assert!(msg.scope.allows(&[], None)); // anonymous
8077        assert!(msg.scope.allows(&["admin".into()], Some("user1"))); // authenticated
8078    }
8079
8080    #[test]
8081    fn wired_role_filters() {
8082        let temp_dir = create_test_project();
8083        let root = temp_dir.path().to_path_buf();
8084        let config = crate::Config::default();
8085        let state = AppState::new(config, root).unwrap();
8086
8087        let mut rx = state.wired_tx.subscribe();
8088        let _ = state.wired_tx.send(WiredMessage {
8089            json: r#"{"wired.revenue":"1000"}"#.to_string(),
8090            scope: WiredScope::Roles(vec!["admin".into()]),
8091        });
8092        let msg = rx.try_recv().unwrap();
8093        assert!(msg.scope.allows(&["admin".into()], None));
8094        assert!(!msg.scope.allows(&["viewer".into()], None));
8095        assert!(!msg.scope.allows(&[], None)); // anonymous
8096    }
8097
8098    #[test]
8099    fn wired_multi_role() {
8100        let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
8101        assert!(scope.allows(&["editor".into()], None));
8102        assert!(scope.allows(&["admin".into()], None));
8103        assert!(!scope.allows(&["viewer".into()], None));
8104    }
8105
8106    #[test]
8107    fn wired_user_scope() {
8108        let scope = WiredScope::User("user42".into());
8109        assert!(scope.allows(&[], Some("user42")));
8110        assert!(!scope.allows(&["admin".into()], Some("other_user")));
8111        assert!(!scope.allows(&[], None));
8112    }
8113
8114    #[test]
8115    fn wired_unauthenticated_public_only() {
8116        // Anonymous client should only receive Public messages
8117        let public = WiredScope::Public;
8118        let admin_only = WiredScope::Roles(vec!["admin".into()]);
8119        let user_only = WiredScope::User("user1".into());
8120
8121        assert!(public.allows(&[], None));
8122        assert!(!admin_only.allows(&[], None));
8123        assert!(!user_only.allows(&[], None));
8124    }
8125
8126    #[test]
8127    fn wired_undeclared_key_defaults_public() {
8128        let temp_dir = create_test_project();
8129        let root = temp_dir.path().to_path_buf();
8130        let config = crate::Config::default();
8131        let state = AppState::new(config, root).unwrap();
8132
8133        // get_wired_scope is async, but we can test the default behavior
8134        // by checking that the scopes map is empty (all keys default to Public)
8135        let rt = tokio::runtime::Runtime::new().unwrap();
8136        let scope = rt.block_on(state.get_wired_scope("nonexistent_key"));
8137        assert!(matches!(scope, WiredScope::Public));
8138    }
8139
8140    #[test]
8141    fn test_session_mutation_with_filters() {
8142        let mut session_data = HashMap::new();
8143        session_data.insert("name".to_string(), json!("alice"));
8144        session_data.insert("bio".to_string(), json!("Hello world from alice"));
8145
8146        // Filters inside #...# references should work
8147        let val = resolve_session_value(&json!("#session.name|uppercase#"), &session_data);
8148        assert_eq!(val, json!("ALICE"));
8149
8150        let val = resolve_session_value(&json!("#session.bio|truncate:10#"), &session_data);
8151        assert_eq!(val, json!("Hello worl..."));
8152
8153        let val = resolve_session_value(&json!("Hi #session.name|capitalize#!"), &session_data);
8154        assert_eq!(val, json!("Hi Alice!"));
8155    }
8156}