diff --git a/Justfile b/Justfile index 9730b1270fa..6b979015c65 100644 --- a/Justfile +++ b/Justfile @@ -256,7 +256,18 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ cargo test compiled_policy_matches_expected -- --ignored --nocapture - echo "Both compiled states verified." + echo "=== Maximum accepted demo name reaches Rust build validation ===" + DEMO_CONFIG="$(node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..31})" /dev/null 1234567812345678)" + DEMO_SLUG="$(node -e 'console.log(JSON.parse(process.argv[1]).slug)' "$DEMO_CONFIG")" + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" \ + BUZZ_TEST_EXPECTED_DEMO_SLUG="$DEMO_SLUG" \ + cargo test compiled_demo_slug_matches_expected -- --ignored --nocapture + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" cargo test --workspace + if node ../scripts/demo-build-config.mjs "$(printf 'x%.0s' {1..32})" /dev/null 1234567812345678; then + echo "A 32-character demo name unexpectedly passed JavaScript validation" >&2 + exit 1 + fi + echo "Both compiled states and the accepted/rejected demo-name boundary verified." # Build the full desktop Tauri app locally (unsigned, for testing) # Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. @@ -277,6 +288,38 @@ desktop-release-build target="aarch64-apple-darwin": pnpm install cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}} +# Build an unsigned named macOS demo DMG with isolated app and runtime identities. +desktop-demo-build demo_name target="aarch64-apple-darwin": + #!/usr/bin/env bash + set -euo pipefail + TARGET={{target}} + [[ "$(uname -s)" == "Darwin" && "$TARGET" == *-apple-darwin ]] || { echo "Demo DMGs require a macOS Apple target" >&2; exit 2; } + CONFIG_PATH="$(mktemp "${TMPDIR:-/tmp}/buzz-demo-config.XXXXXX")" + trap 'rm -f "$CONFIG_PATH"' EXIT + DEMO_BUILD_ID="$(node -e 'console.log(require("node:crypto").randomBytes(8).toString("hex"))')" + DEMO_CONFIG="$(node desktop/scripts/demo-build-config.mjs {{quote(demo_name)}} "$CONFIG_PATH" "$DEMO_BUILD_ID")" + read_config() { node -e 'console.log(JSON.parse(process.argv[1])[process.argv[2]])' "$DEMO_CONFIG" "$1"; } + PRODUCT_NAME="$(read_config productName)" + DMG_VOLUME_NAME="$(read_config dmgVolumeName)" + DMG_FILE_STEM="$(read_config dmgFileStem)" + DEMO_SLUG="$(read_config slug)" + cargo build --release --target "$TARGET" \ + -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp \ + -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + pnpm install + cd {{desktop_dir}} + BUZZ_BUILD_DEMO_SLUG="$DEMO_SLUG" pnpm tauri build --features mesh-llm --target "$TARGET" --bundles app --config "$CONFIG_PATH" + cd .. + VERSION="$(node -p "require('./desktop/package.json').version")" + DMG_ARCH="${TARGET%%-*}"; [[ "$DMG_ARCH" == "x86_64" ]] && DMG_ARCH=x64 + APP_PATH="desktop/src-tauri/target/$TARGET/release/bundle/macos/$PRODUCT_NAME.app" + PLIST="$APP_PATH/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $PRODUCT_NAME" "$PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleName $PRODUCT_NAME" "$PLIST" + codesign --force --deep --sign - "$APP_PATH" + VOL_NAME="$DMG_VOLUME_NAME" ./desktop/scripts/package-macos-dmg.sh "$APP_PATH" "desktop/src-tauri/target/$TARGET/release/bundle/dmg/${DMG_FILE_STEM}_${VERSION}_${DMG_ARCH}.dmg" + # Run desktop checks suitable for CI / pre-push desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index a78a499bdd1..7ebabccbbbd 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -93,8 +93,9 @@ impl TokenSource for StaticTokenSource { /// /// The `discovery_url` must return a JSON document with at least /// `authorization_endpoint` and `token_endpoint` (RFC 8414). The -/// `cache_namespace` is the directory under `~/.config/buzz-agent/oauth/` -/// the token JSON lives in — separates providers' caches cleanly. +/// `cache_namespace` is the directory under the platform config directory's +/// `buzz-agent/oauth/` root where the token JSON lives — separates providers' +/// caches cleanly. #[derive(Debug, Clone)] pub struct PkceOAuthConfig { pub discovery_url: String, @@ -102,7 +103,7 @@ pub struct PkceOAuthConfig { pub scopes: Vec, pub cache_namespace: String, /// When `Some`, the engine writes tokens here instead of - /// `~/.config/buzz-agent/oauth//`. Production code + /// `/buzz-agent/oauth//`. Production code /// leaves this `None`. Integration tests use it to avoid stomping on /// a shared `$HOME` when running in parallel. pub cache_dir_override: Option, @@ -444,6 +445,29 @@ fn is_expired(t: &CachedToken) -> bool { now + TOKEN_REFRESH_LEEWAY.as_secs() >= exp } +const BUZZ_AGENT_CONFIG_DIR_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +fn oauth_cache_root_for( + config_override: Option, + home_dir: Option, +) -> Result { + if let Some(root) = config_override { + return Ok(root.join("buzz-agent").join("oauth")); + } + Ok(home_dir + .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? + .join(".config") + .join("buzz-agent") + .join("oauth")) +} + +fn default_oauth_cache_root() -> Result { + oauth_cache_root_for( + std::env::var_os(BUZZ_AGENT_CONFIG_DIR_ENV).map(PathBuf::from), + dirs::home_dir(), + ) +} + fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let mut h = sha2::Sha256::new(); h.update(cfg.discovery_url.as_bytes()); @@ -455,12 +479,7 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { let dir = match &cfg.cache_dir_override { Some(p) => p.join(&cfg.cache_namespace), - None => dirs::home_dir() - .ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))? - .join(".config") - .join("buzz-agent") - .join("oauth") - .join(&cfg.cache_namespace), + None => default_oauth_cache_root()?.join(&cfg.cache_namespace), }; Ok(dir.join(format!("{hash}.json"))) } @@ -862,7 +881,41 @@ mod tests { } #[test] - fn cache_path_uses_platform_home_directory() { + fn production_and_demo_oauth_roots_are_concrete_and_distinct() { + let home = PathBuf::from("/Users/demo"); + let production = oauth_cache_root_for(None, Some(home.clone())).unwrap(); + let first_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-1234567812345678"); + let second_demo_config = home + .join("Library/Application Support") + .join("buzz-demo-board-8765432187654321"); + let first_demo = oauth_cache_root_for(Some(first_demo_config), Some(home.clone())).unwrap(); + let second_demo = oauth_cache_root_for(Some(second_demo_config), Some(home)).unwrap(); + + assert_eq!( + production, + PathBuf::from("/Users/demo/.config/buzz-agent/oauth") + ); + assert_eq!( + first_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_eq!( + second_demo, + PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-8765432187654321/buzz-agent/oauth" + ) + ); + assert_ne!(production, first_demo); + assert_ne!(production, second_demo); + assert_ne!(first_demo, second_demo); + } + + #[test] + fn cache_path_preserves_production_home_config_directory() { let cfg = PkceOAuthConfig { discovery_url: "https://example.com/.well-known".into(), client_id: "abc".into(), diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 82f3b086cd6..f2cda834fd1 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -12,7 +12,7 @@ //! This helper never opens a browser. Callers choose whether to reject, degrade, //! or start a separate interactive authentication flow. -use std::{collections::HashSet, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::Path, sync::Arc, time::Duration}; use reqwest::Client; use serde_json::Value; @@ -133,7 +133,26 @@ pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { /// # Panics /// Never panics. pub async fn discover_databricks_models(cfg: &Config) -> Result, AgentError> { - discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await + discover_databricks_models_with_cache_dir(cfg, None).await +} + +/// Discover Databricks models while storing PKCE credentials under an explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn discover_databricks_models_with_cache_dir( + cfg: &Config, + cache_dir: Option<&Path>, +) -> Result, AgentError> { + let token_source = if matches!(cfg.provider, Provider::Databricks | Provider::DatabricksV2) + && cfg.api_key.is_empty() + { + crate::auth::PkceOAuthTokenSource::new(crate::llm::databricks_pkce_config( + &cfg.base_url, + cache_dir.map(Path::to_path_buf), + ))? + } else { + build_token_source(cfg)? + }; + discover_databricks_models_with_token_source(cfg, token_source).await } async fn discover_databricks_models_with_token_source( diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index b094a0f9fd7..3de47c82a4a 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -13,7 +13,9 @@ mod permission; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry}; +pub use catalog::{ + discover_databricks_models, discover_databricks_models_with_cache_dir, ModelEntry, +}; pub use config::Provider; pub use types::AgentError; @@ -161,10 +163,22 @@ pub fn run() -> Result<(), Box> { Ok(()) } +/// Authenticate to Databricks and store credentials under an optional explicit +/// cache root. `None` preserves buzz-agent's production cache location. +pub async fn authenticate_databricks_with_cache_dir( + host: &str, + cache_dir: Option<&std::path::Path>, +) -> Result<(), AgentError> { + auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config( + host, + cache_dir.map(std::path::Path::to_path_buf), + ))? + .interactive_login() + .await +} + pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> { - auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))? - .interactive_login() - .await + authenticate_databricks_with_cache_dir(host, None).await } /// `buzz-agent auth ` — run the interactive auth flow for a diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 1d46c16e163..1bac5147743 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -2033,7 +2034,10 @@ where ))) } -pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { +pub(crate) fn databricks_pkce_config( + host: &str, + cache_dir_override: Option, +) -> PkceOAuthConfig { PkceOAuthConfig { discovery_url: format!( "{}/oidc/.well-known/oauth-authorization-server", @@ -2045,7 +2049,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig { .map(|scope| (*scope).into()) .collect(), cache_namespace: "databricks".into(), - cache_dir_override: None, + cache_dir_override, } } @@ -2070,6 +2074,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A } Ok(PkceOAuthTokenSource::new(databricks_pkce_config( &cfg.base_url, + None, ))?) } } diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 42c9cc48780..a848557ae2f 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -45,6 +45,9 @@ const PASSTHROUGH_ENV: &[&str] = &[ "LC_ALL", "TMPDIR", "XDG_CONFIG_HOME", + // Explicit Buzz-owned OAuth root for named demo builds. The agent may spawn + // auth-capable child tools after clearing its ambient environment. + "BUZZ_AGENT_CONFIG_DIR", // SSH — required for git clone/push over SSH (git@github.com:...) "SSH_AUTH_SOCK", "SSH_AGENT_PID", diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index 940448dd2e4..b0e4ebc6e50 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -622,6 +622,8 @@ mod tests { Q::Vector { id: "dbv2-claude-opus-4-7-probe", provider: "databricks_v2", raw_model_id: "claude-opus-4-7", note: None }, Q::Vector { id: "dbv2-databricks-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-7", note: Some("Probes stripping of the databricks- catalog prefix.") }, Q::Vector { id: "dbv2-goose-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes stripping of the goose- catalog prefix.") }, + Q::Vector { id: "dbv2-goose-claude-4-6-sonnet-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-4-6-sonnet", note: Some("Probes the discovered Goose Sonnet 4.6 endpoint spelling and label.") }, + Q::Vector { id: "dbv2-goose-claude-4-7-opus-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-4-7-opus", note: Some("Probes the discovered Goose Opus 4.7 endpoint spelling and label.") }, Q::Vector { id: "dbv2-team-prefix-probe", provider: "databricks_v2", raw_model_id: "team-x-claude-opus-4-7", note: Some("Probes stripping of a team-x- catalog prefix.") }, Q::Vector { id: "dbv2-consolidated-llama-substring-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where a code word ('sol') appears only as a substring, not a boundary-aligned segment.") }, Q::Vector { id: "dbv2-terraform-coder-substring-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where a code word ('terra') is only a segment prefix, not a full segment.") }, @@ -638,6 +640,7 @@ mod tests { Q::Vector { id: "dbv2-goose-claude-opus-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-opus-5", note: Some("Probes a prefixed alias of the Databricks Opus 5 endpoint.") }, Q::Vector { id: "dbv2-claude-sonnet-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-sonnet-5", note: Some("Probes the canonical Databricks Sonnet 5 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-sonnet-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-sonnet-5", note: Some("Probes a prefixed alias of the Databricks Sonnet 5 endpoint.") }, + Q::Vector { id: "dbv2-kimi-2-7-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-kimi-2-7", note: Some("Probes the canonical Databricks Kimi 2.7 endpoint record.") }, Q::Vector { id: "dbv2-kimi-k3-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-kimi-k3", note: Some("Probes the canonical Databricks Kimi K3 endpoint record.") }, Q::Vector { id: "dbv2-goose-kimi-k3-alias-probe", provider: "databricks_v2", raw_model_id: "goose-kimi-k3", note: Some("Probes a prefixed alias of the Databricks Kimi K3 endpoint.") }, Q::Vector { id: "resolver-prefixed-alias-probe", provider: "databricks_v2", raw_model_id: "team-x-databricks-gpt-5-4-mini", note: Some("Probes a prefixed alias of an exact-record id (raw exact key differs).") }, @@ -728,6 +731,7 @@ mod tests { Q::Vector { id: "dbv2-gemini-3-pro-image-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gemini-3-pro-image", note: Some("Probes the Gemini 3 Pro Image endpoint record and label.") }, Q::Vector { id: "dbv2-deepseek-v4-flash-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-deepseek-v4-flash-0731", note: Some("Probes the DeepSeek V4 Flash endpoint record and label.") }, Q::Vector { id: "dbv2-deepseek-v4-pro-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-deepseek-v4-pro-0813", note: Some("Probes the DeepSeek V4 Pro endpoint record and label.") }, + Q::Vector { id: "dbv2-glm-5-3-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-glm-5-3", note: Some("Probes the GLM-5.3 endpoint record and label.") }, Q::Vector { id: "dbv2-glm-5-3-flash-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-glm-5-3-flash", note: Some("Probes the GLM-5.3 Flash endpoint record and label.") }, Q::Vector { id: "dbv2-grok-4-6-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-grok-4-6", note: Some("Probes the Grok 4.6 endpoint record and label.") }, Q::Vector { id: "dbv2-llama-4-maverick-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-llama-4-maverick", note: Some("Probes the Llama 4 Maverick endpoint record and label.") }, @@ -839,7 +843,7 @@ mod tests { } #[test] - fn corpus_has_exactly_135_executable_vectors() { + fn corpus_has_exactly_139_executable_vectors() { // Locks the vector count so a silent INPUTS edit can't quietly drop // coverage; must equal the gate in the TS harness // (modelCapabilitiesCorpus.test.mjs). @@ -848,7 +852,7 @@ mod tests { .filter(|q| matches!(q, Q::Vector { .. })) .count(); assert_eq!( - vectors, 135, + vectors, 139, "corpus executable-vector count changed; update this gate deliberately" ); } @@ -1018,9 +1022,12 @@ mod tests { Some("Claude Fable 5") ); for (alias, label) in [ + ("goose-claude-4-6-sonnet", "Claude Sonnet 4.6"), + ("goose-claude-4-7-opus", "Claude Opus 4.7"), ("goose-claude-opus-4-8", "Claude Opus 4.8"), ("goose-claude-opus-5", "Claude Opus 5"), ("goose-claude-sonnet-5", "Claude Sonnet 5"), + ("goose-kimi-2-7", "Kimi 2.7"), ("goose-kimi-k3", "Kimi K3"), ] { assert_eq!( @@ -1053,6 +1060,7 @@ mod tests { "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", "DeepSeek V4 Flash", ), + ("data_workflow_tools.goose.goose-glm-5-3", "GLM-5.3"), ( "data_workflow_tools.goose.goose-glm-5-3-flash", "GLM-5.3 Flash", diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 00cd81c6940..66ac1d2f8f0 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 42); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1241,6 +1241,45 @@ mod tests { operator_audit.contains("_operator_global_tables"), "migration 39 must register relay_operator_audit in _operator_global_tables" ); + + // NIP-FI core identity + base-lifecycle foundation (migration 0041) and + // final-admission foundation (0042). Both widen the single SQL source of + // truth `community_write_fence_excluded_table` so their durable, + // immutable ledger relations are never fence-attached, purged, or + // counted as tenant-scoped drift. schema.sql keeps one consolidated + // definition of that function whose body must match 0042's exactly. + assert_eq!(migrations[40].version, 41); + let identity_foundation = migrations[40].sql.as_str(); + assert!(identity_foundation.contains("CREATE TABLE identity_bindings")); + assert!(identity_foundation.contains("CREATE TABLE identity_lifecycle_history")); + assert!(identity_foundation + .contains("CREATE OR REPLACE FUNCTION community_write_fence_excluded_table")); + assert!(identity_foundation.contains("'identity_bindings'")); + + assert_eq!(migrations[41].version, 42); + let authorization_foundation = migrations[41].sql.as_str(); + assert!(authorization_foundation.contains("CREATE TABLE authorization_events")); + assert!(authorization_foundation.contains("CREATE TABLE protected_object_authority")); + assert!(authorization_foundation.contains("CREATE TABLE authorization_admission_results")); + + // The consolidated desired-state exclusion function must byte-match + // migration 0042's CREATE OR REPLACE body, or a future schema + // consolidation would silently drop NIP-FI relations from the ledger. + fn extract_excluded_table_array(sql: &str) -> &str { + let anchor = "community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN"; + let start = sql.find(anchor).expect("exclusion function definition"); + let array_start = sql[start..].find("ARRAY[").expect("exclusion array") + start; + let array_end = sql[array_start..] + .find("]::TEXT[]") + .expect("exclusion array end") + + array_start; + &sql[array_start..array_end] + } + assert_eq!( + extract_excluded_table_array(authorization_foundation), + extract_excluded_table_array(desired_schema), + "schema.sql exclusion list drifted from migration 0042" + ); } #[test] @@ -2618,4 +2657,2578 @@ mod tests { .await .expect("drop late-table fixtures"); } + + /// NIP-FI intermediate state: migration 0041 (identity + base lifecycle) + /// alone must present a coherent catalog. Its five community-scoped ledger + /// relations are immutable and durable, so they are registered in the + /// write-fence exclusion — never counted as tenant-scoped drift, never + /// fence-attached — and the exact deletion catalog must still validate. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0041_identity_foundation_is_durable_ledger_after_migration_a() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + // The five identity relations exist. + let identity_tables = [ + "authorization_operation_receipts", + "identity_enrollment_policies", + "identity_bindings", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + ]; + for table in identity_tables { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(exists, "migration 0041 must create {table}"); + } + + // Migration B's relations must NOT exist yet. + for table in ["authorization_events", "protected_object_authority"] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) + .fetch_one(&pool) + .await + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!(!exists, "{table} belongs to migration 0042, not 0041"); + } + + // Every identity relation is excluded from the write fence: none may + // appear as tenant-scoped drift or carry the fence trigger. + let scoped_or_fenced: Vec = sqlx::query_scalar( + "WITH scoped AS ( \ + SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + ) \ + SELECT relname FROM scoped \ + WHERE relname = ANY($1) ORDER BY relname", + ) + .bind(&identity_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped identity relations"); + assert!( + scoped_or_fenced.is_empty(), + "identity ledger relations must be write-fence excluded, not scoped: {scoped_or_fenced:?}" + ); + + // The exact deletion catalog validates: the excluded ledger relations + // do not perturb the scoped-table/fence equality check. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migration 0041"); + + // The immutability contract is enforced, not merely declared. TRUNCATE + // fires the statement-level guard unconditionally, so this proves the + // rejection without constructing a fully valid ledger row. + let rejected = sqlx::query("TRUNCATE identity_lifecycle_selectors") + .execute(&pool) + .await + .expect_err("identity_lifecycle_selectors truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } + + /// NIP-FI full state: migrations 0041 + 0042 together must present a + /// coherent 15-relation catalog with zero dangling foreign keys, all + /// relations write-fence excluded, and an intact exact deletion catalog. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_foundation_is_a_closed_durable_ledger_after_migrations_a_and_b() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let nip_fi_tables = [ + "authorization_admission_results", + "authorization_authentication_denial_attempts", + "authorization_authority_epochs", + "authorization_event_capacity", + "authorization_events", + "authorization_invalidation_domains", + "authorization_invalidation_floors", + "authorization_operation_receipts", + "authorization_operation_version_delta_manifests", + "authorization_operation_version_deltas", + "identity_bindings", + "identity_enrollment_policies", + "identity_lifecycle_history", + "identity_lifecycle_selectors", + "protected_object_authority", + ]; + + // All fifteen relations exist. + let present: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = ANY($1) ORDER BY table_name", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read NIP-FI table catalog"); + let mut expected: Vec = nip_fi_tables.iter().map(|t| t.to_string()).collect(); + expected.sort(); + assert_eq!( + present, expected, + "all NIP-FI relations must exist after 0042" + ); + + // Zero dangling foreign keys: every FK target is a live relation. + let invalid_fks: i64 = sqlx::query_scalar( + "SELECT count(*)::BIGINT FROM pg_constraint \ + WHERE contype = 'f' AND NOT convalidated", + ) + .fetch_one(&pool) + .await + .expect("read FK validity"); + assert_eq!( + invalid_fks, 0, + "no NIP-FI foreign key may be left unvalidated" + ); + + // None of the fifteen appear as tenant-scoped drift; all are excluded. + let scoped: Vec = sqlx::query_scalar( + "SELECT c.relname FROM pg_class c \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_attribute a ON a.attrelid = c.oid \ + WHERE n.nspname = 'public' AND c.relkind IN ('r','p') \ + AND NOT c.relispartition AND a.attname = 'community_id' \ + AND NOT a.attisdropped \ + AND NOT community_write_fence_excluded_table(c.relname) \ + AND c.relname = ANY($1) ORDER BY c.relname", + ) + .bind(&nip_fi_tables[..]) + .fetch_all(&pool) + .await + .expect("read scoped NIP-FI relations"); + assert!( + scoped.is_empty(), + "all NIP-FI ledger relations must be write-fence excluded: {scoped:?}" + ); + + // The exact deletion catalog validates with the full ledger present. + crate::deletion::DeletionStore::new(pool.clone()) + .validate_catalog() + .await + .expect("deletion catalog validates after migrations 0041 + 0042"); + + // A migration-B relation is immutable too. TRUNCATE fires the + // statement-level guard unconditionally. + let rejected = sqlx::query("TRUNCATE authorization_admission_results") + .execute(&pool) + .await + .expect_err("authorization_admission_results truncation must be rejected"); + assert!( + rejected.to_string().contains("cannot be truncated"), + "expected immutability rejection, got: {rejected}" + ); + } + + /// NIP-FI monotonic invalidation-floor advancement must actually run + /// through the `BEFORE UPDATE` guard. PL/pgSQL defers record-field + /// resolution to execution, so a guard that references a column absent from + /// its Phase-A table passes every catalog/parity test yet aborts the first + /// real advancement. This test exercises live UPDATEs: legitimate forward + /// moves on `floor_generation` and `binding_version_floor` must commit, and + /// equal/regressive moves must be rejected. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_invalidation_floor_advances_through_guard() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("floor-guard-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Each floor state points at an operation receipt via + // (community_id, operation_id, request_fingerprint). Seed one receipt + // per operation the test advances through. + let operations: [(uuid::Uuid, u8); 4] = [ + (uuid::Uuid::new_v4(), 0x11), + (uuid::Uuid::new_v4(), 0x22), + (uuid::Uuid::new_v4(), 0x33), + (uuid::Uuid::new_v4(), 0x44), + ]; + for (operation_id, fp_byte) in operations { + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(vec![fp_byte; 32]) + .bind(vec![0xAA_u8; 32]) + .bind(vec![0xBB_u8; 32]) + .execute(&pool) + .await + .expect("seed operation receipt"); + } + + // selector_kind 3 requires binding_version_floor, so this row exercises + // both monotonic dimensions the guard still governs. + let selector_fingerprint = vec![0xCC_u8; 32]; + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, floor_generation, \ + binding_version_floor, operation_id, request_fingerprint, updated_at) \ + VALUES ($1, 3, $2, 1, 1, $3, $4, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .bind(operations[0].0) + .bind(vec![operations[0].1; 32]) + .execute(&pool) + .await + .expect("insert initial invalidation floor"); + + let advance = + |generation: i64, binding_floor: i64, op_index: usize, updated_at: &'static str| { + sqlx::query( + "UPDATE authorization_invalidation_floors \ + SET floor_generation = $1, binding_version_floor = $2, \ + operation_id = $3, request_fingerprint = $4, updated_at = $5::timestamptz \ + WHERE community_id = $6 AND selector_kind = 3 AND selector_fingerprint = $7", + ) + .bind(generation) + .bind(binding_floor) + .bind(operations[op_index].0) + .bind(vec![operations[op_index].1; 32]) + .bind(updated_at) + .bind(community_id) + .bind(selector_fingerprint.clone()) + .execute(&pool) + }; + + // Forward generation advance commits. + advance(2, 1, 1, "2026-01-01T00:01:00Z") + .await + .expect("forward floor_generation advance must pass the guard"); + + // Forward binding_version_floor advance commits (generation unchanged). + advance(2, 2, 2, "2026-01-01T00:02:00Z") + .await + .expect("forward binding_version_floor advance must pass the guard"); + + // Regressive generation is rejected. + let regressive = advance(1, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("regressive floor_generation must be rejected"); + assert!( + regressive.to_string().contains("cannot move backward"), + "expected monotonic rejection, got: {regressive}" + ); + + // Equal floors with only a new operation is a rejected no-op advance. + let no_op = advance(2, 2, 3, "2026-01-01T00:03:00Z") + .await + .expect_err("equal-floor no-op advance must be rejected"); + assert!( + no_op.to_string().contains("cannot move backward"), + "expected no-op rejection, got: {no_op}" + ); + + // The committed state reflects only the two accepted advances. + let (generation, binding_floor): (i64, i64) = sqlx::query_as( + "SELECT floor_generation, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = 3 AND selector_fingerprint = $2", + ) + .bind(community_id) + .bind(&selector_fingerprint) + .fetch_one(&pool) + .await + .expect("read final floor state"); + assert_eq!( + (generation, binding_floor), + (2, 2), + "only the accepted forward advances may persist" + ); + } + + /// NIP-FI identity FK contract: a binding's provenance is determined from + /// operation evidence and is independent of the enrollment policy's mode. + /// The corrected FK references only `(community_id, policy_revision)`; + /// the original composite FK `(community_id, policy_revision, + /// binding_provenance) → (community_id, policy_revision, enrollment_mode)` + /// would have rejected valid admissions such as TOFU policy + + /// attested-key provenance (NIP-FI.md §352, §424). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_binding_provenance_is_independent_of_enrollment_mode() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("provenance-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Enrollment policy: mode 3 (TOFU). + let policy_revision: i64 = 1; + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(policy_revision) + .bind(vec![0xA0_u8; 32]) // policy_digest + .execute(&pool) + .await + .expect("insert TOFU enrollment policy"); + + // Insert a binding with provenance 1 (attested-key) under the TOFU + // policy. The circular deferred FK between identity_bindings and + // identity_lifecycle_history requires both to be committed in one + // transaction; all cross-table FKs in this pair are DEFERRABLE + // INITIALLY DEFERRED. A pinned connection is required so that BEGIN + // and each subsequent statement share the same session/transaction. + let binding_id = uuid::Uuid::new_v4(); + let history_id = uuid::Uuid::new_v4(); + let operation_id = uuid::Uuid::new_v4(); + let request_fingerprint = vec![0xAB_u8; 32]; + + let mut conn = pool.acquire().await.expect("acquire connection"); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Enrollment history must be inserted BEFORE the operation receipt: + // authorization_operation_receipt_history_guard_v1 fires AFTER INSERT + // on authorization_operation_receipts and checks that lifecycle receipts + // already have exactly one history row. The history → receipt FK is + // DEFERRABLE INITIALLY DEFERRED, so this order is safe. + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(history_id) + .bind(binding_id) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![0xAE_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert lifecycle history"); + + // Operation receipt: kind 1 (enroll), outcome 1 (applied). + // The receipt_history_cardinality trigger fires here and validates the + // history row inserted above. + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![0xAC_u8; 32]) + .bind(vec![0xAD_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert operation receipt"); + + // Binding: provenance 1 (attested-key) under TOFU-mode policy. + // Before the FK fix this INSERT would fail at commit with a FK + // violation because 1 (attested-key) ≠ 3 (TOFU mode). + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-01', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_id) + .bind(vec![0xAF_u8; 32]) // principal_fingerprint + .bind(vec![0xB0_u8; 32]) // event_author_pubkey + .bind(policy_revision) + .bind(vec![0xB1_u8; 32]) // enrollment_evidence_digest + .bind(history_id) + .bind(operation_id) + .bind(&request_fingerprint) + .execute(&mut *conn) + .await + .expect("insert binding"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("attested-key binding under TOFU policy must commit — FK is on (community_id, policy_revision) only"); + + // Confirm the binding persisted with provenance 1, policy mode 3. + let (stored_provenance, stored_mode): (i16, i16) = sqlx::query_as( + "SELECT b.binding_provenance, p.enrollment_mode \ + FROM identity_bindings b \ + JOIN identity_enrollment_policies p \ + ON p.community_id = b.community_id AND p.policy_revision = b.policy_revision \ + WHERE b.community_id = $1 AND b.binding_id = $2", + ) + .bind(community_id) + .bind(binding_id) + .fetch_one(&pool) + .await + .expect("read persisted binding"); + assert_eq!(stored_provenance, 1, "provenance must be attested-key (1)"); + assert_eq!(stored_mode, 3, "enrollment mode must be TOFU (3)"); + assert_ne!( + stored_provenance, stored_mode, + "provenance and mode are independent: they must differ here" + ); + + // --- Negative half: absent policy revision --- + // + // Two-sided mutation sensitivity requires that a FK dropped or neutered + // entirely is also detected. A second otherwise-valid deferred + // transaction uses a nonexistent policy_revision (999) and must fail + // with SQLSTATE 23503 — the narrowed FK + // identity_bindings(community_id, policy_revision) + // → identity_enrollment_policies(community_id, policy_revision) + // rejects the row. This FK is not deferred, so it fires at INSERT + // time; a `COMMIT` is unnecessary and not reached. If the FK were + // absent the INSERT would succeed and this assertion would catch the + // regression. + let absent_binding_id = uuid::Uuid::new_v4(); + let absent_history_id = uuid::Uuid::new_v4(); + let absent_operation_id = uuid::Uuid::new_v4(); + let absent_fp = vec![0xC0_u8; 32]; + let nonexistent_policy_revision: i64 = 999; + + let mut conn2 = pool.acquire().await.expect("acquire second connection"); + + sqlx::query("BEGIN") + .execute(&mut *conn2) + .await + .expect("begin absent-policy transaction"); + + // History first (receipt_history_cardinality guard fires on receipt + // insert and requires the history row to already exist). + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(absent_history_id) + .bind(absent_binding_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .bind(vec![0xC1_u8; 32]) + .execute(&mut *conn2) + .await + .expect("insert absent-policy lifecycle history"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn2) + .await + .expect("insert absent-policy operation receipt"); + + // The policy FK is not deferred; it fires at INSERT, not COMMIT. + let absent_policy_err = sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-02', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(absent_binding_id) + .bind(vec![0xC4_u8; 32]) // principal_fingerprint (unique, different from first binding) + .bind(vec![0xC5_u8; 32]) // event_author_pubkey (unique, different from first binding) + .bind(nonexistent_policy_revision) + .bind(vec![0xC6_u8; 32]) // enrollment_evidence_digest + .bind(absent_history_id) + .bind(absent_operation_id) + .bind(&absent_fp) + .execute(&mut *conn2) + .await + .expect_err("binding with nonexistent policy_revision must be rejected by the FK"); + assert!( + absent_policy_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected FK violation (23503) for absent policy_revision, got: {absent_policy_err}" + ); + + sqlx::query("ROLLBACK") + .execute(&mut *conn2) + .await + .expect("rollback absent-policy transaction"); + } + + /// NIP-FI policy-revision monotonicity: each new policy revision for a + /// community must strictly exceed the current maximum revision + /// (FI-INV-06 — stable assertion policy). `effective_at` ordering is + /// deliberately not enforced — the downstream constructor stamps every + /// immediately-effective revision with Unix epoch. + /// + /// Mutation sensitivity is two-sided: + /// - neutering the guard lets a replayed or backfilled revision through + /// (the positive half detects insertion into a guarded table); + /// - leaving the guard intact rejects equal/regressive inserts (negative + /// halves detect that each rejection fires). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn identity_enrollment_policy_revision_is_monotonic() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(41, &pool) + .await + .expect("apply migrations 1-41"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("policy-mono-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // First insertion: no prior rows — should always succeed. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 1, $2, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA1_u8; 32]) + .execute(&pool) + .await + .expect("first policy insertion (revision 1) must succeed"); + + // Forward advance: revision 2. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 1, $2, '2026-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA2_u8; 32]) + .execute(&pool) + .await + .expect("forward advance to revision 2 must succeed"); + + // Seed a gap: skip from 2 to 100, then advance to 101. + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 100, 1, $2, '2027-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA3_u8; 32]) + .execute(&pool) + .await + .expect("jump to revision 100 must succeed"); + + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 101, 1, $2, '2027-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA4_u8; 32]) + .execute(&pool) + .await + .expect("advance to revision 101 must succeed"); + + // Negative: unused lower revision 99 — not a PK duplicate (never inserted), + // but the guard must reject it because 99 < MAX(100, 101). This is the + // case a plain PK constraint cannot catch; the named guard must fire. + let backfill_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 99, 1, $2, '2028-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA5_u8; 32]) + .execute(&pool) + .await + .expect_err("unused lower revision 99 must be rejected by the guard"); + assert!( + backfill_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from identity_enrollment_policy_revision_monotonic \ + guard for backfilled revision 99, got: {backfill_err}" + ); + + // Negative: equal revision (101 <= 101). The PK is (community_id, policy_revision) + // so this is a PK duplicate regardless of policy_digest; either 23505 from the PK + // or 23514 from the guard fires first. This case is secondary — the load-bearing + // proof is the unused-99 case above, which is not a PK duplicate. + let replay_err = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 101, 2, $2, '2028-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xA6_u8; 32]) + .execute(&pool) + .await + .expect_err("equal revision must be rejected"); + // PK (23505) or guard (23514) — either proves the insert cannot commit. + assert!( + replay_err + .as_database_error() + .map(|e| { + let code = e.code(); + let c = code.as_deref().unwrap_or(""); + c == "23514" || c == "23505" + }) + .unwrap_or(false), + "expected check_violation (23514) or unique_violation (23505) for replayed revision, \ + got: {replay_err}" + ); + + // Concurrency regression: prove the advisory lock is load-bearing. The + // test uses a controlled two-connection schedule: + // + // 1. tx1 opens a transaction and inserts revision 102. The BEFORE INSERT + // trigger acquires `pg_advisory_xact_lock(lock_key)` and completes the + // INSERT — tx1 now holds the advisory lock until it commits. + // 2. tx2 opens a transaction on a second backend and issues INSERT for + // revision 103. The trigger fires and blocks inside + // `pg_advisory_xact_lock(lock_key)` waiting for tx1 to release. + // 3. We observe tx2's backend entering a Lock-wait state via + // pg_stat_activity (wait_event_type='Lock', wait_event='advisory'), + // with a bounded timeout — not a sleep. If the advisory-lock call is + // removed from the guard, the trigger returns immediately; tx2 never + // enters the advisory wait, and the poll times out, failing the test. + // This is the mutation-sensitivity guarantee. + // 4. tx1 commits, releasing the advisory lock. tx2 unblocks, its trigger + // reads the fresh MAX=102, and the INSERT succeeds (103 > 102). + // 5. tx2 commits. Both revisions 102 and 103 are present. + use std::time::Instant; + + // tx1: open a transaction and insert revision 102. The INSERT returns after + // the trigger acquires the lock and succeeds; the advisory lock stays held + // until the transaction commits. + let mut conn1 = pool.acquire().await.expect("acquire conn1"); + sqlx::query("BEGIN") + .execute(&mut *conn1) + .await + .expect("begin tx1"); + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 102, 1, $2, '2029-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB1_u8; 32]) + .execute(&mut *conn1) + .await + .expect("tx1 INSERT revision 102 must succeed"); + // tx1 holds the advisory lock. Do NOT commit yet. + + // tx2: acquire a separate backend, record its PID, then issue the INSERT. + // The trigger will block on the advisory lock held by tx1. + let pool2 = pool.clone(); + let pool3 = pool.clone(); + let (pid_tx, pid_rx) = tokio::sync::oneshot::channel::(); + let tx2_task = tokio::spawn(async move { + let mut conn2 = pool2.acquire().await.expect("acquire conn2"); + // Report this backend's PID so the observer can poll pg_stat_activity. + let backend_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *conn2) + .await + .expect("get conn2 backend pid"); + let _ = pid_tx.send(backend_pid); + sqlx::query("BEGIN") + .execute(&mut *conn2) + .await + .expect("begin tx2"); + // This INSERT will block inside the trigger waiting for tx1's advisory lock. + let insert_r = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 103, 1, $2, '2029-06-01T00:00:00Z')", + ) + .bind(community_id) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn2) + .await; + let commit_r = sqlx::query("COMMIT").execute(&mut *conn2).await; + (insert_r, commit_r) + }); + + // Receive tx2's backend PID and wait until it enters an advisory-lock wait. + // Mutation proof: without pg_advisory_xact_lock in the guard, the trigger + // returns immediately; tx2 never parks on an advisory lock; the poll below + // times out and panics, making this test deterministically red. + let tx2_pid = pid_rx.await.expect("tx2 reports its backend pid"); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 FROM pg_stat_activity \ + WHERE pid = $1 \ + AND wait_event_type = 'Lock' \ + AND wait_event = 'advisory'\ + )", + ) + .bind(tx2_pid) + .fetch_one(&pool3) + .await + .expect("poll tx2 advisory-lock wait"); + if waiting { + break; + } + assert!( + Instant::now() < deadline, + "tx2 never entered advisory-lock wait — pg_advisory_xact_lock \ + must be present in the guard for the lock to serialize writers" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // tx2 is observably blocked. Commit tx1, releasing the advisory lock. + sqlx::query("COMMIT") + .execute(&mut *conn1) + .await + .expect("tx1 COMMIT must succeed"); + + // tx2 unblocks: the trigger re-runs its SELECT MAX, sees committed 102, + // and INSERT 103 succeeds. Both the INSERT and COMMIT must complete. + let (insert2, commit2) = tx2_task.await.expect("tx2 task completed"); + insert2.expect("tx2 INSERT revision 103 must succeed after tx1 commits"); + commit2.expect("tx2 COMMIT must succeed"); + + // Both revisions 102 and 103 must be present (total: 1, 2, 100, 101, 102, 103). + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM identity_enrollment_policies WHERE community_id = $1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("count persisted policy revisions"); + assert_eq!( + count, 6, + "exactly six revisions must persist after the controlled concurrency sequence" + ); + } + + /// NIP-FI admission-result ↔ kind-11 receipt cardinality: a kind-11 + /// (protected-mutation) receipt must commit with exactly one admission + /// result; an admission result must commit against a kind-11 receipt. + /// + /// Mutation sensitivity is two-sided: + /// - the guard is load-bearing when a kind-11 receipt has no result row + /// (negative A) — without the guard this commits silently; + /// - the guard is load-bearing when a result attaches to a non-kind-11 + /// receipt (negative B) — without the guard this commits silently. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_admission_result_requires_kind_11_receipt_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("adm-result-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity must exist for authorization_events inserts; admission-result + // tests exercise only authorization_operation_receipts and + // authorization_admission_results — no authorization_events rows are + // needed here, but insert capacity anyway to satisfy any trigger + // that reads the policy row defensively. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-11 receipt + admission result in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let fp1 = vec![0xB1_u8; 32]; + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB2_u8; 32]) + .bind(vec![0xB3_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert kind-11 receipt"); + + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op1) + .bind(&fp1) + .bind(vec![0xB4_u8; 32]) // semantic_fingerprint + .bind(vec![0xB5_u8; 32]) // object_key + .execute(&mut *conn) + .await + .expect("insert admission result"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-11 receipt + result must commit"); + drop(conn); + + // --- Negative A: kind-11 receipt without result must be rejected --- + let op2 = uuid::Uuid::new_v4(); + let fp2 = vec![0xC1_u8; 32]; + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op2) + .bind(&fp2) + .bind(vec![0xC2_u8; 32]) + .bind(vec![0xC3_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-11 receipt for negative A"); + + let no_result_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-11 receipt without result must be rejected at commit"); + assert!( + no_result_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-11 without result, got: {no_result_err}" + ); + drop(conn_a); + + // --- Negative B: admission result against non-kind-11 receipt --- + // Use operation_kind 12 (invalidation) — no admission result should + // ever attach to it. The guard fires at COMMIT (deferred trigger). + let op3 = uuid::Uuid::new_v4(); + let fp3 = vec![0xD1_u8; 32]; + + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD2_u8; 32]) + .bind(vec![0xD3_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("insert kind-12 receipt"); + + // The guard is deferred: the INSERT succeeds; the violation surfaces + // at COMMIT when the guard checks that the receipt is kind-11. + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op3) + .bind(&fp3) + .bind(vec![0xD4_u8; 32]) + .bind(vec![0xD5_u8; 32]) + .execute(&mut *conn_b) + .await + .expect("result insert must pass — deferred guard fires at commit, not here"); + + let wrong_kind_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err("result against non-kind-11 receipt must be rejected at commit"); + assert!( + wrong_kind_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for result against non-kind-11 receipt, got: {wrong_kind_err}" + ); + drop(conn_b); + + // --- Negative C: mismatched request_fingerprint rejected by composite FK --- + // The admission result table has an immediate composite FK + // (community_id, operation_id, request_fingerprint) + // REFERENCES authorization_operation_receipts(...) + // A result referencing a receipt that exists but with a different + // request_fingerprint must be rejected. This exercises the semantic half + // of Carl finding 2 — cardinality is handled by the deferred trigger; + // coordinate binding is handled by the structural FK. + let op4 = uuid::Uuid::new_v4(); + let fp4_receipt = vec![0xE1_u8; 32]; // fingerprint stored in the receipt + let fp4_wrong = vec![0xE2_u8; 32]; // wrong fingerprint used in the result + + let mut conn_c = pool.acquire().await.expect("acquire connection C"); + sqlx::query("BEGIN") + .execute(&mut *conn_c) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 11, $4, 1, $5)", + ) + .bind(community_id) + .bind(op4) + .bind(&fp4_receipt) + .bind(vec![0xE3_u8; 32]) + .bind(vec![0xE4_u8; 32]) + .execute(&mut *conn_c) + .await + .expect("insert kind-11 receipt for negative C"); + + // The admission result FK is immediate (not deferred), so the INSERT + // itself rejects a fingerprint with no matching receipt row. + let wrong_fp_err = sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id, operation_id, request_fingerprint, semantic_fingerprint, \ + object_kind, object_key) \ + VALUES ($1, $2, $3, $4, 1, $5)", + ) + .bind(community_id) + .bind(op4) + .bind(&fp4_wrong) // wrong fingerprint — no matching receipt row + .bind(vec![0xE5_u8; 32]) + .bind(vec![0xE6_u8; 32]) + .execute(&mut *conn_c) + .await + .expect_err("result with mismatched request_fingerprint must be rejected at INSERT"); + // Immediate composite FK fires as foreign_key_violation (23503). + assert!( + wrong_fp_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected foreign_key_violation (23503) for mismatched request_fingerprint, got: {wrong_fp_err}" + ); + sqlx::query("ROLLBACK").execute(&mut *conn_c).await.ok(); + } + + /// NIP-FI denial-attempt ↔ kind-9 event cardinality: a kind-9 + /// (pre-authentication denial) audit event must commit with exactly one + /// denial attempt; a denial attempt must commit with a matching kind-9 + /// audit event. + /// + /// Mutation sensitivity is two-sided: + /// - the event-side guard is load-bearing when a kind-9 event has no + /// attempt row (negative A) — without it this commits silently, making + /// replay reconstruction impossible; + /// - the attempt-side guard is load-bearing for semantic mismatches (negatives + /// B1–B3) — the old deferred FK only checks event existence/kind and would + /// not catch a correlation, reason_code, or attempt_id mismatch. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorization_denial_attempt_requires_kind_9_event_bidirectional() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("denial-attempt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Capacity is required by the authorization_events BEFORE INSERT trigger. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let mut conn = pool.acquire().await.expect("acquire connection"); + + // --- Positive: kind-9 event + denial attempt in one transaction --- + let op1 = uuid::Uuid::new_v4(); + let event1 = uuid::Uuid::new_v4(); + let corr1 = uuid::Uuid::new_v4(); + let attempt1_id = uuid::Uuid::new_v4(); + + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Insert denial attempt first (FKs are deferred). + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op1) + .bind(corr1) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint + .bind(attempt1_id) + .bind(event1) + .execute(&mut *conn) + .await + .expect("insert denial attempt before event"); + + // Insert the kind-9 event (actor_kind 4, no request_fingerprint). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event1) + .bind(op1) + .bind(corr1) + .bind(attempt1_id) + .bind(vec![0xE1_u8; 32]) // semantic_fingerprint matches denial attempt + .bind(vec![0xE2_u8; 64]) // canonical_envelope (≤16384 bytes) + .bind(vec![0xE3_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert kind-9 event"); + + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .expect("kind-9 event + denial attempt must commit"); + drop(conn); + + // --- Negative A: kind-9 event alone must be rejected at commit --- + let op2 = uuid::Uuid::new_v4(); + let event2 = uuid::Uuid::new_v4(); + let corr2 = uuid::Uuid::new_v4(); + let attempt2_id = uuid::Uuid::new_v4(); + + let mut conn_a = pool.acquire().await.expect("acquire connection A"); + sqlx::query("BEGIN") + .execute(&mut *conn_a) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event2) + .bind(op2) + .bind(corr2) + .bind(attempt2_id) + .bind(vec![0xF0_u8; 32]) // semantic_fingerprint (non-zero) + .bind(vec![0xF1_u8; 64]) + .bind(vec![0xF2_u8; 32]) + .execute(&mut *conn_a) + .await + .expect("insert kind-9 event without attempt"); + + let no_attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_a) + .await + .expect_err("kind-9 event without denial attempt must be rejected at commit"); + assert!( + no_attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) for kind-9 event without attempt, got: {no_attempt_err}" + ); + drop(conn_a); + + // --- Negatives B1-B3: semantic coordinate mismatches, each attributed to + // the named guard (23514), not the old deferred FK (23503). Each case + // inserts a valid event then a denial attempt that matches everywhere + // except one coordinate; the guard must fire for that mismatch. + + // B1: correlation_id mismatch — attempt carries a different correlation + // than the event it references. + let op_b1 = uuid::Uuid::new_v4(); + let event_b1 = uuid::Uuid::new_v4(); + let corr_b1_event = uuid::Uuid::new_v4(); + let corr_b1_wrong = uuid::Uuid::new_v4(); // different from corr_b1_event + let attempt_b1 = uuid::Uuid::new_v4(); + + let mut conn_b1 = pool.acquire().await.expect("acquire connection B1"); + sqlx::query("BEGIN") + .execute(&mut *conn_b1) + .await + .expect("begin B1"); + + // Insert the event first (deferred FK allows this ordering). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b1) + .bind(op_b1) + .bind(corr_b1_event) + .bind(attempt_b1) + .bind(vec![0xB3_u8; 32]) // semantic_fingerprint matches denial attempt + .bind(vec![0xB1_u8; 64]) + .bind(vec![0xB2_u8; 32]) + .execute(&mut *conn_b1) + .await + .expect("insert kind-9 event for B1"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b1) + .bind(corr_b1_wrong) // wrong correlation_id + .bind(vec![0xB3_u8; 32]) + .bind(attempt_b1) + .bind(event_b1) + .execute(&mut *conn_b1) + .await + .expect("insert denial attempt with wrong correlation_id (guard deferred)"); + + let corr_err = sqlx::query("COMMIT") + .execute(&mut *conn_b1) + .await + .expect_err("mismatched correlation_id must be rejected at commit"); + assert!( + corr_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ + for correlation_id mismatch, got: {corr_err}" + ); + drop(conn_b1); + + // B2: reason_code mismatch — attempt carries denial_reason=1 (MissingCredential, + // requires reason_code=2 per canonical mapping), but event carries reason_code=1. + // The attempt INSERT passes (denial_reason=1↔reason_code=2 is a valid mapping pair), + // then the deferred guard fires at commit because event reason_code=1 ≠ attempt + // reason_code=2. + let op_b2 = uuid::Uuid::new_v4(); + let event_b2 = uuid::Uuid::new_v4(); + let corr_b2 = uuid::Uuid::new_v4(); + let attempt_b2 = uuid::Uuid::new_v4(); + + let mut conn_b2 = pool.acquire().await.expect("acquire connection B2"); + sqlx::query("BEGIN") + .execute(&mut *conn_b2) + .await + .expect("begin B2"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 1, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b2) + .bind(op_b2) + .bind(corr_b2) + .bind(attempt_b2) + .bind(vec![0xC3_u8; 32]) // semantic_fingerprint matches denial attempt + .bind(vec![0xC1_u8; 64]) + .bind(vec![0xC2_u8; 32]) + .execute(&mut *conn_b2) + .await + .expect("insert kind-9 event for B2 (reason_code=1)"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + // reason_code = 2 but event has reason_code = 1 + ) + .bind(community_id) + .bind(op_b2) + .bind(corr_b2) + .bind(vec![0xC3_u8; 32]) + .bind(attempt_b2) + .bind(event_b2) + .execute(&mut *conn_b2) + .await + .expect( + "insert denial attempt with wrong reason_code (deferred guard will fire at commit)", + ); + + let reason_err = sqlx::query("COMMIT") + .execute(&mut *conn_b2) + .await + .expect_err("mismatched reason_code must be rejected at commit"); + assert!( + reason_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from authorization_denial_attempt_semantic_binding \ + for reason_code mismatch, got: {reason_err}" + ); + drop(conn_b2); + + // B3: attempt_id mismatch — the denial attempt's attempt_id FK references + // a different event (attempt_b3_wrong) than the one being paired (event_b3). + // The attempt_id FK on the denial attempt table binds + // (community_id, operation_id, audit_event_kind, attempt_id) + // -> authorization_events(community_id, operation_id, event_kind, attempt_id) + // so using a different attempt_id that doesn't exist for this operation + // will be caught as a FK violation (23503) at commit. + let op_b3 = uuid::Uuid::new_v4(); + let event_b3 = uuid::Uuid::new_v4(); + let corr_b3 = uuid::Uuid::new_v4(); + let attempt_b3_correct = uuid::Uuid::new_v4(); + let attempt_b3_wrong = uuid::Uuid::new_v4(); // not registered for this operation + + let mut conn_b3 = pool.acquire().await.expect("acquire connection B3"); + sqlx::query("BEGIN") + .execute(&mut *conn_b3) + .await + .expect("begin B3"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b3) + .bind(op_b3) + .bind(corr_b3) + .bind(attempt_b3_correct) + .bind(vec![0xD3_u8; 32]) // semantic_fingerprint (matches denial attempt) + .bind(vec![0xD1_u8; 64]) + .bind(vec![0xD2_u8; 32]) + .execute(&mut *conn_b3) + .await + .expect("insert kind-9 event for B3"); + + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b3) + .bind(corr_b3) + .bind(vec![0xD3_u8; 32]) + .bind(attempt_b3_wrong) // wrong attempt_id — no matching UNIQUE row on events + .bind(event_b3) + .execute(&mut *conn_b3) + .await + .expect("insert denial attempt with wrong attempt_id (FK is deferred)"); + + let attempt_err = sqlx::query("COMMIT") + .execute(&mut *conn_b3) + .await + .expect_err("mismatched attempt_id must be rejected at commit"); + // The attempt_id FK is deferred and fires as foreign_key_violation (23503). + assert!( + attempt_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23503")) + .unwrap_or(false), + "expected foreign_key_violation (23503) for attempt_id mismatch \ + (deferred FK on denial attempt), got: {attempt_err}" + ); + + // B4: denial_reason ↔ reason_code mapping violation — the denial attempt + // row carries denial_reason=2 (InvalidCredential) but reason_code=2 + // (Missing). The canonical mapping requires InvalidCredential(2)↔Invalid(3); + // reason_code=2 is only valid for MissingCredential(denial_reason=1). + // The immediate CHECK constraint authorization_denial_reason_reason_code_binding + // fires at INSERT, not commit. Mutation-sensitive: removing the CHECK lets + // this INSERT succeed (the guard does not compare denial_reason; only the + // paired event's reason_code is checked at commit). + let op_b4 = uuid::Uuid::new_v4(); + let event_b4 = uuid::Uuid::new_v4(); + let corr_b4 = uuid::Uuid::new_v4(); + let attempt_b4 = uuid::Uuid::new_v4(); + + let mut conn_b4 = pool.acquire().await.expect("acquire connection B4"); + sqlx::query("BEGIN") + .execute(&mut *conn_b4) + .await + .expect("begin B4"); + + // Insert the matching kind-9 event first. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b4) + .bind(op_b4) + .bind(corr_b4) + .bind(attempt_b4) + .bind(vec![0xE4_u8; 32]) // semantic_fingerprint + .bind(vec![0xE5_u8; 64]) + .bind(vec![0xE6_u8; 32]) + .execute(&mut *conn_b4) + .await + .expect("insert kind-9 event for B4"); + + // Insert denial attempt with denial_reason=2 (InvalidCredential) but + // reason_code=2 (Missing) — violates the canonical mapping (requires reason_code=3). + let denial_reason_err = sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 2, 1, 1, 2, $5, $6, 9)", + // denial_reason=2 (InvalidCredential) requires reason_code=3; reason_code=2 is wrong + ) + .bind(community_id) + .bind(op_b4) + .bind(corr_b4) + .bind(vec![0xE4_u8; 32]) + .bind(attempt_b4) + .bind(event_b4) + .execute(&mut *conn_b4) + .await + .expect_err("denial_reason/reason_code mapping violation must be rejected at INSERT"); + + assert!( + denial_reason_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from \ + authorization_denial_reason_reason_code_binding for denial_reason mismatch, \ + got: {denial_reason_err}" + ); + + // B5: semantic_fingerprint mismatch — the event carries semantic_fingerprint + // 0xB5…B5 while the denial attempt carries 0xB6…B6. correlation_id, reason_code, + // and attempt_id all match; only the fingerprint differs. The deferred guard + // authorization_denial_attempt_guard_v1 fires at COMMIT on the denial-attempt + // side, compares found_semantic_fingerprint (from the event) with + // NEW.semantic_fingerprint (from the attempt), and raises 23514 with named + // constraint authorization_denial_attempt_semantic_binding. + // Mutation-sensitive: removing the semantic_fingerprint comparison block from + // the guard function lets this transaction commit. + let op_b5 = uuid::Uuid::new_v4(); + let event_b5 = uuid::Uuid::new_v4(); + let corr_b5 = uuid::Uuid::new_v4(); + let attempt_b5 = uuid::Uuid::new_v4(); + let fp_event_b5 = vec![0xB5_u8; 32]; // event semantic_fingerprint + let fp_attempt_b5 = vec![0xB6_u8; 32]; // mismatched attempt semantic_fingerprint + + let mut conn_b5 = pool.acquire().await.expect("acquire connection B5"); + sqlx::query("BEGIN") + .execute(&mut *conn_b5) + .await + .expect("begin B5"); + + // Insert the kind-9 event with fingerprint 0xB5…B5. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, operation_id, correlation_id, attempt_id, \ + semantic_fingerprint, occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 2, 4, $3, $4, $5, \ + $6, '2026-01-01T00:00:00Z', $7, $8)", + ) + .bind(community_id) + .bind(event_b5) + .bind(op_b5) + .bind(corr_b5) + .bind(attempt_b5) + .bind(fp_event_b5) + .bind(vec![0xB7_u8; 64]) // canonical_envelope + .bind(vec![0xB8_u8; 32]) // envelope_digest + .execute(&mut *conn_b5) + .await + .expect("insert kind-9 event for B5"); + + // Insert denial attempt with the WRONG semantic_fingerprint (0xB6…B6). + // correlation_id, reason_code=2, denial_reason=1 (MissingCredential↔Missing), + // and attempt_id all match the event — only semantic_fingerprint differs. + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_b5) + .bind(corr_b5) + .bind(fp_attempt_b5) // 0xB6…B6 ≠ event's 0xB5…B5 + .bind(attempt_b5) + .bind(event_b5) + .execute(&mut *conn_b5) + .await + .expect( + "insert denial attempt with mismatched fingerprint (deferred guard fires at commit)", + ); + + let fp_mismatch_err = sqlx::query("COMMIT") + .execute(&mut *conn_b5) + .await + .expect_err("commit with mismatched semantic_fingerprint must be rejected"); + + assert!( + fp_mismatch_err + .as_database_error() + .map(|e| e.code().as_deref() == Some("23514")) + .unwrap_or(false), + "expected check_violation (23514) from \ + authorization_denial_attempt_semantic_binding for semantic_fingerprint mismatch, \ + got: {fp_mismatch_err}" + ); + } + + /// NIP-FI authenticated kind-9 OperatorDenied denial: an authenticated + /// kind-9 event (actor_kind 1–3, non-null request_fingerprint) must commit + /// without an authorization_authentication_denial_attempts row and must + /// reject any attempt to attach one. + /// + /// Mutation sensitivity: + /// - Removing the `actor_kind <> 4` guard from the event-side trigger makes + /// positive A red: the COMMIT fails because the guard now requires a + /// denial-attempt row for the authenticated event and none is present. + /// - Removing the `actor_kind <> 4` shape guard from the attempt-side + /// trigger makes negative B red: the COMMIT is rejected by the pre-existing + /// `authorization_denial_attempt_semantic_binding` guard instead (non-null + /// attempt `semantic_fingerprint` vs. null on the authenticated event), so + /// `assert_eq!` on the constraint name fails. The exact constraint name + /// assertion is therefore the load-bearing proof that the new shape guard — + /// not the pre-existing semantic-binding check — is what fires. + /// + /// The unresolved pre-auth positive path (actor_kind 4) is exercised in + /// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and + /// is unchanged by this fix. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authenticated_kind_9_denial_commits_without_denial_attempt() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("auth-denial-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // Seed an operation receipt for the authenticated denial. Use + // operation_kind = 12 (invalidation) with outcome_code = 2 (denied): + // this satisfies the receipt CHECK constraints without triggering the + // lifecycle history guard (expected_count = 0 for non-lifecycle kinds) + // and without requiring a lifecycle event (expected_event_kind = NULL). + // The authorization_events FK on (community_id, operation_id, + // request_fingerprint) requires a receipt row. + let op_auth = uuid::Uuid::new_v4(); + let fp_auth = vec![0xA1_u8; 32]; + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 12, $4, 2, $5)", + // operation_kind 12 (invalidation), outcome_code 2 (denied) + ) + .bind(community_id) + .bind(op_auth) + .bind(&fp_auth) + .bind(vec![0xA2_u8; 32]) // actor_fingerprint + .bind(vec![0xA3_u8; 32]) // result_digest + .execute(&pool) + .await + .expect("seed authenticated denial receipt"); + + let event_auth = uuid::Uuid::new_v4(); + let corr_auth = uuid::Uuid::new_v4(); + let attempt_auth = uuid::Uuid::new_v4(); + + // --- Positive A: authenticated kind-9 denial (actor_kind = 1) commits + // without any denial-attempt row. The semantic_fingerprint must be NULL + // per the corrected shape CHECK. The deferred cardinality guard must + // skip this event because actor_kind ≠ 4. + let mut conn = pool.acquire().await.expect("acquire connection"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 9, 2, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + ) + .bind(community_id) + .bind(event_auth) + .bind(vec![0xA4_u8; 32]) // actor_fingerprint (required for actor_kind 1) + .bind(op_auth) + .bind(&fp_auth) // non-null request_fingerprint (authenticated shape) + .bind(corr_auth) + .bind(attempt_auth) + // semantic_fingerprint = NULL: authenticated kind-9 must not carry one + .bind(vec![0xA5_u8; 64]) // canonical_envelope + .bind(vec![0xA6_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert authenticated kind-9 event"); + + sqlx::query("COMMIT").execute(&mut *conn).await.expect( + "authenticated kind-9 denial must commit without a denial-attempt row \ + — the event-side cardinality guard must skip actor_kind 1", + ); + drop(conn); + + // Confirm no denial attempt was needed: the table must have zero rows + // for this event. + let attempt_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_authentication_denial_attempts \ + WHERE community_id = $1 AND audit_event_id = $2", + ) + .bind(community_id) + .bind(event_auth) + .fetch_one(&pool) + .await + .expect("count denial attempts for authenticated event"); + assert_eq!( + attempt_count, 0, + "no denial-attempt row should exist for an authenticated kind-9 event" + ); + + // --- Negative B: a denial attempt cannot bind to the authenticated kind-9 + // event. The attempt-side shape guard must reject this at commit because + // the referenced event has actor_kind = 1 (not 4). The rejection must + // name the exact shape constraint (authorization_denial_attempt_event_kind) + // rather than merely returning 23514, proving the new actor/request-fingerprint + // guard fires — not the pre-existing semantic_fingerprint equality check + // (which would fire as authorization_denial_attempt_semantic_binding if + // the shape guard were absent, because the attempt carries a non-null + // semantic_fingerprint while the authenticated event has null). + // + // Reuse attempt_auth from the committed event so the deferred attempt_id + // FK resolves (wrong attempt_id would activate that FK first and make the + // negative non-isolated to the new guard). + let mut conn_b = pool.acquire().await.expect("acquire connection B"); + sqlx::query("BEGIN") + .execute(&mut *conn_b) + .await + .expect("begin B"); + + // Insert the denial attempt referencing the authenticated event. + // The attempt table FKs are deferred, so this INSERT succeeds; + // the shape guard fires at COMMIT. + sqlx::query( + "INSERT INTO authorization_authentication_denial_attempts \ + (community_id, operation_id, correlation_id, semantic_fingerprint, \ + denial_reason, expected_revision, action, reason_code, \ + attempt_id, audit_event_id, audit_event_kind) \ + VALUES ($1, $2, $3, $4, 1, 1, 1, 2, $5, $6, 9)", + ) + .bind(community_id) + .bind(op_auth) + .bind(corr_auth) + .bind(vec![0xA7_u8; 32]) // semantic_fingerprint on the attempt (non-null) + .bind(attempt_auth) // reuse the event's attempt_id — FK isolation + .bind(event_auth) // references the authenticated event (actor_kind = 1) + .execute(&mut *conn_b) + .await + .expect("attempt INSERT must pass — shape guard is deferred"); + + let cross_shape_err = sqlx::query("COMMIT") + .execute(&mut *conn_b) + .await + .expect_err( + "denial attempt binding to authenticated kind-9 event must be rejected at commit", + ); + // The exact constraint name must be authorization_denial_attempt_event_kind — + // the new actor/request_fingerprint shape guard. If the shape guard were + // removed, the pre-existing semantic_fingerprint equality check would fire + // instead, named authorization_denial_attempt_semantic_binding. Requiring + // the exact name makes the mutation reliably red. + assert_eq!( + cross_shape_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denial_attempt_event_kind"), + "rejection must be attributed to authorization_denial_attempt_event_kind \ + shape guard (not an incidental FK or semantic-binding check), \ + got: {cross_shape_err}" + ); + } + + /// NIP-FI denied lifecycle receipt: a denied core lifecycle receipt + /// (outcome_code = 2) must commit without a paired audit event. Requiring + /// one would falsely record that the lifecycle transition occurred. + /// + /// The denied branch forbids any event from the complete core + /// success-transition class (kinds 1, 2, 3, 6). This test uses the mapped + /// kind (kind 1 for enroll). Cross-kind rejection — a wrong success-transition + /// kind on a denied receipt — is exercised by + /// `denied_lifecycle_receipt_wrong_kind_receipt_side` (receipt-side trigger) + /// and `denied_lifecycle_receipt_wrong_kind_event_side` (event-side trigger). + /// + /// Mutation sensitivity: + /// - Removing the `outcome_code IN (1, 3)` branch entirely (or replacing it with a + /// blanket early-return) makes the positive case red — the denied enroll receipt + /// cannot commit alone because the guard then demands a paired enroll audit event + /// (expected_event_kind = 1) that is absent. + /// - Removing the `ELSIF outcome_code = 2` zero-event branch makes the + /// receipt-then-event negative below green (COMMIT succeeds when it must not), + /// failing `expect_err`. The event-side isolation in + /// `denied_lifecycle_receipt_event_side_trigger_isolated` independently confirms + /// the same branch using only the `authorization_event_receipt_cardinality` + /// trigger direction. + /// Applied/no-op lifecycle cardinality is exercised by + /// `applied_lifecycle_receipt_requires_exactly_one_event`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_commits_without_audit_event() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "denied-lifecycle-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert community"); + + // Denied enroll receipt (operation_kind = 1, outcome_code = 2) must commit + // without any paired authorization_events row. The guard must skip it + // because outcome_code = 2 is not in (1, 3). + // + // The receipt history guard (migration 0041) uses `outcome_code IN (1, 3)` + // for lifecycle receipts, so a denied enroll receipt (outcome_code = 2) + // expects zero lifecycle history rows — no history setup is needed. + let op_denied = uuid::Uuid::new_v4(); + let fp_denied = vec![0xB1_u8; 32]; + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 2, $5)", + // operation_kind 1 (enroll), outcome_code 2 (denied) + ) + .bind(community_id) + .bind(op_denied) + .bind(&fp_denied) + .bind(vec![0xB2_u8; 32]) + .bind(vec![0xB3_u8; 32]) + .execute(&pool) + .await + .expect("denied enroll receipt must commit without a paired audit event"); + + // No audit event for this operation; confirm the table is empty for it. + let event_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_events \ + WHERE community_id = $1 AND operation_id = $2", + ) + .bind(community_id) + .bind(op_denied) + .fetch_one(&pool) + .await + .expect("count events for denied receipt"); + assert_eq!( + event_count, 0, + "no audit event should be required or present for a denied lifecycle receipt" + ); + + // --- Negative: denied enroll receipt paired with its mapped success- + // transition event (event_kind = 1, enrolled) must be rejected at COMMIT. + // The receipt-side deferred trigger fires here (receipt was inserted in + // this same transaction). The event-side trigger direction is isolated in + // `denied_lifecycle_receipt_event_side_trigger_isolated`. + // + // Seed event capacity; the authorization_events BEFORE INSERT trigger + // requires a capacity row. No lifecycle history is needed: denied receipts + // (outcome_code = 2) expect zero history rows per the history guard. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let op_neg = uuid::Uuid::new_v4(); + let fp_neg = vec![0xD1_u8; 32]; + let event_neg = uuid::Uuid::new_v4(); + let corr_neg = uuid::Uuid::new_v4(); + let attempt_neg = uuid::Uuid::new_v4(); + + let mut conn_neg = pool.acquire().await.expect("acquire connection neg"); + sqlx::query("BEGIN") + .execute(&mut *conn_neg) + .await + .expect("begin neg"); + + // Denied enroll receipt — no history row needed (outcome_code = 2). + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_neg) + .bind(&fp_neg) + .bind(vec![0xD2_u8; 32]) + .bind(vec![0xD3_u8; 32]) + .execute(&mut *conn_neg) + .await + .expect("insert denied receipt — event guard is deferred"); + + // Insert the mapped success-transition event (event_kind = 1, enrolled). + // actor_kind = 1 requires a non-null actor_fingerprint and a matching + // receipt FK (satisfied by the denied receipt above, which shares the + // same (community_id, operation_id, request_fingerprint)). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + // event_kind 1 (enrolled) — the mapped success transition for enroll + ) + .bind(community_id) + .bind(event_neg) + .bind(vec![0xD4_u8; 32]) // actor_fingerprint + .bind(op_neg) + .bind(&fp_neg) + .bind(corr_neg) + .bind(attempt_neg) + .bind(vec![0xD5_u8; 64]) // canonical_envelope + .bind(vec![0xD6_u8; 32]) // envelope_digest + .execute(&mut *conn_neg) + .await + .expect("event INSERT must pass — deferred guard fires at COMMIT"); + + let contradiction_err = sqlx::query("COMMIT") + .execute(&mut *conn_neg) + .await + .expect_err( + "denied receipt + mapped success event must be rejected at COMMIT \ + — contradictory durable facts must not be permitted", + ); + assert_eq!( + contradiction_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "expected authorization_denied_lifecycle_receipt_no_success_event constraint \ + rejection for denied receipt + success event, got: {contradiction_err}" + ); + } + + /// NIP-FI event-side trigger isolation: when a denied enroll receipt is already + /// committed (auto-commit via pool), a new independent transaction that inserts + /// only the mapped success-transition event must be rejected at COMMIT by + /// `authorization_event_receipt_cardinality` (the event-side deferred trigger). + /// + /// This isolates the `authorization_event_receipt_cardinality` trigger path. + /// In `denied_lifecycle_receipt_commits_without_audit_event`'s receipt-then-event + /// negative, the receipt-side trigger (`authorization_operation_receipt_event_cardinality`) + /// also fires. Here the committed receipt produces no deferred trigger, so rejection + /// can only come from the event-side trigger. Uses the mapped kind (kind 1 for + /// enroll). Wrong-kind event-side isolation is in + /// `denied_lifecycle_receipt_wrong_kind_event_side`. + /// + /// Mutation sensitivity: disabling the + /// `authorization_event_receipt_cardinality` trigger (DROP or ALTER TABLE + /// DISABLE TRIGGER) makes this negative green — the COMMIT succeeds when it + /// must not, so `expect_err` panics. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_event_side_trigger_isolated() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("evt-side-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + // Seed event capacity before any event insert. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // Commit a denied enroll receipt in auto-commit mode (no explicit BEGIN). + // This receipt produces no deferred trigger — the receipt-side deferred + // trigger only fires within the transaction that inserts the receipt row. + let op_id = uuid::Uuid::new_v4(); + let fp = vec![0xE1_u8; 32]; + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xE2_u8; 32]) + .bind(vec![0xE3_u8; 32]) + .execute(&pool) + .await + .expect("denied receipt must commit alone in auto-commit mode"); + + // Now open a NEW transaction and insert only the mapped success-transition + // event (event_kind = 1, enrolled). The receipt is already committed and + // its deferred trigger is no longer active. Rejection at COMMIT must come + // from authorization_event_receipt_cardinality (the event-side trigger). + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for event-side test"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin event-side transaction"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xE4_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xE5_u8; 64]) // canonical_envelope + .bind(vec![0xE6_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); + + let event_side_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( + "mapping a success-transition event to a committed denied receipt \ + must be rejected at COMMIT by the event-side trigger", + ); + assert_eq!( + event_side_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event, \ + got: {event_side_err}" + ); + } + + /// NIP-FI applied lifecycle receipt: an applied core lifecycle enroll receipt + /// (outcome_code = 1) requires exactly one mapped success-transition event + /// (event_kind = 1, enrolled). This exercises the `outcome_code IN (1, 3)` + /// branch of `authorization_operation_receipt_event_guard_v1` at migration 42. + /// + /// Mutation sensitivity: + /// - Removing/bypassing the applied/no-op branch (replacing it with a blanket + /// RETURN NULL) makes the positive transaction commit without an event, leaving + /// the contract silently unenforced. The negative below requires the cardinality + /// constraint to fire when the event is absent. + /// - Removing the negative assertion: the absent-event case would commit when it + /// must not. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn applied_lifecycle_receipt_requires_exactly_one_event() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "applied-lifecycle-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert community"); + + // Enrollment policy (TOFU, mode 3). + let policy_revision: i64 = 1; + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, $2, 3, $3, '2026-01-01T00:00:00Z')", + ) + .bind(community_id) + .bind(policy_revision) + .bind(vec![0xF0_u8; 32]) + .execute(&pool) + .await + .expect("insert enrollment policy"); + + // Event capacity — required by authorization_event_capacity_before_insert_v1. + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // --- Positive: applied enroll commits with exactly one mapped event --- + // + // All cross-table FKs between identity_lifecycle_history, identity_bindings, + // authorization_operation_receipts, and authorization_events are + // DEFERRABLE INITIALLY DEFERRED — insert order within the transaction is + // flexible, but a pinned connection is required for BEGIN/COMMIT to share + // the same session. The receipt_history_cardinality trigger (migration 0041) + // fires at COMMIT and requires exactly one history row for applied enroll. + let op_id = uuid::Uuid::new_v4(); + let binding_id = uuid::Uuid::new_v4(); + let history_id = uuid::Uuid::new_v4(); + let fp = vec![0xF1_u8; 32]; + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for positive case"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin positive transaction"); + + // History first: the receipt_history_cardinality AFTER INSERT trigger + // on authorization_operation_receipts is DEFERRED and checks at COMMIT + // time, but inserting history before receipt is idiomatic. + // successor_binding_version = 1 because binding_version is an identity + // sequence starting at 1 per community; this is the first binding. + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 1, 1, 1, $4, $5, $6)", + ) + .bind(community_id) + .bind(history_id) + .bind(binding_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xF2_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert lifecycle history"); + + // Applied enroll receipt (operation_kind = 1, outcome_code = 1). + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xF3_u8; 32]) + .bind(vec![0xF4_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert applied enroll receipt"); + + // Binding — birth_history_id FK is deferred; binding_version is generated. + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-applied', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_id) + .bind(vec![0xF5_u8; 32]) // principal_fingerprint + .bind(vec![0xF6_u8; 32]) // event_author_pubkey + .bind(policy_revision) + .bind(vec![0xF7_u8; 32]) // enrollment_evidence_digest + .bind(history_id) + .bind(op_id) + .bind(&fp) + .execute(&mut *conn) + .await + .expect("insert identity binding"); + + // Mapped success-transition event (event_kind = 1, enrolled). + // actor_kind = 1 requires non-null actor_fingerprint and matching receipt FK. + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xF8_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xF9_u8; 64]) // canonical_envelope + .bind(vec![0xFA_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("insert mapped success-transition event"); + + sqlx::query("COMMIT").execute(&mut *conn).await.expect( + "applied enroll receipt + exactly one mapped event must commit — \ + authorization_operation_receipt_event_guard_v1 applied/no-op branch", + ); + + // Confirm exactly one event committed for this operation. + let event_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_events \ + WHERE community_id = $1 AND operation_id = $2", + ) + .bind(community_id) + .bind(op_id) + .fetch_one(&pool) + .await + .expect("count events for applied receipt"); + assert_eq!( + event_count, 1, + "exactly one audit event must be present for an applied enroll receipt" + ); + + // --- Negative: applied enroll receipt without a mapped event must reject --- + // + // A second applied enroll transaction that commits receipt + history + binding + // but no event must be rejected with authorization_operation_receipt_event_cardinality. + let op_neg = uuid::Uuid::new_v4(); + let binding_neg = uuid::Uuid::new_v4(); + let history_neg = uuid::Uuid::new_v4(); + let fp_neg = vec![0xFB_u8; 32]; + + let mut conn_neg = pool + .acquire() + .await + .expect("acquire connection for negative case"); + sqlx::query("BEGIN") + .execute(&mut *conn_neg) + .await + .expect("begin negative transaction"); + + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, \ + successor_binding_id, successor_binding_version, \ + successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, 2, 1, 1, $4, $5, $6)", + // successor_binding_version = 2: second binding in this community + ) + .bind(community_id) + .bind(history_neg) + .bind(binding_neg) + .bind(op_neg) + .bind(&fp_neg) + .bind(vec![0xFC_u8; 32]) + .execute(&mut *conn_neg) + .await + .expect("insert negative lifecycle history"); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(community_id) + .bind(op_neg) + .bind(&fp_neg) + .bind(vec![0xFD_u8; 32]) + .bind(vec![0xFE_u8; 32]) + .execute(&mut *conn_neg) + .await + .expect("insert negative applied receipt"); + + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, \ + principal_fingerprint, event_author_pubkey, \ + binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, \ + birth_history_id, creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', 'sub-applied-neg', \ + $3, $4, 1, 1, 1, $5, $6, $7, $8, $9)", + ) + .bind(community_id) + .bind(binding_neg) + .bind(vec![0xE7_u8; 32]) // principal_fingerprint (distinct from positive) + .bind(vec![0xE8_u8; 32]) // event_author_pubkey (distinct from positive) + .bind(policy_revision) + .bind(vec![0xE9_u8; 32]) + .bind(history_neg) + .bind(op_neg) + .bind(&fp_neg) + .execute(&mut *conn_neg) + .await + .expect("insert negative binding — no event inserted"); + + // Commit without the mapped event — guard must reject. + let absent_event_err = sqlx::query("COMMIT") + .execute(&mut *conn_neg) + .await + .expect_err( + "applied enroll receipt without a mapped success-transition event \ + must be rejected at COMMIT", + ); + assert_eq!( + absent_event_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_operation_receipt_event_cardinality"), + "expected authorization_operation_receipt_event_cardinality rejection \ + for applied receipt without event, got: {absent_event_err}" + ); + } + + /// NIP-FI cross-kind denied lifecycle: a wrong success-transition kind paired + /// with a denied lifecycle receipt must be rejected through the receipt-side + /// deferred trigger. Uses a denied enroll receipt (operation_kind = 1, mapped + /// kind = 1) with a kind-6 (retired) event — a different success-transition + /// kind that is equally forbidden by the class-based guard (kinds 1, 2, 3, 6). + /// + /// Both the receipt and the wrong-kind event are inserted in the same + /// transaction, so the receipt-side deferred trigger + /// (`authorization_operation_receipt_event_cardinality`) fires at COMMIT. + /// + /// Mutation sensitivity: narrowing the denied filter back to + /// `event_kind = expected_event_kind` (the mapped kind, 1) removes kind 6 + /// from the forbidden set, causing this negative to turn green — COMMIT + /// succeeds when it must not, failing `expect_err`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_wrong_kind_receipt_side() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("wrong-kind-rcpt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + let op_id = uuid::Uuid::new_v4(); + let fp = vec![0xA0_u8; 32]; + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for wrong-kind receipt-side test"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin"); + + // Denied enroll receipt (operation_kind = 1, outcome_code = 2). + // No history row needed: outcome_code = 2 expects zero history rows. + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xA1_u8; 32]) + .bind(vec![0xA2_u8; 32]) + .execute(&mut *conn) + .await + .expect("insert denied enroll receipt — deferred guard"); + + // Wrong success-transition kind: event_kind = 6 (retired), not the mapped + // kind 1 (enrolled). Both are in the forbidden class (1, 2, 3, 6). + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + // event_kind 6 (retired) — wrong kind for a denied enroll receipt + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xA3_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xA4_u8; 64]) // canonical_envelope + .bind(vec![0xA5_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("event INSERT must pass — deferred guard fires at COMMIT"); + + let wrong_kind_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( + "denied receipt + wrong success-transition kind (6) must be rejected at COMMIT \ + — class-based guard forbids all of kinds 1, 2, 3, 6", + ); + assert_eq!( + wrong_kind_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "expected authorization_denied_lifecycle_receipt_no_success_event for \ + denied receipt + wrong kind (6), got: {wrong_kind_err}" + ); + } + + /// NIP-FI cross-kind denied lifecycle event-side: after a denied enroll + /// receipt is committed alone (auto-commit), a new transaction that inserts + /// only a wrong success-transition kind (kind 6, retired) must be rejected at + /// COMMIT by `authorization_event_receipt_cardinality` (event-side trigger). + /// + /// This isolates the event-side trigger path for the cross-kind case. + /// The committed receipt produces no active deferred trigger, so rejection + /// can only come from the event-side trigger. + /// + /// Mutation sensitivity: narrowing the denied filter to + /// `event_kind = expected_event_kind` (kind 1) removes kind 6 from the + /// forbidden set, making this negative green — COMMIT succeeds when it must + /// not, failing `expect_err`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn denied_lifecycle_receipt_wrong_kind_event_side() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(42, &pool) + .await + .expect("apply migrations 1-42"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!("wrong-kind-evt-{}.example", community_id.simple())) + .execute(&pool) + .await + .expect("insert community"); + + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 1000, 16777216, 16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("insert event capacity"); + + // Commit a denied enroll receipt in auto-commit mode. No deferred trigger + // is active after this commit; the receipt-side trigger fires only within + // the transaction that inserts the receipt. + let op_id = uuid::Uuid::new_v4(); + let fp = vec![0xB0_u8; 32]; + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 2, $5)", + ) + .bind(community_id) + .bind(op_id) + .bind(&fp) + .bind(vec![0xB1_u8; 32]) + .bind(vec![0xB2_u8; 32]) + .execute(&pool) + .await + .expect("denied receipt must commit alone in auto-commit mode"); + + // New transaction: insert only a kind-6 (retired) event for the same + // operation. The event-side trigger is the only active deferred trigger. + let event_id = uuid::Uuid::new_v4(); + let corr_id = uuid::Uuid::new_v4(); + let attempt_id = uuid::Uuid::new_v4(); + + let mut conn = pool + .acquire() + .await + .expect("acquire connection for wrong-kind event-side test"); + sqlx::query("BEGIN") + .execute(&mut *conn) + .await + .expect("begin event-side wrong-kind transaction"); + + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, \ + actor_kind, actor_fingerprint, operation_id, request_fingerprint, \ + correlation_id, attempt_id, semantic_fingerprint, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 6, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \ + '2026-01-01T00:00:00Z', $8, $9)", + // event_kind 6 (retired) — wrong kind for the denied enroll receipt + ) + .bind(community_id) + .bind(event_id) + .bind(vec![0xB3_u8; 32]) // actor_fingerprint + .bind(op_id) + .bind(&fp) + .bind(corr_id) + .bind(attempt_id) + .bind(vec![0xB4_u8; 64]) // canonical_envelope + .bind(vec![0xB5_u8; 32]) // envelope_digest + .execute(&mut *conn) + .await + .expect("event INSERT must pass — event-side deferred guard fires at COMMIT"); + + let wrong_kind_evt_err = sqlx::query("COMMIT").execute(&mut *conn).await.expect_err( + "kind-6 event paired with a committed denied enroll receipt must be \ + rejected at COMMIT by the event-side trigger", + ); + assert_eq!( + wrong_kind_evt_err + .as_database_error() + .and_then(|e| e.constraint()), + Some("authorization_denied_lifecycle_receipt_no_success_event"), + "event-side trigger must reject with authorization_denied_lifecycle_receipt_no_success_event \ + for wrong kind (6) against denied receipt, got: {wrong_kind_evt_err}" + ); + } } diff --git a/desktop/package.json b/desktop/package.json index 14db248a134..425fbbbd9dd 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"", "preview": "vite preview", "tauri": "node ./scripts/tauri-command.mjs", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/scripts/demo-build-config.mjs b/desktop/scripts/demo-build-config.mjs new file mode 100644 index 00000000000..fd5c9ed2a1c --- /dev/null +++ b/desktop/scripts/demo-build-config.mjs @@ -0,0 +1,94 @@ +import { randomBytes } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PRODUCTION_IDENTIFIER = "xyz.block.buzz.app"; +// The build ID suffix is 17 characters including its separator, and the Rust +// build contract caps the complete demo slug at 48 ASCII bytes. +const MAX_DEMO_SLUG_LENGTH = 48; +const DEMO_BUILD_ID_SUFFIX_LENGTH = 17; +const MAX_DEMO_NAME_LENGTH = MAX_DEMO_SLUG_LENGTH - DEMO_BUILD_ID_SUFFIX_LENGTH; + +export const productionBuildIdentity = Object.freeze({ + productName: "Buzz", + identifier: PRODUCTION_IDENTIFIER, + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", +}); + +export function demoBuildConfig( + rawName, + buildId = randomBytes(8).toString("hex"), +) { + if (typeof rawName !== "string") throw new Error("Demo name must be text"); + const name = rawName.trim().replace(/\s+/g, " "); + if (!name) throw new Error("Demo name must not be empty"); + if (name.length > MAX_DEMO_NAME_LENGTH) { + throw new Error( + `Demo name must be at most ${MAX_DEMO_NAME_LENGTH} characters`, + ); + } + if (!/^[A-Za-z0-9][A-Za-z0-9 -]*$/.test(name)) { + throw new Error( + "Demo name may contain ASCII letters, numbers, spaces, and hyphens only", + ); + } + + if (!/^[a-f0-9]{16}$/.test(buildId)) { + throw new Error( + "Demo build ID must be sixteen lowercase hexadecimal characters", + ); + } + + const readableSlug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const slug = `${readableSlug}-${buildId}`; + const productName = `Buzz ${name}`; + return { + name, + slug, + productName, + dmgVolumeName: productName, + dmgFileStem: productName.replace(/ /g, "_"), + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + appDataIdentity: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName, + identifier: `${PRODUCTION_IDENTIFIER}.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [name, outputPath, buildId] = process.argv.slice(2); + if (!outputPath) { + console.error( + "Usage: demo-build-config.mjs ", + ); + process.exit(2); + } + try { + const config = demoBuildConfig(name, buildId); + writeFileSync( + outputPath, + `${JSON.stringify(config.tauriConfig, null, 2)}\n`, + ); + console.log(JSON.stringify(config)); + } catch (error) { + console.error(`Invalid demo build: ${error.message}`); + process.exit(1); + } +} diff --git a/desktop/scripts/demo-build-config.test.mjs b/desktop/scripts/demo-build-config.test.mjs new file mode 100644 index 00000000000..db2ba568c7b --- /dev/null +++ b/desktop/scripts/demo-build-config.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + demoBuildConfig, + productionBuildIdentity, +} from "./demo-build-config.mjs"; + +const expected = (name, slug) => ({ + name, + slug, + productName: `Buzz ${name}`, + dmgVolumeName: `Buzz ${name}`, + dmgFileStem: `Buzz_${name.replace(/ /g, "_")}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + appDataIdentity: `xyz.block.buzz.app.demo.${slug}`, + deepLinkScheme: `buzz-demo-${slug}`, + keyringService: `buzz-desktop-demo.${slug}`, + nestName: `.buzz-demo-${slug}`, + cliName: `buzz-demo-${slug}`, + tauriConfig: { + productName: `Buzz ${name}`, + identifier: `xyz.block.buzz.app.demo.${slug}`, + plugins: { "deep-link": { desktop: { schemes: [`buzz-demo-${slug}`] } } }, + bundle: { targets: ["app"] }, + }, +}); + +test("production identity remains unchanged", () => { + assert.deepEqual(productionBuildIdentity, { + productName: "Buzz", + identifier: "xyz.block.buzz.app", + deepLinkScheme: "buzz", + keyringService: "buzz-desktop", + nestName: ".buzz", + cliName: "buzz", + }); +}); + +test("two demo names produce complete, distinct identities", () => { + const board = demoBuildConfig("Workstream Board", "27a4294c27a4294c"); + const interests = demoBuildConfig("Interests Demo", "deb5339adeb5339a"); + assert.deepEqual( + board, + expected("Workstream Board", "workstream-board-27a4294c27a4294c"), + ); + assert.deepEqual( + interests, + expected("Interests Demo", "interests-demo-deb5339adeb5339a"), + ); + for (const key of [ + "productName", + "dmgVolumeName", + "dmgFileStem", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(board[key], interests[key], key); + assert.notEqual(board[key], productionBuildIdentity[key], key); + } +}); + +test("normalized spelling aliases retain distinct runtime identities", () => { + for (const [leftName, rightName] of [ + ["A B", "A-B"], + ["Demo", "demo"], + ["Workstream Board", "WORKSTREAM BOARD"], + ]) { + const left = demoBuildConfig(leftName, "1111111111111111"); + const right = demoBuildConfig(rightName, "2222222222222222"); + assert.notEqual(left.slug, right.slug); + for (const key of [ + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual( + left[key], + right[key], + `${leftName}/${rightName}: ${key}`, + ); + } + } +}); + +test("the same display name gets a distinct identity for each build", () => { + const first = demoBuildConfig("Demo", "1111111111111111"); + const second = demoBuildConfig("Demo", "2222222222222222"); + assert.equal(first.productName, second.productName); + assert.equal(first.dmgFileStem, second.dmgFileStem); + for (const key of [ + "slug", + "identifier", + "appDataIdentity", + "deepLinkScheme", + "keyringService", + "nestName", + "cliName", + ]) { + assert.notEqual(first[key], second[key], key); + } +}); + +test("whitespace normalization preserves deterministic identity", () => { + assert.deepEqual( + demoBuildConfig(" Workstream Board ", "27a4294c27a4294c"), + demoBuildConfig("Workstream Board", "27a4294c27a4294c"), + ); +}); + +test("maximum-length name produces a Rust-valid 48-byte slug", () => { + const config = demoBuildConfig("x".repeat(31), "1234567812345678"); + assert.equal(config.slug.length, 48); + assert.match(config.slug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +}); + +for (const name of [ + "", + " ", + "Workstream/Board", + "Workstream_Board", + "équipe", + "x".repeat(32), +]) { + test(`rejects unusable name ${JSON.stringify(name)}`, () => + assert.throws(() => demoBuildConfig(name, "1234567812345678"))); +} diff --git a/desktop/scripts/package-macos-dmg.sh b/desktop/scripts/package-macos-dmg.sh new file mode 100755 index 00000000000..7ecaf9502e8 --- /dev/null +++ b/desktop/scripts/package-macos-dmg.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Build a drag-to-Applications DMG without requiring a GUI login session. +# Finder styling is optional; the disk image itself is always authoritative. + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +app_path="$1" +out_dmg="$2" +app_name="$(basename "$app_path")" +volume_name="${VOL_NAME:-Buzz}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +background="$script_dir/../src-tauri/icons/dmg-background.png" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-dmg.XXXXXX")" +source_dir="$work_dir/source" +rw_dmg="$work_dir/read-write.dmg" +mount_point="$work_dir/mount" +applescript="$work_dir/style.applescript" +device="" + +finish() { + local status="$?" + trap - EXIT + if [[ -n "$device" ]]; then + hdiutil detach "$device" >/dev/null 2>&1 || true + hdiutil detach -force "$device" >/dev/null 2>&1 || true + fi + rm -rf "$work_dir" + exit "$status" +} +trap finish EXIT + +[[ -d "$app_path" ]] || { echo "App bundle not found: $app_path" >&2; exit 1; } +[[ -f "$background" ]] || { echo "DMG background not found: $background" >&2; exit 1; } + +mkdir -p "$(dirname "$out_dmg")" "$source_dir/.background" "$mount_point" +ditto "$app_path" "$source_dir/$app_name" +ln -s /Applications "$source_dir/Applications" +cp "$background" "$source_dir/.background/background.png" + +rm -f "$rw_dmg" "$out_dmg" +hdiutil create -volname "$volume_name" -srcfolder "$source_dir" \ + -format UDRW -ov "$rw_dmg" >/dev/null + +attach_output="$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse \ + -mountpoint "$mount_point" "$rw_dmg")" +device="$(printf '%s\n' "$attach_output" | awk '/^\/dev\// { print $1; exit }')" +[[ -n "$device" ]] || { echo "Failed to attach writable DMG" >&2; exit 1; } + +detach() { + local attempt + for attempt in 1 2 3 4 5; do + if hdiutil detach "$device" >/dev/null 2>&1; then + device="" + return 0 + fi + sleep 1 + done + hdiutil detach -force "$device" >/dev/null + device="" +} + +if command -v SetFile >/dev/null 2>&1; then + SetFile -a V "$mount_point/.background" || true + icon="$mount_point/$app_name/Contents/Resources/icon.icns" + if [[ -f "$icon" ]]; then + cp "$icon" "$mount_point/.VolumeIcon.icns" || true + SetFile -c icnC "$mount_point/.VolumeIcon.icns" || true + SetFile -a C "$mount_point" || true + fi +fi + +cat >"$applescript" <<'APPLESCRIPT' +on run argv + set mountPath to item 1 of argv + set appName to item 2 of argv + tell application "Finder" + set rootFolder to POSIX file mountPath as alias + open rootFolder + set imageWindow to container window of rootFolder + set current view of imageWindow to icon view + set toolbar visible of imageWindow to false + set statusbar visible of imageWindow to false + set bounds of imageWindow to {200, 120, 860, 652} + set viewOptions to icon view options of imageWindow + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 128 + set text size of viewOptions to 14 + set background picture of viewOptions to file ".background:background.png" of rootFolder + set position of item appName of rootFolder to {191, 330} + set position of item "Applications" of rootFolder to {469, 330} + set extension hidden of item appName of rootFolder to true + delay 1 + close imageWindow + end tell +end run +APPLESCRIPT + +style_with_finder() { + local child elapsed=0 + /usr/bin/osascript "$applescript" "$mount_point" "$app_name" & + child=$! + while kill -0 "$child" 2>/dev/null; do + if (( elapsed >= 100 )); then + echo "Finder styling timed out; continuing without it" >&2 + kill "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + return 124 + fi + sleep 0.1 + elapsed=$((elapsed + 1)) + done + wait "$child" +} + +if ! style_with_finder; then + echo "Finder styling unavailable; continuing without it" >&2 +fi + +sync +detach +hdiutil convert "$rw_dmg" -format UDZO -imagekey zlib-level=9 \ + -o "$out_dmg" >/dev/null +printf 'DMG ready: %s\n' "$out_dmg" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c735..8b0e63f12bc 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -18,8 +18,29 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") { + let valid = !slug.is_empty() + && slug.len() <= 48 + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && slug + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && slug + .bytes() + .last() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + panic!("BUZZ_BUILD_DEMO_SLUG must be a lowercase ASCII slug"); + } + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DEMO_SLUG={slug}"); + } + // Explicit owner-only agent-access capability. Release packaging sets this // presence-only marker; OSS/custom builds leave agent access configurable. if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f58..7684355a5bc 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -7,7 +7,12 @@ fn dev_keyring_service(configured: Option) -> String { } pub(crate) fn keyring_service() -> &'static str { - if cfg!(debug_assertions) { + if crate::build_identity::is_demo_build() { + static DEMO_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); + DEMO_SERVICE + .get_or_init(|| crate::build_identity::keyring_service().into_owned()) + .as_str() + } else if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); DEV_SERVICE .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) diff --git a/desktop/src-tauri/src/build_identity.rs b/desktop/src-tauri/src/build_identity.rs new file mode 100644 index 00000000000..ee84696c7f0 --- /dev/null +++ b/desktop/src-tauri/src/build_identity.rs @@ -0,0 +1,183 @@ +//! Compile-time identity for reusable named demo builds. +//! +//! Production builds leave `BUZZ_DESKTOP_BUILD_DEMO_SLUG` unset and retain all +//! existing names. The demo recipe validates one slug and `build.rs` bakes it +//! into the binary; every runtime identity is then derived from that one value. + +use std::borrow::Cow; + +pub(crate) fn demo_slug() -> Option<&'static str> { + option_env!("BUZZ_DESKTOP_BUILD_DEMO_SLUG") +} + +pub(crate) fn is_demo_build() -> bool { + demo_slug().is_some() +} + +pub(crate) const DEMO_AGENT_CONFIG_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; + +pub(crate) fn demo_config_home() -> Result, String> { + demo_config_home_for(demo_slug(), dirs::config_dir()) +} + +pub(crate) fn demo_agent_oauth_cache_dir() -> Result, String> { + Ok(demo_config_home()?.map(|dir| dir.join("buzz-agent").join("oauth"))) +} + +/// Keep child config caches inside this demo build's identity. In particular, +/// bundled buzz-agent OAuth tokens must not read or write production's root. +/// Refuse launch if a demo cannot resolve its root; None means production only. +pub(crate) fn apply_demo_config_home(command: &mut std::process::Command) -> Result<(), String> { + if let Some(config_home) = demo_config_home()? { + command.env(DEMO_AGENT_CONFIG_ENV, config_home); + } + Ok(()) +} + +fn demo_config_home_for( + demo_slug: Option<&str>, + config_dir: Option, +) -> Result, String> { + match demo_slug { + None => Ok(None), + Some(slug) => config_dir + .map(|dir| Some(dir.join(format!("buzz-demo-{slug}")))) + .ok_or_else(|| "cannot resolve demo credential directory".to_string()), + } +} + +pub(crate) fn deep_link_scheme() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-demo-{slug}"))) + .unwrap_or(Cow::Borrowed("buzz")) +} + +pub(crate) fn is_deep_link_for_build(value: &str) -> bool { + is_deep_link_for_scheme(value, deep_link_scheme().as_ref()) +} + +fn is_deep_link_for_scheme(value: &str, scheme: &str) -> bool { + value + .strip_prefix(scheme) + .is_some_and(|suffix| suffix.starts_with("://")) +} + +pub(crate) fn keyring_service() -> Cow<'static, str> { + demo_slug() + .map(|slug| Cow::Owned(format!("buzz-desktop-demo.{slug}"))) + .unwrap_or(Cow::Borrowed("buzz-desktop")) +} + +pub(crate) fn nest_name(is_dev: bool) -> Cow<'static, str> { + nest_name_for(demo_slug(), is_dev) +} + +fn nest_name_for(demo_slug: Option<&str>, is_dev: bool) -> Cow<'_, str> { + if let Some(slug) = demo_slug { + Cow::Owned(format!(".buzz-demo-{slug}")) + } else if is_dev { + Cow::Borrowed(".buzz-dev") + } else { + Cow::Borrowed(".buzz") + } +} + +pub(crate) fn cli_name(is_dev: bool) -> String { + if let Some(slug) = demo_slug() { + format!("buzz-demo-{slug}") + } else if is_dev { + "buzz-dev".to_string() + } else { + "buzz".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "compiled with BUZZ_BUILD_DEMO_SLUG by the compiled-flags recipe"] + fn compiled_demo_slug_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_DEMO_SLUG") + .expect("BUZZ_TEST_EXPECTED_DEMO_SLUG must be set"); + assert_eq!(demo_slug(), Some(expected.as_str())); + } + + #[test] + fn ordinary_release_defaults_remain_production_identity() { + if demo_slug().is_none() { + assert_eq!(deep_link_scheme(), "buzz"); + assert_eq!(keyring_service(), "buzz-desktop"); + assert_eq!(nest_name(false), ".buzz"); + assert_eq!(cli_name(false), "buzz"); + } + } + + #[test] + fn demo_agent_config_and_oauth_roots_are_build_scoped() { + let base = std::path::PathBuf::from("/Users/demo/Library/Application Support"); + assert_eq!( + demo_config_home_for(None, Some(base.clone())).unwrap(), + None + ); + let first = demo_config_home_for(Some("board-1234567812345678"), Some(base.clone())) + .unwrap() + .unwrap(); + let second = demo_config_home_for(Some("board-8765432187654321"), Some(base)) + .unwrap() + .unwrap(); + assert_eq!( + first, + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678" + ) + ); + assert_eq!( + first.join("buzz-agent/oauth"), + std::path::PathBuf::from( + "/Users/demo/Library/Application Support/buzz-demo-board-1234567812345678/buzz-agent/oauth" + ) + ); + assert_ne!(first, second); + } + + #[test] + fn unresolved_demo_credentials_never_select_production_defaults() { + assert_eq!(demo_config_home_for(None, None).unwrap(), None); + assert_eq!( + demo_config_home_for(Some("board-1234567812345678"), None), + Err("cannot resolve demo credential directory".to_string()) + ); + } + + #[test] + fn duplicate_instance_links_follow_the_build_scheme() { + assert!(is_deep_link_for_scheme("buzz://message?id=1", "buzz")); + assert!(!is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz" + )); + assert!(is_deep_link_for_scheme( + "buzz-demo-board-1234567812345678://message?id=1", + "buzz-demo-board-1234567812345678" + )); + assert!(!is_deep_link_for_scheme( + "buzz://message?id=1", + "buzz-demo-board-1234567812345678" + )); + } + + #[test] + fn production_and_named_demo_nests_are_distinct() { + assert_eq!(nest_name_for(None, false), ".buzz"); + assert_eq!( + nest_name_for(Some("workstream-board"), false), + ".buzz-demo-workstream-board" + ); + assert_eq!( + nest_name_for(Some("second-demo"), false), + ".buzz-demo-second-demo" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..f671983bbc6 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -54,6 +54,8 @@ pub(super) async fn run_agent_models_command( for (k, v) in &merged_env { cmd.env(k, v); } + // Demo identity is authoritative and must win over ambient/user env. + crate::build_identity::apply_demo_config_home(&mut cmd)?; crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_models_databricks.rs b/desktop/src-tauri/src/commands/agent_models_databricks.rs index 1f66f24c6a3..07f19f9a204 100644 --- a/desktop/src-tauri/src/commands/agent_models_databricks.rs +++ b/desktop/src-tauri/src/commands/agent_models_databricks.rs @@ -178,12 +178,23 @@ pub(super) async fn discover_databricks_models( parsed_filter.clone(), ); let redaction_env = redaction_env_with_value(env, "DATABRICKS_TOKEN", &api_key); + let oauth_cache_dir = crate::build_identity::demo_agent_oauth_cache_dir()?; - let entries = match buzz_agent_pkg::discover_databricks_models(&config).await { + let entries = match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) if should_start_interactive_auth(&api_key) => { let _auth = AUTH_GATE.lock().await; - match buzz_agent_pkg::discover_databricks_models(&config).await { + match buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + { // A peer sign-in under the gate already succeeded. Ok(entries) => entries, Err(buzz_agent_pkg::AgentError::LlmAuth(_)) => { @@ -194,22 +205,28 @@ pub(super) async fn discover_databricks_models( return Err(databricks_sign_in_required_error()); } run_interactive_databricks_auth( - buzz_agent_pkg::authenticate_databricks(&host), + buzz_agent_pkg::authenticate_databricks_with_cache_dir( + &host, + oauth_cache_dir.as_deref(), + ), AUTH_FLOW_TIMEOUT, &AUTH_COOLDOWNS, &host, &redaction_env, ) .await?; - buzz_agent_pkg::discover_databricks_models(&config) - .await - .map_err(|error| { - format_redacted_error( - "Databricks model discovery failed after sign-in", - &error, - &redaction_env, - ) - })? + buzz_agent_pkg::discover_databricks_models_with_cache_dir( + &config, + oauth_cache_dir.as_deref(), + ) + .await + .map_err(|error| { + format_redacted_error( + "Databricks model discovery failed after sign-in", + &error, + &redaction_env, + ) + })? } Err(error) => { return Err(format_redacted_error( diff --git a/desktop/src-tauri/src/commands/project_repo_paths.rs b/desktop/src-tauri/src/commands/project_repo_paths.rs index 4193327c012..3fd4bbcaf82 100644 --- a/desktop/src-tauri/src/commands/project_repo_paths.rs +++ b/desktop/src-tauri/src/commands/project_repo_paths.rs @@ -145,13 +145,26 @@ pub(crate) fn find_local_repo_dir( } pub(crate) fn default_repos_root_candidates() -> Vec { + default_repos_root_candidates_for( + nest_dir(), + dirs::home_dir(), + crate::build_identity::is_demo_build(), + ) +} + +fn default_repos_root_candidates_for( + nest: Option, + home: Option, + is_demo_build: bool, +) -> Vec { let mut candidates = Vec::new(); - candidates.extend(nest_dir().map(|path| path.join("REPOS"))); - candidates.extend( - dirs::home_dir() - .map(|home| home.join(".buzz").join("REPOS")) - .filter(|path| !candidates.iter().any(|candidate| candidate == path)), - ); + candidates.extend(nest.map(|path| path.join("REPOS"))); + if !is_demo_build { + candidates.extend( + home.map(|home| home.join(".buzz").join("REPOS")) + .filter(|path| !candidates.iter().any(|candidate| candidate == path)), + ); + } candidates } @@ -190,3 +203,34 @@ pub(crate) fn canonical_repos_roots( } Ok(roots) } + +#[cfg(test)] +mod tests { + use super::default_repos_root_candidates_for; + use std::path::PathBuf; + + #[test] + fn production_keeps_the_legacy_repo_fallback() { + let home = PathBuf::from("/Users/example"); + assert_eq!( + default_repos_root_candidates_for( + Some(home.join(".buzz-dev")), + Some(home.clone()), + false, + ), + vec![home.join(".buzz-dev/REPOS"), home.join(".buzz/REPOS")] + ); + } + + #[test] + fn named_demos_only_search_their_selected_nest() { + let home = PathBuf::from("/Users/example"); + for slug in ["workstream-board", "second-demo"] { + let nest = home.join(format!(".buzz-demo-{slug}")); + assert_eq!( + default_repos_root_candidates_for(Some(nest.clone()), Some(home.clone()), true,), + vec![nest.join("REPOS")] + ); + } + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 83ac7e59ff9..614c62e1aaf 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -404,6 +404,18 @@ const ENTITY_LINK_TABS: [&str; 6] = [ "channels", ]; +/// Validate the build-specific transport URL, then hand the frontend its +/// canonical entity-link representation. Never broaden frontend scheme trust. +fn canonical_entity_deep_link(url: &Url, build_scheme: &str) -> Option { + if url.scheme() != build_scheme { + return None; + } + parse_entity_deep_link(url)?; + let mut canonical = url.clone(); + canonical.set_scheme("buzz").ok()?; + Some(canonical.into()) +} + /// The canonical-form rules match `parseEntityLink`: no path segments, no /// fragment, and no parameters beyond `owner`/`d` (plus `id` for event /// links and the optional `tab` for coordinate links), so a future @@ -600,7 +612,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } }; - if url.scheme() != "buzz" { + if url.scheme() != crate::build_identity::deep_link_scheme() { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } @@ -678,17 +690,17 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let _ = app.emit("deep-link-message", payload); } Some("repo" | "project" | "pr" | "issue") => { - // `buzz://repo|project?owner=&d=` and - // `buzz://pr|issue?id=&owner=&d=` — the - // share links copied from the Projects UI. The frontend owns - // routing (`useEntityDeepLinks`), so the validated URL is - // forwarded unchanged. - if parse_entity_deep_link(&url).is_none() { + // OS routing uses this build's scheme; frontend navigation consumes + // canonical buzz:// entity links rather than transport identity. + let Some(href) = canonical_entity_deep_link( + &url, + crate::build_identity::deep_link_scheme().as_ref(), + ) else { eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); return; - } + }; activate_main_window(app); - let pending = queue_entity_deep_link(app, url_str.to_owned()); + let pending = queue_entity_deep_link(app, href); let _ = app.emit("deep-link-entity", pending); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs index 84a08c4c64e..da960f3a2d9 100644 --- a/desktop/src-tauri/src/deep_link_tests.rs +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -1,10 +1,11 @@ use url::Url; use super::{ - parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, - parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, - PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, - PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, + canonical_entity_deep_link, parse_add_community_deep_link, parse_channel_deep_link, + parse_entity_deep_link, parse_join_deep_link, parse_message_deep_link, + parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + PendingEntityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, + ENTITY_LINK_TABS, }; fn entity_link_golden() -> serde_json::Value { @@ -12,6 +13,35 @@ fn entity_link_golden() -> serde_json::Value { .expect("valid entity-links golden fixture") } +#[test] +fn demo_entity_transport_produces_the_frontend_golden_contract() { + let golden = entity_link_golden(); + let scheme = "buzz-demo-board-1234567812345678"; + for canonical in golden["links"].as_object().unwrap().values() { + let canonical = canonical.as_str().unwrap(); + let transport = Url::parse(&canonical.replacen("buzz:", &format!("{scheme}:"), 1)).unwrap(); + let href = canonical_entity_deep_link(&transport, scheme).unwrap(); + // This same fixture is parsed and routed by the frontend entity tests. + assert_eq!(href, canonical); + let queue = PendingEntityDeepLinks::default(); + let pending = queue.enqueue(href); + assert_eq!(queue.first().unwrap().href, canonical); + assert!(queue.acknowledge(&pending.id)); + assert!(queue.first().is_none()); + assert!(canonical_entity_deep_link(&transport, "buzz").is_none()); + assert!( + canonical_entity_deep_link(&transport, "buzz-demo-other-8765432187654321").is_none() + ); + assert!(canonical_entity_deep_link(&Url::parse(canonical).unwrap(), scheme).is_none()); + assert_eq!( + canonical_entity_deep_link(&Url::parse(canonical).unwrap(), "buzz").as_deref(), + Some(canonical) + ); + } + let invalid = Url::parse(&format!("{scheme}://repo?owner=bad&d=repo")).unwrap(); + assert!(canonical_entity_deep_link(&invalid, scheme).is_none()); +} + #[test] fn parse_entity_deep_link_accepts_every_share_link_shape() { let golden = entity_link_golden(); diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index f9f70657698..09154d5237d 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -587,6 +587,10 @@ fn tts_model_slot() -> ModelSlot { .with_expected_sizes(tts_expected_size) } +fn models_dir(nest_dir: PathBuf) -> PathBuf { + nest_dir.join("models") +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -594,18 +598,18 @@ fn tts_model_slot() -> ModelSlot { /// Cheap to clone — all inner state is behind `Arc`. #[derive(Clone)] pub struct ModelManager { - /// `~/.buzz/models/` + /// Model storage under the selected build's nest. models_dir: PathBuf, stt: ModelSlot, tts: ModelSlot, } impl ModelManager { - /// Create a new `ModelManager` rooted at `~/.buzz/models/`. + /// Create a new `ModelManager` rooted in the selected build's nest. /// - /// Returns `None` if the home directory cannot be resolved. + /// Returns `None` if the nest directory cannot be resolved. pub fn new() -> Option { - let models_dir = dirs::home_dir()?.join(".buzz").join("models"); + let models_dir = models_dir(crate::managed_agents::nest_dir()?); let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs index 699ffbe459f..5f70b1f3f3a 100644 --- a/desktop/src-tauri/src/huddle/models_tests.rs +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn voice_models_follow_the_selected_build_nest() { + let home = PathBuf::from("/Users/example"); + for nest_name in [ + ".buzz", + ".buzz-demo-workstream-board", + ".buzz-demo-second-demo", + ] { + let nest = home.join(nest_name); + assert_eq!(models_dir(nest.clone()), nest.join("models")); + } +} + fn create_ready_model_dir(root: &Path) -> PathBuf { let model_dir = root.join(TTS_MODEL_DIR_NAME); std::fs::create_dir_all(&model_dir).expect("create model dir"); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bb9e90525b0..b9837551e43 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod adopt_buzz_data; mod app_menu; mod app_state; mod archive; +mod build_identity; mod builderlab; mod channel_head_cache; mod commands; @@ -126,7 +127,7 @@ pub fn run() { } // Forward any deep link URLs from the duplicate launch. for arg in &argv { - if arg.starts_with("buzz://") { + if crate::build_identity::is_deep_link_for_build(arg) { handle_deep_link_url(app, arg); } } @@ -347,7 +348,10 @@ pub fn run() { // the now-inert ~/.sprout; the frontend dedupes the toast. // Suppressed when a reset completed this boot: the nest was wiped and // a fresh ~/.sprout-less state is exactly what we want. - if !reset_outcome.completed && migration::migrate_legacy_nest() { + if !crate::build_identity::is_demo_build() + && !reset_outcome.completed + && migration::migrate_legacy_nest() + { let _ = app_handle.emit("legacy-nest-migrated", ()); } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index f4c263e3f30..3bcdc12b93a 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -63,12 +63,6 @@ const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli"; /// Nest directory name for production builds. const NEST_DIR_PROD: &str = ".buzz"; -/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data -/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest -/// so that the DMG and dev-build instances don't clobber each other's -/// `.repos-dir` dotfile and `REPOS` symlink. -const NEST_DIR_DEV: &str = ".buzz-dev"; - /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// @@ -88,8 +82,8 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; - let path = dirs::home_dir().map(|h| h.join(suffix)); + let suffix = crate::build_identity::nest_name(is_dev); + let path = dirs::home_dir().map(|h| h.join(suffix.as_ref())); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. let _ = NEST_DIR.set(path); @@ -315,12 +309,8 @@ fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { /// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a /// concurrent dev build each own a separate link and never clobber each other — /// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev). -pub fn cli_link_name(is_dev: bool) -> &'static str { - if is_dev { - "buzz-dev" - } else { - "buzz" - } +pub fn cli_link_name(is_dev: bool) -> String { + crate::build_identity::cli_name(is_dev) } /// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 9aa1eeb0985..7d54c5a7b07 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -7,7 +7,7 @@ fn nest_dir_is_under_home() { // whether init_nest_dir was called before this test ran. let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir must end with .buzz or .buzz-dev, got {dir:?}" ); } @@ -23,7 +23,7 @@ fn init_nest_dir_prod_sets_buzz() { if let Some(d) = dir { let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); assert!( - name == NEST_DIR_PROD || name == NEST_DIR_DEV, + name == NEST_DIR_PROD || name == crate::build_identity::nest_name(true), "nest_dir suffix must be .buzz or .buzz-dev, got {d:?}" ); } @@ -357,13 +357,19 @@ fn ensure_skill_symlinks_skip_dangling_symlink() { } #[test] -fn cli_link_name_prod_is_buzz() { - assert_eq!(cli_link_name(false), "buzz"); +fn cli_link_name_prod_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz".to_string()); + assert_eq!(cli_link_name(false), expected); } #[test] -fn cli_link_name_dev_is_buzz_dev() { - assert_eq!(cli_link_name(true), "buzz-dev"); +fn cli_link_name_dev_follows_build_identity() { + let expected = crate::build_identity::demo_slug() + .map(|slug| format!("buzz-demo-{slug}")) + .unwrap_or_else(|| "buzz-dev".to_string()); + assert_eq!(cli_link_name(true), expected); } #[cfg(unix)] @@ -395,8 +401,8 @@ fn ensure_cli_symlink_creates_symlink_dev() { let local_bin = tmp.path().join("local_bin"); fs::create_dir_all(&local_bin).unwrap(); - // Dev link must be "buzz-dev", never "buzz". - assert_eq!(cli_link_name(true), "buzz-dev"); + // Dev and demo links must never overwrite production's "buzz". + assert_ne!(cli_link_name(true), "buzz"); let link = local_bin.join(cli_link_name(true)); std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index bbb8a8962f3..175deb5a193 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -74,6 +74,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // losing the waking mention, or making every ordinary start replay up // to 15 minutes of already-handled traffic. "BUZZ_ACP_REPLAY_FLOOR", + // Demo-build identity owns the child agent config root. A user override + // could silently reconnect a demo harness to production OAuth state. + "BUZZ_AGENT_CONFIG_DIR", // Desktop ownership markers: these brand every spawned harness with the // launching Desktop instance. A user-supplied override would let a // definition masquerade as a different instance or fake the nonce used diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index e1d5e848e99..faeda2566b7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -515,7 +515,6 @@ pub fn spawn_agent_child( // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); - // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -799,6 +798,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + crate::build_identity::apply_demo_config_home(&mut command)?; // B5: carry persisted effort; harness resolves thought_level configId at first session. // Written AFTER descriptor.env so the canonical persisted value wins over any diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 1c256d54eec..e49c09cbd9b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -129,10 +129,9 @@ pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) { } fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { - // Initialize the process-lifetime nest directory before any filesystem - // operation that calls nest_dir(). The discriminator matches the existing - // pattern used by reconcile_target_dir: dev instances have an app-data-dir - // name starting with CANONICAL_DEV_IDENTIFIER. + // Initialize the process-lifetime nest directory before filesystem access + // that calls nest_dir(). The discriminator matches reconcile_target_dir: + // dev instances have an app-data-dir name starting with CANONICAL_DEV_IDENTIFIER. let is_dev = if let Ok(data_dir) = app.path().app_data_dir() { let dev = data_dir .file_name() @@ -144,18 +143,18 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { false }; - // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE - // control returns to lib.rs where resolve_repos_at_boot() reads it. This - // ensures the dev nest boots with the correct workspace on its first launch, - // matching what the prod nest had configured. Skip-if-dest-exists so it is - // idempotent and never clobbers a value the dev nest already set explicitly. - // Uses the composed helper so gate + migration share the tested code path. + // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev before + // resolve_repos_at_boot() reads it. Skip-if-dest-exists so it is idempotent + // and never clobbers a value the dev nest already set explicitly. + // The composed helper keeps gate + migration on the tested code path. if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } - migrate_legacy_app_data_dir(app); - sync_shared_agent_data(app); + if !crate::build_identity::is_demo_build() { + migrate_legacy_app_data_dir(app); + sync_shared_agent_data(app); + } // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 18ddd80eb8d..401d63c9c49 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -104,6 +104,11 @@ pub(crate) struct ResetContext<'a> { pub keychain: &'a dyn ResetKeychain, pub home_dir: Option, pub is_dev: bool, + /// Build-owned config root for demos. Production leaves this unset. + pub demo_config_dir: Option, + /// Demo builds own only build-scoped state and must never delete shared + /// production or legacy agent roots. + pub is_demo: bool, } /// Entry point called from `lib.rs` setup (before migrations). @@ -126,6 +131,16 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir); let nest_dir = crate::managed_agents::nest_dir(); + let demo_config_dir = match crate::build_identity::demo_config_home() { + Ok(dir) => dir, + Err(error) => { + eprintln!("buzz-desktop reset: {error}"); + return ResetOutcome { + completed: false, + failed: true, + }; + } + }; let ctx = ResetContext { app_data_dir, legacy_app_data_dir: legacy_dir, @@ -133,6 +148,8 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { keychain: &store, home_dir, is_dev, + demo_config_dir, + is_demo: crate::build_identity::is_demo_build(), }; run_boot_reset_with_keychain(ctx) @@ -166,6 +183,15 @@ fn rename_to_trash(src: &Path) -> Result { /// Core wipe logic — separated for testing. pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome { + // An unknown demo credential root is not evidence of an absent root. Refuse + // before any destructive work and retain reset intent for the next boot. + if ctx.is_demo && ctx.demo_config_dir.is_none() { + eprintln!("buzz-desktop reset: cannot resolve demo credential directory"); + return ResetOutcome { + completed: false, + failed: true, + }; + } let app_data_dir = ctx.app_data_dir; // ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ── @@ -211,13 +237,34 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom None }; - // ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ──── + // ── Step 3: remove build-owned nest and CLI symlink ────────────────────── + // Production and dev preserve their existing legacy/global cleanup. A demo + // never owns these shared roots, so signing out of one must leave them + // available to production and every other demo. if let Some(ref nest) = ctx.nest_dir { let _ = std::fs::remove_dir_all(nest); } + // A demo owns credentials here. Failure to remove them must keep the reset + // pending, even if the app data and keychain were successfully wiped. + let demo_config_removed = + ctx.demo_config_dir + .as_ref() + .is_none_or(|path| match std::fs::remove_dir_all(path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + eprintln!( + "buzz-desktop reset: remove demo config {}: {error}", + path.display() + ); + false + } + }); if let Some(ref home) = ctx.home_dir { - let _ = std::fs::remove_dir_all(home.join(".sprout")); - let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + if !ctx.is_demo { + let _ = std::fs::remove_dir_all(home.join(".sprout")); + let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent")); + } let link_name = crate::managed_agents::cli_link_name(ctx.is_dev); let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name)); } @@ -273,6 +320,11 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom .map(|p| !p.exists()) .unwrap_or(true); let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true); + // `exists()` treats metadata errors as absence. Only NotFound establishes + // that credentials are gone; a dangling symlink is not an absent root. + let demo_config_gone = ctx.demo_config_dir.as_ref().is_none_or(|path| { + matches!(std::fs::symlink_metadata(path), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + }); let trash_app_gone = !trash_app.exists(); let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true); let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true); @@ -281,6 +333,8 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom || !app_data_gone || !legacy_gone || !nest_gone + || !demo_config_removed + || !demo_config_gone || !trash_app_gone || !trash_legacy_gone || !trash_webkit_gone @@ -288,6 +342,7 @@ pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcom eprintln!( "buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \ app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \ + demo_config_removed={demo_config_removed}, demo_config_gone={demo_config_gone}, \ trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \ trash_webkit_gone={trash_webkit_gone})" ); @@ -318,6 +373,10 @@ mod tests { use std::cell::Cell; use tempfile::TempDir; + mod demo { + include!("reset_demo_tests.rs"); + } + // ── Fake keychain ───────────────────────────────────────────────────────── struct FakeKeychain { @@ -408,6 +467,8 @@ mod tests { keychain, home_dir: None, // skip nest/sprout/CLI ops in unit tests is_dev, + demo_config_dir: None, + is_demo: false, } } @@ -451,6 +512,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -584,6 +647,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -620,6 +685,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -653,6 +720,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: false, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); @@ -736,6 +805,8 @@ mod tests { keychain: &kc, home_dir: None, is_dev: true, + demo_config_dir: None, + is_demo: false, }; let outcome = run_boot_reset_with_keychain(ctx); assert!(outcome.completed, "reset must complete"); @@ -830,6 +901,8 @@ mod tests { keychain: &kc1, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let first = run_boot_reset_with_keychain(ctx1); assert!(first.failed, "first attempt must fail"); @@ -853,6 +926,8 @@ mod tests { keychain: &kc2, home_dir: Some(tmp.path().to_path_buf()), is_dev: false, + demo_config_dir: None, + is_demo: false, }; let second = run_boot_reset_with_keychain(ctx2); assert!(second.completed, "second attempt must complete"); diff --git a/desktop/src-tauri/src/reset_demo_tests.rs b/desktop/src-tauri/src/reset_demo_tests.rs new file mode 100644 index 00000000000..9db2ab3dc74 --- /dev/null +++ b/desktop/src-tauri/src/reset_demo_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +#[test] +fn test_demo_reset_preserves_shared_and_other_build_state() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let app_data = tmp + .path() + .join("Application Support") + .join("xyz.block.buzz.app.demo.current-1234567812345678"); + let demo_nest = home.join(".buzz-demo-current-1234567812345678"); + let prod_nest = home.join(".buzz"); + let other_demo_nest = home.join(".buzz-demo-other-8765432187654321"); + let shared_sprout = home.join(".sprout"); + let shared_agent = home.join(".config").join("buzz-agent"); + let demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-current-1234567812345678"); + let demo_oauth = demo_config.join("buzz-agent").join("oauth"); + let other_demo_config = home + .join("Library") + .join("Application Support") + .join("buzz-demo-other-8765432187654321"); + let other_demo_oauth = other_demo_config.join("buzz-agent").join("oauth"); + + for path in [ + &app_data, + &demo_nest, + &prod_nest, + &other_demo_nest, + &shared_sprout, + &shared_agent, + &demo_oauth, + &other_demo_oauth, + ] { + std::fs::create_dir_all(path).unwrap(); + } + write_sentinel(&app_data).unwrap(); + + let kc = FakeKeychain::ok(); + let ctx = ResetContext { + app_data_dir: &app_data, + legacy_app_data_dir: None, + nest_dir: Some(demo_nest.clone()), + keychain: &kc, + home_dir: Some(home), + is_dev: false, + demo_config_dir: Some(demo_config.clone()), + is_demo: true, + }; + + let outcome = run_boot_reset_with_keychain(ctx); + + assert!(outcome.completed, "demo reset must complete"); + assert!(!app_data.exists(), "demo app data must be wiped"); + assert!(!demo_nest.exists(), "selected demo nest must be wiped"); + assert!( + !demo_config.exists(), + "selected demo auth root must be wiped" + ); + assert!( + other_demo_oauth.exists(), + "another demo's concrete auth root must survive" + ); + assert!(prod_nest.exists(), "production nest must survive"); + assert!(other_demo_nest.exists(), "another demo nest must survive"); + assert!(shared_sprout.exists(), "shared legacy state must survive"); + assert!( + shared_agent.exists(), + "shared agent auth state must survive" + ); +} + +#[test] +fn demo_config_delete_failure_keeps_sentinel_until_retry() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let production = tmp.path().join("production/oauth/token.json"); + let sibling = tmp.path().join("sibling/oauth/token.json"); + for path in [&production, &sibling] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "preserve").unwrap(); + } + // A file at the directory path makes remove_dir_all fail on every platform, + // independent of the test user's privileges. + std::fs::write(&config, "obstruction").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + let first = run(); + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert!(config.exists()); + + std::fs::remove_file(&config).unwrap(); + let token = config.join("buzz-agent/oauth/databricks/token.json"); + std::fs::create_dir_all(token.parent().unwrap()).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + let second = run(); + assert!(second.completed && !second.failed); + assert!(!check_sentinel(&app_data)); + assert!(!config.exists()); + for path in [&production, &sibling] { + assert_eq!(std::fs::read_to_string(path).unwrap(), "preserve"); + } + // A retry after a crash that already removed the root must also succeed. + write_sentinel(&app_data).unwrap(); + assert!(run().completed); + assert!(!check_sentinel(&app_data)); +} + +#[cfg(unix)] +#[test] +fn demo_oauth_permission_failure_preserves_retry_intent() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let config = tmp.path().join("demo-config"); + let oauth = config.join("buzz-agent/oauth/databricks"); + let token = oauth.join("token.json"); + std::fs::create_dir_all(&oauth).unwrap(); + std::fs::write(&token, "demo credential").unwrap(); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let run = || { + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + ctx.demo_config_dir = Some(config.clone()); + run_boot_reset_with_keychain(ctx) + }; + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o500)).unwrap(); + let first = run(); + // Restore permissions before assertions so a failure never leaves test debris. + if oauth.exists() { + std::fs::set_permissions(&oauth, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + assert!(first.failed && !first.completed); + assert!(check_sentinel(&app_data)); + assert_eq!(std::fs::read_to_string(&token).unwrap(), "demo credential"); + assert!(run().completed); + assert!(!config.exists()); + assert!(!check_sentinel(&app_data)); +} + +#[test] +fn unresolved_demo_config_keeps_reset_pending_without_deleting_state() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let mut ctx = make_ctx(&app_data, &kc, false); + ctx.is_demo = true; + assert!(ctx.demo_config_dir.is_none()); + let outcome = run_boot_reset_with_keychain(ctx); + assert!(outcome.failed && !outcome.completed); + assert!(check_sentinel(&app_data)); + assert!( + app_data.exists(), + "unresolved root must refuse before wiping" + ); +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 14d23a342a9..3506824450d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -281,7 +281,7 @@ with a TypeScript lookup table or an id comparison in a component. refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. -17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. +17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 295c37f23c8..52a58c91343 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -818,7 +818,6 @@ export function AgentConfigFields({ fallbackModel === null && !dependentFieldsDisabled } - keepSelectedModelValueLabel model={dependentFieldsDisabled ? "" : (config.model ?? "")} modelDiscoveryLoading={ dependentFieldsDisabled ? false : modelDiscoveryLoading diff --git a/desktop/src/features/agents/ui/agentConfigControls.tsx b/desktop/src/features/agents/ui/agentConfigControls.tsx index 1a431d1f914..677db669a34 100644 --- a/desktop/src/features/agents/ui/agentConfigControls.tsx +++ b/desktop/src/features/agents/ui/agentConfigControls.tsx @@ -339,7 +339,6 @@ export function AgentModelField({ allowDefaultModel = true, defaultModelLabel, disableSelectDuringDiscovery = true, - keepSelectedModelValueLabel = false, id = "agent-model", isCustomModelEditing, isRequired, @@ -371,8 +370,6 @@ export function AgentModelField({ defaultModelLabel?: string; /** Disable the trigger while live model discovery refreshes the option list. */ disableSelectDuringDiscovery?: boolean; - /** Keep the closed trigger from swapping to discovered display labels. */ - keepSelectedModelValueLabel?: boolean; /** DOM id for the model select. Defaults to `"agent-model"`. Override in * contexts where multiple instances coexist on the same page (e.g. the * global-config settings card) to avoid duplicate DOM ids. */ @@ -513,12 +510,6 @@ export function AgentModelField({ // yields an empty list and discovery has finished, add a disabled sentinel // row so the user sees "No models found" instead of a bare white bar. appendNoModelsSentinel(modelOptions, modelDiscoveryLoading); - const stableSelectedModelLabel = - keepSelectedModelValueLabel && - modelSelectValue === trimmedModel && - trimmedModel.length > 0 - ? trimmedModel - : undefined; // While discovery is in flight with nothing selected, the closed field // reads "Loading models…" instead of a select-prompt — the field isn't // waiting on the user, it's waiting on the harness. @@ -547,7 +538,6 @@ export function AgentModelField({ placeholder={restingPlaceholder} placeholderClassName={placeholderClassName} searchable - selectedLabel={stableSelectedModelLabel} testId={testId ?? id} value={modelSelectValue} /> diff --git a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs index 8d75b71d491..492c1cd6acf 100644 --- a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs +++ b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs @@ -25,10 +25,10 @@ const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8")); // (`_group`) are skipped. Mirrors the Rust corpus filter. const executable = corpus.filter((entry) => entry.expect != null); -test("corpus has exactly 135 executable vectors", () => { +test("corpus has exactly 139 executable vectors", () => { // Locks the vector count so a silent corpus edit can't quietly drop coverage; // must equal the gate in the Rust suite (model_capabilities.rs). - assert.equal(executable.length, 135); + assert.equal(executable.length, 139); }); test("registry label aliases refuse an unprefixed query", () => { @@ -50,6 +50,9 @@ test("UC model-family FQNs and goose- aliases humanize onto their base records", // must resolve onto the same base databricks_v2 records via the new family // tokens. Mirrors the Rust `test_databricks_registry_label_lookup` coverage. const cases = [ + ["goose-claude-4-6-sonnet", "Claude Sonnet 4.6"], + ["goose-claude-4-7-opus", "Claude Opus 4.7"], + ["goose-kimi-2-7", "Kimi 2.7"], ["system.ai.gemini-3-5-flash", "Gemini 3.5 Flash"], ["system.ai.gemini-3-pro-image", "Gemini 3 Pro Image"], ["system.ai.deepseek-v4-pro-0813", "DeepSeek V4 Pro"], @@ -65,6 +68,7 @@ test("UC model-family FQNs and goose- aliases humanize onto their base records", "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", "DeepSeek V4 Flash", ], + ["data_workflow_tools.goose.goose-glm-5-3", "GLM-5.3"], ["data_workflow_tools.goose.goose-glm-5-3-flash", "GLM-5.3 Flash"], ["data_workflow_tools.goose.goose-grok-4-6", "Grok 4.6"], ]; diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 53b09cfd1dd..03fb365ebdd 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -5,6 +5,7 @@ import { ChevronDown } from "lucide-react"; import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; +import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext"; import { useMentions } from "@/features/messages/lib/useMentions"; @@ -101,6 +102,8 @@ export function ForumComposer({ mentions.isMentionOpen || channelLinks.isChannelOpen; const submitMessageRef = React.useRef<() => void>(() => {}); + const formRef = React.useRef(null); + const composerOwnsFocus = useComposerFocusOwnership(formRef); // Set after `useLinkEditor` exists; the editor's link-click handler // delegates through this ref to break the hook ordering cycle. @@ -500,6 +503,7 @@ export function ForumComposer({ }} onFocusCapture={expandCompactComposer} onSubmit={handleSubmit} + ref={formRef} > {media.isDragOver && } {isCompactLayout ? ( @@ -526,6 +530,7 @@ export function ForumComposer({ ? channelLinks.channelSuggestions : [] } + composerOwnsFocus={composerOwnsFocus} mentionSelectedIndex={mentions.mentionSelectedIndex} mentionSuggestions={ mentions.isMentionOpen ? mentions.suggestions : [] diff --git a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx index 149eec7b91c..e3a63e7dd58 100644 --- a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx +++ b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx @@ -8,6 +8,7 @@ import { type ForumComposerAutocompletesProps = { channelSelectedIndex: number; channelSuggestions: ChannelSuggestion[]; + composerOwnsFocus: boolean; mentionSelectedIndex: number; mentionSuggestions: MentionSuggestion[]; onChannelSelect: (suggestion: ChannelSuggestion) => void; @@ -20,6 +21,7 @@ type ForumComposerAutocompletesProps = { export function ForumComposerAutocompletes({ channelSelectedIndex, channelSuggestions, + composerOwnsFocus, mentionSelectedIndex, mentionSuggestions, onChannelSelect, @@ -31,12 +33,14 @@ export function ForumComposerAutocompletes({ return ( <> ", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + Element: dom.window.Element, + Event: dom.window.Event, + FocusEvent: dom.window.FocusEvent, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +async function renderHarness() { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { useComposerFocusOwnership } = await import( + "./useComposerFocusOwnership.ts" + ); + + function Harness() { + const formRef = React.useRef(null); + const ownsFocus = useComposerFocusOwnership(formRef); + return React.createElement( + React.Fragment, + null, + React.createElement( + "form", + { "data-testid": "composer", ref: formRef }, + React.createElement("input", { "aria-label": "Editor" }), + React.createElement("button", { type: "button" }, "Overlay control"), + React.createElement("output", { + "data-testid": "owned", + "data-owned": String(ownsFocus), + }), + ), + React.createElement("input", { "aria-label": "Elsewhere" }), + ); + } + + const view = render(React.createElement(Harness)); + return { + view, + ownership: () => view.getByTestId("owned").getAttribute("data-owned"), + }; +} + +test("tracks focus entering, moving within, and leaving the composer", async () => { + const { act } = await import("react"); + const { view, ownership } = await renderHarness(); + + assert.equal(ownership(), "false"); + + const editor = view.getByRole("textbox", { name: "Editor" }); + await act(async () => editor.focus()); + assert.equal(ownership(), "true"); + + // Focus handed from the editor to an overlay control stays owned — this is + // the transition an editor-focus gate got wrong, unmounting the overlay + // before the control it was handing focus to could receive it. + const control = view.getByRole("button", { name: "Overlay control" }); + await act(async () => control.focus()); + assert.equal(ownership(), "true"); + + const elsewhere = view.getByRole("textbox", { name: "Elsewhere" }); + await act(async () => elsewhere.focus()); + assert.equal(ownership(), "false"); +}); + +test("an internal focus move never reports an unowned intermediate state", async () => { + const React = await import("react"); + const { act } = React; + const { render } = await import("@testing-library/react"); + const { useComposerFocusOwnership } = await import( + "./useComposerFocusOwnership.ts" + ); + + const observed = []; + function Harness() { + const formRef = React.useRef(null); + const ownsFocus = useComposerFocusOwnership(formRef); + observed.push(ownsFocus); + return React.createElement( + "form", + { ref: formRef }, + React.createElement("input", { "aria-label": "Editor" }), + React.createElement("button", { type: "button" }, "Overlay control"), + ); + } + + const view = render(React.createElement(Harness)); + const editor = view.getByRole("textbox", { name: "Editor" }); + const control = view.getByRole("button", { name: "Overlay control" }); + await act(async () => editor.focus()); + observed.length = 0; + + // relatedTarget mirrors a browser handing focus editor → overlay control. + // The focusout handler must read it instead of assuming focus left. + const { fireEvent } = await import("@testing-library/react"); + fireEvent.focusOut(editor, { relatedTarget: control }); + fireEvent.focusIn(control); + + assert.equal(observed.includes(false), false); +}); diff --git a/desktop/src/features/messages/lib/useComposerFocusOwnership.ts b/desktop/src/features/messages/lib/useComposerFocusOwnership.ts new file mode 100644 index 00000000000..f39d618dc03 --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerFocusOwnership.ts @@ -0,0 +1,44 @@ +import * as React from "react"; + +/** + * Tracks whether a composer owns document focus: true while the focused + * element lives anywhere inside `containerRef` (the composer form) — the + * editor or the focusable controls of its suggestion overlays. + * + * This is the value the autocomplete overlays gate their rendering on. It is + * deliberately not the editor's own focus state: an overlay gated on editor + * focus alone unmounts the moment keyboard focus moves from the editor into + * the overlay's controls, which makes those controls unreachable. Ownership + * is tracked with `focusout` + `relatedTarget` containment rather than + * blur/focus pairs so an internal focus move never passes through a false + * state — a false flicker would unmount the overlay before the control it + * is handing focus to receives it. Each composer form has its own instance, + * so focus in one composer never keeps a sibling composer's overlays alive. + */ +export function useComposerFocusOwnership( + containerRef: React.RefObject, +): boolean { + const [ownsFocus, setOwnsFocus] = React.useState(false); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + setOwnsFocus(container.contains(document.activeElement)); + const handleFocusIn = () => setOwnsFocus(true); + const handleFocusOut = (event: FocusEvent) => { + setOwnsFocus( + event.relatedTarget instanceof Node && + container.contains(event.relatedTarget), + ); + }; + container.addEventListener("focusin", handleFocusIn); + container.addEventListener("focusout", handleFocusOut); + return () => { + container.removeEventListener("focusin", handleFocusIn); + container.removeEventListener("focusout", handleFocusOut); + }; + }, [containerRef]); + + return ownsFocus; +} diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 57e458bc911..21a0a102350 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -238,8 +238,11 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { return { handled: true }; } + // Forward Tab selects; Shift+Tab deliberately does not. The reverse + // move stays the browser's, so this overlay can't swallow a keyboard + // user's way back out (see useMentions for the same split). if ( - event.key === "Tab" || + (event.key === "Tab" && !event.shiftKey) || (event.key === "Enter" && !event.ctrlKey && !event.metaKey && diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 74365e76292..4abee923c8d 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -728,9 +728,13 @@ export function useMentions( ); return { handled: true }; } + // Shift+Tab is deliberately not a select: it is the keyboard route out + // of the editor — into this overlay's Options controls where the + // composer offers them, otherwise the browser's own backward focus + // move — so those controls stay reachable. if ( exactMentionSpace || - event.key === "Tab" || + (event.key === "Tab" && !event.shiftKey) || (event.key === "Enter" && !event.ctrlKey && !event.metaKey && diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index e3e17071fad..2f00531820d 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -167,16 +167,12 @@ export function useRichTextEditor({ const addressedAgentMentionNamesRef = React.useRef([]); const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; - const onSubmitRef = React.useRef(onSubmit); onSubmitRef.current = onSubmit; - const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; - const onEditLinkRef = React.useRef(onEditLink); onEditLinkRef.current = onEditLink; - const onLinkSelectionChangeRef = React.useRef(onLinkSelectionChange); onLinkSelectionChangeRef.current = onLinkSelectionChange; @@ -618,13 +614,19 @@ export function useRichTextEditor({ const hadFocusBeforeDisableRef = React.useRef(false); React.useEffect(() => { if (!editor || editor.isEditable === editable) return; + // `emitUpdate: false` on both toggles — the doc hasn't changed, so the + // default synthetic `update` event would replay `onUpdate` with stale + // text/cursor and resurrect consumer state derived from it (e.g. reopen + // a mention menu the user dismissed with Escape, or re-fire a typing + // notification for an untouched draft). Real content changes (typing, + // clearContent) dispatch real transactions that emit their own updates. if (!editable) { // About to disable: remember whether we currently hold focus so we know // whether to restore it when re-enabled. hadFocusBeforeDisableRef.current = editor.isFocused; - editor.setEditable(false); + editor.setEditable(false, false); } else { - editor.setEditable(true); + editor.setEditable(true, false); // Re-enabled: if we owned focus before the disable blurred us, take it // back (preserving the current selection — `focus()` with no arg keeps // the existing selection rather than jumping to the end). diff --git a/desktop/src/features/messages/ui/ChannelAutocomplete.tsx b/desktop/src/features/messages/ui/ChannelAutocomplete.tsx index 1305919a1c9..5c759a8a693 100644 --- a/desktop/src/features/messages/ui/ChannelAutocomplete.tsx +++ b/desktop/src/features/messages/ui/ChannelAutocomplete.tsx @@ -12,6 +12,11 @@ import { type ChannelAutocompleteProps = { suggestions: ChannelSuggestion[]; selectedIndex: number; + /** + * Whether the owning composer owns document focus. Composers that don't + * must not render suggestions — see MentionAutocomplete for the rationale. + */ + composerOwnsFocus: boolean; onSelect: (suggestion: ChannelSuggestion) => void; position?: "above" | "below"; }; @@ -19,6 +24,7 @@ type ChannelAutocompleteProps = { export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ suggestions, selectedIndex, + composerOwnsFocus, onSelect, position = "above", }: ChannelAutocompleteProps) { @@ -31,7 +37,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ activeItem?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); - if (suggestions.length === 0) { + if (!composerOwnsFocus || suggestions.length === 0) { return null; } @@ -42,6 +48,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ position === "below" ? "top-full mt-1" : "bottom-full mb-1", )} > + {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, no behavior of its own — an unprevented mousedown here (scrollbar, padding ring) blurs the editor, and the focus gate above would unmount the overlay mid-press. */}
event.preventDefault()} ref={listRef} style={POPOVER_SHADOW_STYLE} > diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index ae39931334e..30bfe196e3f 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -195,7 +195,10 @@ export function ComposerMentionButton({ data-testid="message-insert-mention" disabled={disabled} onClick={onOpen} - onMouseDown={onCaptureSelection} + onMouseDown={(event) => { + onCaptureSelection(); + event.preventDefault(); + }} type="button" >