//! Demo-only retention and a conservative database provenance boundary before migrations. use crate::{ error::{Error, Result, ensure}, store::Store, }; use chrono::{DateTime, Utc}; use cot::db::{Database, DatabaseError}; use serde::Serialize; use std::sync::OnceLock; use uuid::Uuid; #[derive(Debug, Clone, Serialize)] pub struct Retention { pub workspaces: i64, pub accounts: i64, pub requests: i64, pub events: i64, pub purge_grace: i64, } impl Default for Retention { fn default() -> Self { Self { workspaces: 86400, accounts: 172800, requests: 7200, events: 21600, purge_grace: 600, } } } impl Retention { pub fn from_env() -> Result { fn read(name: &str, default: i64) -> Result { let value = std::env::var(name).map_or(Ok(default), |v| { v.parse().map_err(|_| { Error::Validation(format!("{name} must be an integer number of seconds")) }) })?; ensure( (60..=2592000).contains(&value), format!("{name} must be 60–2592000 seconds"), )?; Ok(value) } let d = Self::default(); Ok(Self { workspaces: read("MEGURI_DEMO_WORKSPACE_TTL_SECONDS", d.workspaces)?, accounts: read("MEGURI_DEMO_ACCOUNT_TTL_SECONDS", d.accounts)?, requests: read("MEGURI_DEMO_REQUEST_TTL_SECONDS", d.requests)?, events: read("MEGURI_DEMO_EVENT_TTL_SECONDS", d.events)?, purge_grace: read("MEGURI_DEMO_PURGE_GRACE_SECONDS", d.purge_grace)?, }) } } #[derive(Debug, Clone, Serialize)] pub struct Notice { pub isolated: bool, pub retention: Retention, } static NOTICE: OnceLock = OnceLock::new(); pub fn notice() -> Option<&'static Notice> { NOTICE.get() } pub fn active() -> bool { NOTICE.get().is_some() } pub fn install(notice: Notice) { let _ = NOTICE.set(notice); } fn identifier(name: &str) -> String { format!("\"{}\"", name.replace('"', "\"\"")) } fn code(error: &DatabaseError) -> Option { if let DatabaseError::DatabaseEngineError(e) = error { e.as_database_error() .and_then(|e| e.code().map(|c| c.into_owned())) } else { None } } pub fn application_url(url: &str, demo: bool) -> Result { let mut parsed = reqwest::Url::parse(url).map_err(|_| Error::Validation("Invalid PostgreSQL URL".into()))?; ensure(parsed.scheme() == "postgresql", "Use a postgresql:// URL")?; let pairs: Vec<_> = parsed .query_pairs() .filter(|(k, _)| k != "application_name") .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); parsed.set_query(None); parsed.query_pairs_mut().extend_pairs(pairs).append_pair( "application_name", &format!( "meguri-{}-{}", if demo { "demo" } else { "normal" }, Uuid::new_v4().simple() ), ); Ok(parsed.to_string()) } const CREATE_GUARD: &str = "CREATE TABLE IF NOT EXISTS public.meguri_runtime_guard(id boolean PRIMARY KEY CHECK(id),signature text NOT NULL,last_mode text NOT NULL CHECK(last_mode IN('normal','demo')),started_at timestamptz NOT NULL)"; const PROOF: &str = "DO $$ BEGIN IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; END $$;"; /// A failed proof never authorizes a write. No migration or marker update happens here. pub async fn is_demo_database(db: &Database) -> bool { db.raw(PROOF).await.is_ok() } /// Claim only a database with no user relations. A pre-existing application DB is never claimed. pub async fn claim_empty(db: &Database) -> Result<()> { db.raw(&format!("DO $$ BEGIN PERFORM pg_advisory_xact_lock(726482039); IF EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname<>'information_schema' AND c.relkind IN('r','p','v','m','f','S')) THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; {CREATE_GUARD}; INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','demo',clock_timestamp()); END $$;")).await?; Ok(()) } pub async fn mark_normal(db: &Database) -> Result<()> { db.raw(&format!("DO $$ BEGIN PERFORM pg_advisory_xact_lock(726482039); {CREATE_GUARD}; INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','normal',clock_timestamp()) ON CONFLICT(id) DO UPDATE SET last_mode='normal',started_at=clock_timestamp(); END $$;")).await?; Ok(()) } /// Uses the configured DB only with positive demo provenance. Otherwise creates/reuses a /// separately named DB on the same server. Protected candidates and migration tables survive. pub async fn select_database(configured: &str, alternate: Option<&str>) -> Result<(String, bool)> { let configured = application_url(configured, true)?; let source = Database::new(&configured).await?; if is_demo_database(&source).await { return Ok((configured, false)); } let base = reqwest::Url::parse(alternate.unwrap_or(&configured)) .map_err(|_| Error::Validation("Invalid MEGURI_DEMO_DATABASE_URL".into()))?; let fingerprint = crate::generation::stable_id(&serde_json::json!([ base.host_str(), base.port(), base.path() ])); for attempt in 0..8 { let mut target = base.clone(); let name = if alternate.is_some() && attempt == 0 { base.path().trim_start_matches('/').to_owned() } else { format!( "meguri_demo_{}_{}", &fingerprint.simple().to_string()[..12], attempt ) }; ensure(!name.is_empty(), "A demo database name is required")?; target.set_path(&format!("/{name}")); let target = application_url(target.as_str(), true)?; let database = match Database::new(&target).await { Ok(db) => db, Err(e) if code(&e).as_deref() == Some("3D000") => { // Database creation is the only cluster-level write; no DROP DATABASE/schema/CASCADE. let mut admin = base.clone(); admin.set_path("/postgres"); let admin = Database::new(application_url(admin.as_str(), true)?).await?; if let Err(e) = admin .raw(&format!("CREATE DATABASE {}", identifier(&name))) .await && code(&e).as_deref() != Some("42P04") { return Err(Error::Validation("Cannot create an isolated demo database. Ask the administrator to provision an empty database and set MEGURI_DEMO_DATABASE_URL. The configured database was not changed.".into())); } Database::new(&target).await? } Err(e) => return Err(e.into()), }; if is_demo_database(&database).await { return Ok((target, true)); } if claim_empty(&database).await.is_ok() { return Ok((target, true)); } } Err(Error::Validation("No safe demo database is available. Protected databases were not modified; configure a separate empty MEGURI_DEMO_DATABASE_URL.".into())) } /// Truncate only the enumerated application state, after rechecking provenance under lock. /// No CASCADE: an unexpected external dependency aborts the entire reset. pub async fn reset(db: &Database) -> Result<()> { db.raw("DO $$ DECLARE tables text; BEGIN PERFORM pg_advisory_xact_lock(726482039); IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE; IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF; SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL; IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF; UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id; END $$;").await?; Ok(()) } pub async fn cleanup(db: &Store, now: DateTime, ttl: &Retention) -> Result<()> { // The server checks provenance on every sweep too. A changed mode stops cleanup. ensure( is_demo_database(&db.db).await, "Cleanup refused: database is not marked as demo", )?; db.db .raw_with( CLEANUP, &[&now, &ttl.workspaces, &ttl.accounts, &ttl.requests], ) .await?; db.db .raw_with(PURGE, &[&now, &ttl.purge_grace, &ttl.events]) .await?; Ok(()) } pub async fn run(db: Store, ttl: Retention) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); loop { interval.tick().await; if let Err(error) = async { cleanup(&db, Utc::now(), &ttl).await?; crate::demo::seed(&db).await } .await { tracing::error!(%error,"demo maintenance failed"); } } } const CLEANUP: &str = r#" WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE), expired AS (UPDATE meguri__workspaces SET demo_deleted_at=$1,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$2::bigint::double precision) RETURNING id), expired_users AS (UPDATE meguri__users SET demo_deleted_at=$1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$3::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__demo_pins p WHERE p.kind='user' AND p.id=meguri__users.id) RETURNING id), sessions AS (DELETE FROM meguri__sessions WHERE EXISTS(SELECT 1 FROM guard) AND (user_id IN(SELECT id FROM expired_users) OR expires_at<=$1)), jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM expired)), requests AS ( UPDATE meguri__workspaces SET document=jsonb_set(document::jsonb,'{requests}',COALESCE((SELECT jsonb_agg(r) FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz>$1-make_interval(secs=>$4::bigint::double precision)),'[]'::jsonb))::text,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND id NOT IN(SELECT id FROM expired) AND EXISTS(SELECT 1 FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz<=$1-make_interval(secs=>$4::bigint::double precision)) RETURNING * ), history AS (INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) SELECT id||':'||revision::text,id,revision,document,$1 FROM requests), request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',next_attempt=$1) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests; "#; const PURGE: &str = r#" WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE), workspaces AS MATERIALIZED (SELECT id FROM meguri__workspaces WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision)), members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM workspaces)), history AS (DELETE FROM meguri__workspace_history h WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND effective_at<$1-make_interval(secs=>$3::bigint::double precision) AND EXISTS(SELECT 1 FROM meguri__workspace_history newer WHERE newer.workspace_id=h.workspace_id AND newer.revision>h.revision AND newer.effective_at<$1-make_interval(secs=>$3::bigint::double precision)))), audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND created_at<$1-make_interval(secs=>$3::bigint::double precision))), jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM workspaces)), objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM workspaces)), pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM workspaces)), removed AS (DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM workspaces)), users AS MATERIALIZED (SELECT id FROM meguri__users u WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.user_id=u.id AND m.workspace_id NOT IN(SELECT id FROM workspaces))), sessions AS (DELETE FROM meguri__sessions WHERE user_id IN(SELECT id FROM users)), identities AS (DELETE FROM meguri__external_identities WHERE user_id IN(SELECT id FROM users)) DELETE FROM meguri__users WHERE id IN(SELECT id FROM users); "#;