Repository: cadenkoj/dtim
Pull request #19: feat(db): rough database implementation
Author: login: cadenkoj
Created: 2025-05-22T02:39:33Z | Merged: 2025-05-22T04:38:33Z

### TL;DR

Added PostgreSQL database integration with Diesel ORM for persistent storage of threat indicators.

### What changed?

- Integrated Diesel ORM with PostgreSQL for persistent storage
- Added database migrations for encrypted indicators table
- Modified the `Node` class to store indicators in the database instead of in-memory
- Updated the symmetric key manager to store keys on disk
- Added UUID v5 support for deterministic indicator IDs based on content
- Refactored the `EncryptedThreatIndicator` to use raw bytes instead of base64 strings
- Updated environment variables to support Diesel CLI

### How to test?

1. Copy the new environment variables from `.env.example` to your `.env` file
2. Ensure PostgreSQL is running and accessible with the configured credentials
3. Run `diesel setup` to create the database
4. Run `diesel migration run` to apply the migrations
5. Start the application and verify indicators are persisted between restarts

### Why make this change?

This change moves the application from an in-memory storage model to a persistent database, allowing threat indicators to survive application restarts. Using Diesel ORM provides type-safe database interactions and migrations for schema management. The deterministic UUID generation ensures consistent IDs for the same indicator content, which helps with deduplication and referential integrity.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced persistent encrypted storage of threat indicators in a PostgreSQL database, with automatic encryption and decryption.
  - Added support for deterministic UUID generation for indicators based on their content.
  - Implemented file-based symmetric key management with key rotation and persistence.
  - Added new API endpoints and improved error handling for database operations.

- **Refactor**
  - Migrated from in-memory to database-backed storage for indicators.
  - Unified encryption/decryption logic and data models.
  - Updated UUID generation methods and indicator creation flow.

- **Bug Fixes**
  - Improved error propagation and handling in API and encryption routines.

- **Chores**
  - Updated environment variable configuration and added database migration scripts.
  - Added and reorganized database, schema, and model modules for better maintainability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/.env.example b/.env.example
index c7eec65..a48cb17 100644
--- a/.env.example
+++ b/.env.example
@@ -4,5 +4,7 @@ DTIM__DEFAULT__PORT=3030
 DTIM__DEFAULT__LOG_LEVEL="info"
 
 # Sensitive info (.env only)
-DTIM__DEFAULT__STORAGE__DATABASE_URL="postgres://user:pass@localhost/db"
-DTIM__DEFAULT__WATCHERS__VIRUSTOTAL_API_KEY="your_api_key"
\ No newline at end of file
+DATABASE_URL="postgres://postgres:@localhost:5432/postgres" # For Diesel CLI
+
+DTIM__DEFAULT__STORAGE__DATABASE_URL=${DATABASE_URL}
+DTIM__DEFAULT__WATCHERS__VIRUSTOTAL_API_KEY="your_api_key"
diff --git a/Cargo.lock b/Cargo.lock
index 2d89324..409fb19 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -340,6 +340,12 @@ version = "3.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
 
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
 [[package]]
 name = "bytes"
 version = "1.10.1"
@@ -600,6 +606,43 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "diesel"
+version = "2.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff3e1edb1f37b4953dd5176916347289ed43d7119cc2e6c7c3f7849ff44ea506"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "chrono",
+ "diesel_derives",
+ "itoa",
+ "pq-sys",
+ "r2d2",
+]
+
+[[package]]
+name = "diesel_derives"
+version = "2.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68d4216021b3ea446fd2047f5c8f8fe6e98af34508a254a01e4d6bc1e844f84d"
+dependencies = [
+ "diesel_table_macro_syntax",
+ "dsl_auto_type",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "diesel_table_macro_syntax"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25"
+dependencies = [
+ "syn",
+]
+
 [[package]]
 name = "digest"
 version = "0.10.7"
@@ -625,6 +668,20 @@ version = "0.15.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
 
+[[package]]
+name = "dsl_auto_type"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b"
+dependencies = [
+ "darling",
+ "either",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "dtim"
 version = "0.1.0"
@@ -636,6 +693,7 @@ dependencies = [
  "base64 0.22.1",
  "chrono",
  "config",
+ "diesel",
  "dotenvy",
  "ed25519-dalek",
  "env_logger",
@@ -925,6 +983,12 @@ dependencies = [
  "hashbrown 0.15.3",
 ]
 
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
 [[package]]
 name = "hex"
 version = "0.4.3"
@@ -1444,6 +1508,16 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
 
+[[package]]
+name = "pq-sys"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41c852911b98f5981956037b2ca976660612e548986c30af075e753107bc3400"
+dependencies = [
+ "libc",
+ "vcpkg",
+]
+
 [[package]]
 name = "prettyplease"
 version = "0.2.32"
@@ -1478,6 +1552,17 @@ version = "5.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
 
+[[package]]
+name = "r2d2"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
+dependencies = [
+ "log",
+ "parking_lot",
+ "scheduled-thread-pool",
+]
+
 [[package]]
 name = "rand_core"
 version = "0.6.4"
@@ -1653,6 +1738,15 @@ version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
 
+[[package]]
+name = "scheduled-thread-pool"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
+dependencies = [
+ "parking_lot",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.2.0"
@@ -1759,6 +1853,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
 [[package]]
 name = "sha2"
 version = "0.10.9"
@@ -2112,8 +2212,15 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
 dependencies = [
  "getrandom 0.3.3",
  "serde",
+ "sha1_smol",
 ]
 
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
 [[package]]
 name = "version_check"
 version = "0.9.5"
diff --git a/Cargo.toml b/Cargo.toml
index dc1789d..b1eaed1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ default-run = "dtim"
 axum = { version = "0.8", features = ["http2", "tokio"] }
 axum-server = { version = "0.7", features = ["tls-rustls"] }
 chrono = { version = "0.4", features = ["serde"] }
-uuid = { version = "1.16", features = ["v7", "serde"] }
+uuid = { version = "1.16", features = ["v5", "v7", "serde"] }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 serde_with = "3.12"
@@ -28,3 +28,4 @@ async-trait = "0.1"
 http-body-util = "0.1"
 dotenvy = "0.15"
 once_cell = "1.21"
+diesel = { version = "2.2.0", features = ["postgres", "r2d2", "chrono"] }
diff --git a/diesel.toml b/diesel.toml
new file mode 100644
index 0000000..6623fad
--- /dev/null
+++ b/diesel.toml
@@ -0,0 +1,9 @@
+# For documentation on how to configure this file,
+# see https://diesel.rs/guides/configuring-diesel-cli
+
+[print_schema]
+file = "src/db/schema.rs"
+custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
+
+[migrations_directory]
+dir = "/Users/caden/Developer/dtim/migrations"
diff --git a/migrations/.keep b/migrations/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql
new file mode 100644
index 0000000..a9f5260
--- /dev/null
+++ b/migrations/00000000000000_diesel_initial_setup/down.sql
@@ -0,0 +1,6 @@
+-- This file was automatically created by Diesel to setup helper functions
+-- and other internal bookkeeping. This file is safe to edit, any future
+-- changes will be added to existing projects as new migrations.
+
+DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
+DROP FUNCTION IF EXISTS diesel_set_updated_at();
diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql
new file mode 100644
index 0000000..d68895b
--- /dev/null
+++ b/migrations/00000000000000_diesel_initial_setup/up.sql
@@ -0,0 +1,36 @@
+-- This file was automatically created by Diesel to setup helper functions
+-- and other internal bookkeeping. This file is safe to edit, any future
+-- changes will be added to existing projects as new migrations.
+
+
+
+
+-- Sets up a trigger for the given table to automatically set a column called
+-- `updated_at` whenever the row is modified (unless `updated_at` was included
+-- in the modified columns)
+--
+-- # Example
+--
+-- ```sql
+-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
+--
+-- SELECT diesel_manage_updated_at('users');
+-- ```
+CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
+BEGIN
+    EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
+                    FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
+BEGIN
+    IF (
+        NEW IS DISTINCT FROM OLD AND
+        NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
+    ) THEN
+        NEW.updated_at := current_timestamp;
+    END IF;
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/2025-05-21-234457_create_encrypted_indicators/down.sql b/migrations/2025-05-21-234457_create_encrypted_indicators/down.sql
new file mode 100644
index 0000000..2befc1b
--- /dev/null
+++ b/migrations/2025-05-21-234457_create_encrypted_indicators/down.sql
@@ -0,0 +1 @@
+DROP TABLE encrypted_indicators;
diff --git a/migrations/2025-05-21-234457_create_encrypted_indicators/up.sql b/migrations/2025-05-21-234457_create_encrypted_indicators/up.sql
new file mode 100644
index 0000000..b5a7574
--- /dev/null
+++ b/migrations/2025-05-21-234457_create_encrypted_indicators/up.sql
@@ -0,0 +1,7 @@
+CREATE TABLE encrypted_indicators (
+    id CHAR(64) PRIMARY KEY,
+    ciphertext BYTEA NOT NULL,
+    nonce BYTEA NOT NULL,
+    mac BYTEA NOT NULL,
+    tlp_level TEXT NOT NULL
+);
diff --git a/src/api.rs b/src/api.rs
index b2732a6..2a1d0d7 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -13,19 +13,17 @@ use ed25519_dalek::VerifyingKey;
 use http_body_util::BodyExt as _;
 use rustls::ServerConfig;
 use serde::Serialize;
-use std::{str::FromStr as _, sync::Arc};
+use std::sync::Arc;
 use tokio::sync::Mutex;
 
+use crate::models::ThreatIndicator;
 use crate::{
     crypto::MeshIdentity,
+    db::models::EncryptedIndicator,
     error::{ApiError, ApiErrorResponse},
     node::{Node, NodePeer},
 };
 use crate::{crypto::SymmetricKeyManager, models::StixBundle};
-use crate::{
-    models::{EncryptedThreatIndicator, ThreatIndicator},
-    uuid::Uuid,
-};
 
 #[derive(Clone)]
 pub struct AppState {
@@ -200,13 +198,14 @@ struct GossipIndicatorsResponse {
 
 async fn gossip_indicators_handler(
     State(state): State<Arc<AppState>>,
-    Json(indicators): Json<Vec<EncryptedThreatIndicator>>,
+    Json(indicators): Json<Vec<EncryptedIndicator>>,
 ) -> ApiResponse<GossipIndicatorsResponse> {
     let mut node = state.node.lock().await;
     let mut count = 0;
     for encrypted in indicators {
         if let Ok(indicator) = ThreatIndicator::decrypt(&encrypted, &state.key_mgr) {
-            node.add_or_increment_indicator(indicator);
+            node.add_or_increment_indicator(indicator)
+                .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
             count += 1;
         }
     }
@@ -229,7 +228,9 @@ async fn get_public_indicators_handler(
     State(state): State<Arc<AppState>>,
 ) -> ApiResponse<GetIndicatorsResponse> {
     let node = state.node.lock().await;
-    let indicators = node.list_indicators_by_tlp(crate::models::TlpLevel::White);
+    let indicators = node
+        .list_indicators_by_tlp(crate::models::TlpLevel::White)
+        .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
     // Return anonymized (open) view
     Ok((
         StatusCode::OK,
@@ -244,7 +245,9 @@ async fn get_private_indicators_handler(
     State(state): State<Arc<AppState>>,
 ) -> ApiResponse<GetIndicatorsResponse> {
     let node = state.node.lock().await;
-    let indicators = node.list_indicators_by_tlp(crate::models::TlpLevel::Red);
+    let indicators = node
+        .list_indicators_by_tlp(crate::models::TlpLevel::Red)
+        .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
     // TODO: Ensure the node is an authenticated recipient for private indicators
     // Return anonymized (moderate) view
     Ok((
@@ -261,8 +264,9 @@ async fn get_indicator_by_id_handler(
     Path(id): Path<String>,
 ) -> ApiResponse<serde_json::Value> {
     let node = state.node.lock().await;
-    let id = Uuid::from_str(&id).map_err(|_| ApiError::INVALID_INDICATOR_ID)?;
-    let indicator = node.get_indicator_by_id(&id).ok_or(ApiError::NOT_FOUND)?;
+    let indicator = node
+        .get_indicator_by_id(&id)
+        .map_err(|_| ApiError::NOT_FOUND)?;
     Ok((StatusCode::OK, Json(indicator.to_json(node.get_level()))))
 }
 
@@ -319,7 +323,12 @@ async fn taxii_get_objects_handler(
     Path(_collection_id): Path<String>,
 ) -> ApiResponse<serde_json::Value> {
     let node = state.node.lock().await;
-    let stix_objects = node.list_objects_by_tlp(crate::models::TlpLevel::White);
+    let stix_objects = node
+        .list_objects_by_tlp(crate::models::TlpLevel::White)
+        .map_err(|error| {
+            println!("Error: {:?}", error);
+            ApiError::INTERNAL_SERVER_ERROR
+        })?;
     let bundle = StixBundle::new(stix_objects);
     Ok((StatusCode::OK, Json(bundle.to_stix())))
 }
@@ -331,6 +340,7 @@ async fn taxii_post_objects_handler(
 ) -> ApiResponse<serde_json::Value> {
     let indicator = ThreatIndicator::from_stix(stix).map_err(|_| ApiError::INVALID_STIX_OBJECT)?;
     let mut node = state.node.lock().await;
-    node.add_indicator(indicator);
+    node.add_indicator(indicator)
+        .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
     Ok((StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))))
 }
diff --git a/src/crypto/symmetric_key_manager.rs b/src/crypto/symmetric_key_manager.rs
index 4265314..18ab889 100644
--- a/src/crypto/symmetric_key_manager.rs
+++ b/src/crypto/symmetric_key_manager.rs
@@ -1,9 +1,16 @@
 use aes_gcm::aead::rand_core::RngCore;
 use aes_gcm::aead::{Aead, KeyInit, OsRng};
 use aes_gcm::{Aes256Gcm, Key, Nonce};
-use base64::prelude::*;
+use std::fs::{self, File};
+use std::io::{Read, Write};
+use std::path::Path;
 use std::time::{SystemTime, UNIX_EPOCH};
 
+const SYMM_KEY_PATH: &str = "data/keys/symm.key";
+const SYMM_PREV_KEY_PATH: &str = "data/keys/symm_prev.key";
+
+pub type EncryptedData = (Vec<u8>, Vec<u8>, Vec<u8>);
+
 #[derive(Clone, Debug)]
 pub struct SymmetricKeyManager {
     current_key: Key<Aes256Gcm>,
@@ -13,18 +20,40 @@ pub struct SymmetricKeyManager {
 }
 
 impl SymmetricKeyManager {
-    pub fn new(rotation_days: u64) -> Self {
-        let current_key = Self::generate_key();
+    pub fn load_or_generate(rotation_days: u64) -> std::io::Result<Self> {
+        fs::create_dir_all("data/keys")?;
+
+        let mut current_bytes = [0u8; 32];
+        let mut prev_bytes = [0u8; 32];
+
+        let current_key = if Path::new(SYMM_KEY_PATH).exists() {
+            File::open(SYMM_KEY_PATH)?.read_exact(&mut current_bytes)?;
+            Key::<Aes256Gcm>::from_slice(&current_bytes).to_owned()
+        } else {
+            let key = Self::generate_key();
+            let mut file = File::create(SYMM_KEY_PATH)?;
+            file.write_all(key.as_slice())?;
+            key
+        };
+
+        let previous_key = if Path::new(SYMM_PREV_KEY_PATH).exists() {
+            File::open(SYMM_PREV_KEY_PATH)?.read_exact(&mut prev_bytes)?;
+            Some(Key::<Aes256Gcm>::from_slice(&prev_bytes).to_owned())
+        } else {
+            None
+        };
+
         let key_rotation_time = SystemTime::now()
             .duration_since(UNIX_EPOCH)
             .unwrap()
             .as_secs();
-        Self {
+
+        Ok(Self {
             current_key,
-            previous_key: None,
+            previous_key,
             key_rotation_time,
             rotation_interval: rotation_days * 24 * 60 * 60,
-        }
+        })
     }
 
     fn generate_key() -> Key<Aes256Gcm> {
@@ -33,7 +62,21 @@ impl SymmetricKeyManager {
         Key::<Aes256Gcm>::from_slice(&key_bytes).to_owned()
     }
 
-    pub fn rotate_key(&mut self) {
+    pub fn save_keys(&self) -> std::io::Result<()> {
+        fs::create_dir_all("data/keys")?;
+        let mut file = File::create(SYMM_KEY_PATH)?;
+        file.write_all(self.current_key.as_slice())?;
+
+        if let Some(prev) = &self.previous_key {
+            let mut prev_file = File::create(SYMM_PREV_KEY_PATH)?;
+            prev_file.write_all(prev.as_slice())?;
+        } else if Path::new(SYMM_PREV_KEY_PATH).exists() {
+            fs::remove_file(SYMM_PREV_KEY_PATH)?;
+        }
+        Ok(())
+    }
+
+    pub fn rotate_key(&mut self) -> std::io::Result<()> {
         let now = SystemTime::now()
             .duration_since(UNIX_EPOCH)
             .unwrap()
@@ -42,11 +85,13 @@ impl SymmetricKeyManager {
             self.previous_key = Some(self.current_key);
             self.current_key = Self::generate_key();
             self.key_rotation_time = now;
+            self.save_keys()?;
         }
+        Ok(())
     }
 
-    pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<(String, String, String), String> {
-        self.rotate_key();
+    pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<EncryptedData, std::io::Error> {
+        self.rotate_key()?;
         let cipher = Aes256Gcm::new(&self.current_key);
         let mut nonce_bytes = [0u8; 12];
         OsRng.fill_bytes(&mut nonce_bytes);
@@ -54,33 +99,20 @@ impl SymmetricKeyManager {
         match cipher.encrypt(nonce, plaintext) {
             Ok(mut ciphertext_and_tag) => {
                 let tag = ciphertext_and_tag.split_off(ciphertext_and_tag.len() - 16);
-                Ok((
-                    BASE64_STANDARD.encode(ciphertext_and_tag),
-                    BASE64_STANDARD.encode(nonce_bytes),
-                    BASE64_STANDARD.encode(tag),
-                ))
+                Ok((ciphertext_and_tag, nonce_bytes.to_vec(), tag))
             }
-            Err(_) => Err("Encryption failure".to_string()),
+            Err(_) => Err(std::io::Error::other("Encryption failure")),
         }
     }
 
     pub fn decrypt(
         &self,
-        ciphertext_b64: &str,
-        nonce_b64: &str,
-        tag_b64: &str,
+        mut ciphertext: Vec<u8>,
+        nonce: Vec<u8>,
+        tag: Vec<u8>,
     ) -> Result<Vec<u8>, String> {
-        let mut ciphertext = BASE64_STANDARD
-            .decode(ciphertext_b64)
-            .map_err(|_| "Invalid base64 ciphertext")?;
-        let nonce_bytes = BASE64_STANDARD
-            .decode(nonce_b64)
-            .map_err(|_| "Invalid base64 nonce")?;
-        let tag = BASE64_STANDARD
-            .decode(tag_b64)
-            .map_err(|_| "Invalid base64 tag")?;
         ciphertext.extend_from_slice(&tag);
-        let nonce = Nonce::from_slice(&nonce_bytes);
+        let nonce = Nonce::from_slice(&nonce);
         let try_decrypt = |key: &Key<Aes256Gcm>| {
             let cipher = Aes256Gcm::new(key);
             cipher
diff --git a/src/db/connection.rs b/src/db/connection.rs
new file mode 100644
index 0000000..003c5cc
--- /dev/null
+++ b/src/db/connection.rs
@@ -0,0 +1,13 @@
+use diesel::{
+    pg::PgConnection,
+    r2d2::{ConnectionManager, Pool},
+};
+use std::error::Error;
+
+pub fn get_connection_pool(
+    conn_str: &str,
+) -> Result<Pool<ConnectionManager<PgConnection>>, Box<dyn Error + Send + Sync>> {
+    let manager = ConnectionManager::<PgConnection>::new(conn_str);
+    let pool = Pool::builder().build(manager)?;
+    Ok(pool)
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
new file mode 100644
index 0000000..e57d22b
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1,5 @@
+pub mod connection;
+pub mod models;
+pub mod schema;
+
+pub use connection::get_connection_pool;
diff --git a/src/db/models.rs b/src/db/models.rs
new file mode 100644
index 0000000..cbf6388
--- /dev/null
+++ b/src/db/models.rs
@@ -0,0 +1,14 @@
+use crate::db::schema::encrypted_indicators;
+use diesel::prelude::*;
+use diesel::Selectable;
+use serde::{Deserialize, Serialize};
+
+#[derive(Queryable, Insertable, Selectable, Serialize, Deserialize, Debug)]
+#[diesel(table_name = encrypted_indicators)]
+pub struct EncryptedIndicator {
+    pub id: String,
+    pub ciphertext: Vec<u8>,
+    pub nonce: Vec<u8>,
+    pub mac: Vec<u8>,
+    pub tlp_level: String,
+}
diff --git a/src/db/schema.rs b/src/db/schema.rs
new file mode 100644
index 0000000..5df65f8
--- /dev/null
+++ b/src/db/schema.rs
@@ -0,0 +1,12 @@
+// @generated automatically by Diesel CLI.
+
+diesel::table! {
+    encrypted_indicators (id) {
+        #[max_length = 64]
+        id -> Bpchar,
+        ciphertext -> Bytea,
+        nonce -> Bytea,
+        mac -> Bytea,
+        tlp_level -> Text,
+    }
+}
diff --git a/src/logging.rs b/src/logging.rs
index 9a46c47..9b48692 100644
--- a/src/logging.rs
+++ b/src/logging.rs
@@ -1,3 +1,5 @@
+use base64::prelude::BASE64_STANDARD;
+use base64::Engine as _;
 use chrono::Utc;
 use log::{error, Level, LevelFilter, Metadata, Record};
 use std::fs::{self, OpenOptions};
@@ -37,7 +39,12 @@ impl EncryptedLogger {
             .key_mgr
             .encrypt(log_entry.as_bytes())
             .map_err(|e| std::io::Error::other(format!("Encryption failed: {}", e)))?;
-        let encrypted_entry = format!("{}\n{}\n{}\n", ciphertext, nonce, mac);
+        let encrypted_entry = format!(
+            "{}\n{}\n{}\n",
+            BASE64_STANDARD.encode(ciphertext),
+            BASE64_STANDARD.encode(nonce),
+            BASE64_STANDARD.encode(mac)
+        );
 
         let filename = format!("{}.log", Utc::now().format("%Y-%m-%d"));
         let log_file = self.log_path.join(filename);
@@ -66,15 +73,24 @@ impl EncryptedLogger {
 
         let mut lines = content.lines().peekable();
         while lines.peek().is_some() {
-            let ciphertext = lines.next().ok_or_else(|| {
-                std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid log format")
-            })?;
-            let nonce = lines.next().ok_or_else(|| {
-                std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid log format")
-            })?;
-            let mac = lines.next().ok_or_else(|| {
-                std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid log format")
-            })?;
+            let ciphertext = lines
+                .next()
+                .and_then(|line| BASE64_STANDARD.decode(line).ok())
+                .ok_or_else(|| {
+                    std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid log format")
+                })?;
+            let nonce = lines
+                .next()
+                .and_then(|line| BASE64_STANDARD.decode(line).ok())
+                .ok_or_else(|| {
+                    std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid log format")
+                })?;
+            let mac = lines
+                .next()
+                .and_then(|line| BASE64_STANDARD.decode(line).ok())
+                .ok_or_else(|| {
+                    std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid log format")
+                })?;
 
             match self.key_mgr.decrypt(ciphertext, nonce, mac) {
                 Ok(decrypted) => {
diff --git a/src/main.rs b/src/main.rs
index 34f317d..a085b99 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 mod api;
 mod crypto;
+mod db;
 mod error;
 mod logging;
 mod models;
@@ -76,7 +77,8 @@ fn make_server_config(settings: &settings::Settings) -> Arc<rustls::ServerConfig
 async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
     init_logging();
     let settings = Settings::new()?;
-    let mut key_mgr = crypto::SymmetricKeyManager::new(settings.tls.key_rotation_days);
+    let mut key_mgr =
+        crypto::SymmetricKeyManager::load_or_generate(settings.tls.key_rotation_days)?;
     let tls_config = make_server_config(&settings);
 
     let logger = logging::EncryptedLogger::new(
@@ -91,7 +93,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
         }),
     )?;
 
-    let node = node::Node::new(logger, settings.privacy)?;
+    println!("Database URL: {:?}", settings.storage.database_url);
+    let db_pool = db::get_connection_pool(&settings.storage.database_url)?;
+    let node = node::Node::new(db_pool, key_mgr.clone(), logger, settings.privacy)?;
 
     let id = node.get_id();
     println!("Node ID: {:?}", id);
@@ -130,19 +134,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
         vec!["malicious-activity".to_string()],
         models::TlpLevel::White,
         None,
+    )
+    .unwrap();
+
+    let encrypted = indicator.encrypt(&mut key_mgr).unwrap();
+    println!(
+        "Encrypted: {:?}",
+        serde_json::to_string(&encrypted).unwrap()
     );
 
-    let encrypted = indicator.encrypt(&mut key_mgr);
-    println!("Encrypted: {:?}", encrypted);
+    let _ = key_mgr.rotate_key();
 
-    let decrypted = ThreatIndicator::decrypt(&encrypted.unwrap(), &key_mgr);
+    let decrypted = ThreatIndicator::decrypt(&encrypted, &key_mgr).unwrap();
     println!("Decrypted: {:?}", decrypted);
 
     let node = Arc::new(Mutex::new(node));
 
     {
         let mut node = node.lock().await;
-        node.add_indicator(indicator.clone());
+        let _ = node.add_or_increment_indicator(indicator.clone());
         node.bootstrap_peers(settings.network.init_peers.clone());
     }
 
diff --git a/src/models.rs b/src/models.rs
index 553db92..ab3c4ba 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -4,11 +4,12 @@ use std::{
     net::IpAddr,
 };
 
-use crate::{crypto::SymmetricKeyManager, uuid::Uuid};
+use crate::{crypto::SymmetricKeyManager, db::models::EncryptedIndicator, uuid::Uuid};
 use chrono::{DateTime, Utc};
 use regex::Regex;
 use serde::{Deserialize, Serialize};
 use serde_json::json;
+use sha2::{Digest, Sha256};
 use std::hash::{Hash, Hasher};
 
 #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -27,13 +28,6 @@ pub struct ThreatIndicator {
     pub marking_definitions: Vec<MarkingDefinition>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug)]
-pub struct EncryptedThreatIndicator {
-    ciphertext: String,
-    nonce: String,
-    mac: String,
-}
-
 impl ThreatIndicator {
     pub fn new(
         indicator_type: IndicatorType,
@@ -42,12 +36,14 @@ impl ThreatIndicator {
         tags: Vec<String>,
         tlp: TlpLevel,
         custom_fields: Option<HashMap<String, serde_json::Value>>,
-    ) -> Self {
+    ) -> Result<Self, std::io::Error> {
+        let id = Self::compute_id(&indicator_type, &value, &tlp, &tags)
+            .map_err(|e| std::io::Error::other(format!("Failed to compute ID: {}", e)))?;
         let now = Utc::now();
         let marking_definition = MarkingDefinition::new("tlp".to_string(), tlp.to_string());
 
-        ThreatIndicator {
-            id: Uuid::new_v7_from_datetime(now),
+        Ok(ThreatIndicator {
+            id,
             indicator_type,
             value,
             confidence,
@@ -59,35 +55,58 @@ impl ThreatIndicator {
             recipients: None, // TODO: Handle recipients for TLP:RED
             custom_fields,
             marking_definitions: vec![marking_definition],
-        }
+        })
+    }
+
+    pub fn get_id(&self) -> String {
+        self.id.to_string()
     }
 
-    pub fn get_id(&self) -> Uuid {
-        self.id
+    pub fn compute_id(
+        indicator_type: &IndicatorType,
+        value: &str,
+        tlp: &TlpLevel,
+        tags: &[String],
+    ) -> Result<Uuid, std::io::Error> {
+        let mut hasher = Sha256::new();
+        hasher.update(indicator_type.to_string().as_bytes());
+        hasher.update(value.as_bytes());
+        hasher.update(tlp.to_string().as_bytes());
+        for tag in tags {
+            hasher.update(tag.as_bytes());
+        }
+        let hash = hasher.finalize();
+        Ok(Uuid::new_v5_from_hash(&hash))
     }
 
     pub fn encrypt(
         &self,
         key_mgr: &mut SymmetricKeyManager,
-    ) -> Result<EncryptedThreatIndicator, std::io::Error> {
+    ) -> Result<EncryptedIndicator, std::io::Error> {
         let serialized = serde_json::to_vec(self).expect("Failed to serialize ThreatIndicator");
         let (ciphertext, nonce, mac) = key_mgr
             .encrypt(&serialized)
             .map_err(|e| std::io::Error::other(format!("Encryption failed: {}", e)))?;
 
-        Ok(EncryptedThreatIndicator {
+        Ok(EncryptedIndicator {
+            id: self.id.to_string(),
             ciphertext,
             nonce,
             mac,
+            tlp_level: self.tlp.to_string(),
         })
     }
 
     pub fn decrypt(
-        encrypted: &EncryptedThreatIndicator,
+        encrypted: &EncryptedIndicator,
         key_mgr: &SymmetricKeyManager,
     ) -> Result<Self, String> {
         let decrypted = key_mgr
-            .decrypt(&encrypted.ciphertext, &encrypted.nonce, &encrypted.mac)
+            .decrypt(
+                encrypted.ciphertext.clone(),
+                encrypted.nonce.clone(),
+                encrypted.mac.clone(),
+            )
             .map_err(|e| format!("Decryption failed: {}", e))?;
 
         serde_json::from_slice(&decrypted)
@@ -264,7 +283,7 @@ impl MarkingDefinition {
         let now = Utc::now();
 
         MarkingDefinition {
-            id: Uuid::new_v7_from_datetime(now),
+            id: Uuid::now_v7(),
             created_at: now,
             definition_type: definition_type.clone(),
             definition: json!({
diff --git a/src/node.rs b/src/node.rs
index c3050c1..8965507 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -1,31 +1,46 @@
 use crate::{
-    crypto::{self, MeshIdentity},
+    crypto::{self, MeshIdentity, SymmetricKeyManager},
+    db::{self, models::EncryptedIndicator},
     logging::EncryptedLogger,
     models::{PrivacyLevel, ThreatIndicator, TlpLevel},
     settings::PrivacyConfig,
-    uuid::Uuid,
 };
 use chrono::Utc;
+use diesel::{
+    pg::PgConnection,
+    query_dsl::methods::{FilterDsl, FindDsl},
+    r2d2::Pool,
+    OptionalExtension,
+};
+use diesel::{r2d2::ConnectionManager, ExpressionMethods};
+use diesel::{RunQueryDsl, SelectableHelper};
 use serde::{Deserialize, Serialize};
 use std::{collections::HashMap, io};
 
 #[derive(Clone, Debug)]
 pub struct Node {
     identity: MeshIdentity,
-    indicators: HashMap<Uuid, ThreatIndicator>,
     peers: HashMap<String, NodePeer>,
+    db_pool: Pool<ConnectionManager<PgConnection>>,
+    key_mgr: SymmetricKeyManager,
     logger: EncryptedLogger,
     privacy_level: PrivacyLevel,
     allow_custom_fields: bool,
 }
 
 impl Node {
-    pub fn new(logger: EncryptedLogger, privacy: PrivacyConfig) -> Result<Self, std::io::Error> {
+    pub fn new(
+        db_pool: Pool<ConnectionManager<PgConnection>>,
+        key_mgr: SymmetricKeyManager,
+        logger: EncryptedLogger,
+        privacy: PrivacyConfig,
+    ) -> Result<Self, std::io::Error> {
         let identity = crypto::MeshIdentity::load_or_generate()?;
         Ok(Node {
             identity,
-            indicators: HashMap::new(),
             peers: HashMap::new(),
+            db_pool,
+            key_mgr,
             logger,
             privacy_level: match privacy.level.as_str() {
                 "strict" => PrivacyLevel::Strict,
@@ -62,49 +77,119 @@ impl Node {
         );
     }
 
-    pub fn add_indicator(&mut self, indicator: ThreatIndicator) -> Uuid {
-        let id = indicator.get_id();
-        self.indicators.insert(id, indicator.clone());
-        let _ = self.logger.write_log(
-            log::Level::Info,
-            &format!("Adding indicator: {:?}", indicator),
-        );
-        id
+    pub fn add_indicator(
+        &mut self,
+        indicator: ThreatIndicator,
+    ) -> Result<EncryptedIndicator, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators;
+
+        let encrypted_indicator = indicator.encrypt(&mut self.key_mgr)?;
+
+        let mut conn = self.db_pool.get()?;
+        let res = diesel::insert_into(encrypted_indicators::table)
+            .values(&encrypted_indicator)
+            .returning(EncryptedIndicator::as_returning())
+            .get_result(&mut conn)?;
+
+        let _ = self
+            .logger
+            .write_log(log::Level::Info, &format!("Added indicator: {:?}", res));
+        Ok(res)
     }
 
-    pub fn add_or_increment_indicator(&mut self, new_indicator: ThreatIndicator) -> Uuid {
-        let id = new_indicator.get_id();
-        if let Some(existing) = self.indicators.get_mut(&id) {
-            existing.sightings += 1;
-            existing.updated_at = Utc::now();
+    pub fn add_or_increment_indicator(
+        &mut self,
+        new_indicator: ThreatIndicator,
+    ) -> Result<EncryptedIndicator, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators::dsl::*;
+
+        let indicator_id = new_indicator.get_id();
+
+        let mut conn = self.db_pool.get()?;
+        let existing: Option<EncryptedIndicator> = encrypted_indicators
+            .find(&indicator_id)
+            .first::<EncryptedIndicator>(&mut conn)
+            .optional()?;
+
+        if let Some(encrypted) = existing {
+            let mut indicator = ThreatIndicator::decrypt(&encrypted, &self.key_mgr)?;
+            indicator.sightings += 1;
+            indicator.updated_at = Utc::now();
+
+            let new_encrypted = indicator.encrypt(&mut self.key_mgr)?;
+            let res = diesel::update(encrypted_indicators.find(&indicator_id))
+                .set((
+                    ciphertext.eq(new_encrypted.ciphertext),
+                    nonce.eq(new_encrypted.nonce),
+                    mac.eq(new_encrypted.mac),
+                ))
+                .returning(EncryptedIndicator::as_returning())
+                .get_result(&mut conn)?;
+
             let _ = self.logger.write_log(
                 log::Level::Info,
-                &format!("Incrementing indicator: {:?}", existing),
+                &format!("Incrementing indicator: {:?}", res),
             );
+            Ok(res)
         } else {
-            self.add_indicator(new_indicator);
+            let encrypted = new_indicator.encrypt(&mut self.key_mgr)?;
+            let res = diesel::insert_into(encrypted_indicators)
+                .values(&encrypted)
+                .returning(EncryptedIndicator::as_returning())
+                .get_result(&mut conn)?;
+
+            let _ = self
+                .logger
+                .write_log(log::Level::Info, &format!("Adding indicator: {:?}", res));
+            Ok(res)
         }
-        id
     }
 
     pub fn get_level(&self) -> PrivacyLevel {
         self.privacy_level
     }
 
-    pub fn get_indicator_by_id(&self, id: &Uuid) -> Option<&ThreatIndicator> {
-        self.indicators.get(id)
+    pub fn get_indicator_by_id(
+        &self,
+        indicator_id: &String,
+    ) -> Result<ThreatIndicator, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators::dsl::*;
+
+        let mut conn = self.db_pool.get()?;
+        let indicator = encrypted_indicators
+            .find(indicator_id)
+            .first::<EncryptedIndicator>(&mut conn)?;
+
+        let indicator = ThreatIndicator::decrypt(&indicator, &self.key_mgr)?;
+        Ok(indicator)
     }
 
-    pub fn list_indicators_by_tlp(&self, tlp: TlpLevel) -> Vec<ThreatIndicator> {
-        self.indicators
-            .values()
-            .filter(|i| i.tlp == tlp)
-            .cloned()
-            .collect()
+    pub fn list_indicators_by_tlp(
+        &self,
+        tlp: TlpLevel,
+    ) -> Result<Vec<ThreatIndicator>, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators::dsl::*;
+
+        let mut conn = self.db_pool.get()?;
+        let indicators = encrypted_indicators
+            .filter(tlp_level.eq(tlp.to_string()))
+            .load::<EncryptedIndicator>(&mut conn)?;
+
+        let indicators = indicators
+            .iter()
+            .map(|i| ThreatIndicator::decrypt(i, &self.key_mgr))
+            .collect::<Result<Vec<ThreatIndicator>, std::string::String>>()?;
+
+        Ok(indicators)
     }
 
-    pub fn list_objects_by_tlp(&self, tlp: TlpLevel) -> Vec<serde_json::Value> {
-        let indicators: Vec<_> = self.list_indicators_by_tlp(tlp);
+    pub fn list_objects_by_tlp(
+        &self,
+        tlp: TlpLevel,
+    ) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error + Send + Sync>> {
+        let indicators = self.list_indicators_by_tlp(tlp)?;
+
+        println!("Indicators: {:?}", indicators);
 
         let mut stix_indicators: Vec<serde_json::Value> = indicators
             .iter()
@@ -118,7 +203,7 @@ impl Node {
 
         stix_indicators.extend(stix_mds);
         stix_indicators.sort_by(|a, b| a["id"].as_str().unwrap().cmp(b["id"].as_str().unwrap()));
-        stix_indicators
+        Ok(stix_indicators)
     }
 
     pub fn get_peers(&self) -> &HashMap<String, NodePeer> {
diff --git a/src/uuid.rs b/src/uuid.rs
index cc50089..666447e 100644
--- a/src/uuid.rs
+++ b/src/uuid.rs
@@ -60,12 +60,17 @@ impl Deref for Uuid {
 
 impl Uuid {
     /// Generate a new UUID
-    pub fn new() -> Self {
-        Self(uuid::Uuid::now_v7())
+    pub fn new_v7() -> Self {
+        Self::now_v7()
     }
     /// Generate a new V7 UUID
-    pub fn new_v7() -> Self {
-        Self(uuid::Uuid::now_v7())
+    pub fn now_v7() -> Self {
+        let ctx = TIMESTAMP_CONTEXT.lock().unwrap();
+        Self(uuid::Uuid::new_v7(uuid::Timestamp::now(&*ctx)))
+    }
+    /// Generate a new V5 UUID
+    pub fn new_v5_from_hash(hash: &[u8]) -> Self {
+        Self(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, hash))
     }
     /// Generate a new V7 UUID
     pub fn new_v7_from_datetime(timestamp: DateTime<Utc>) -> Self {


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 10**

<details>
<summary>🔭 Outside diff range comments (2)</summary><blockquote>

<details>
<summary>src/main.rs (1)</summary><blockquote>

`80-99`: _⚠️ Potential issue_

**Key manager is cloned – resulting instances rotate independently**

`load_or_generate` returns a *value* that you subsequently `clone()` for the `Logger`, `Node`, and `API` layers.  
Because `SymmetricKeyManager` owns its key material, each clone maintains its own `current_key`, `rotation_time`, etc.  
After the first call to `key_mgr.rotate_key()` the original instance diverges from the clones, breaking decryption for requests processed through the API.

Recommended fix:

1. Wrap the key manager in `Arc<Mutex<…>>` (or `Arc<RwLock<…>>`) and share the single instance:

```diff
- let mut key_mgr = crypto::SymmetricKeyManager::load_or_generate(...)?
+ let key_mgr = Arc::new(Mutex::new(
+     crypto::SymmetricKeyManager::load_or_generate(settings.tls.key_rotation_days)?
+ ));
```

2. Pass `Arc<…>` everywhere (Node, Logger, API state) and acquire a mutable guard only when `encrypt`/`rotate_key` are needed.

This guarantees consistent key material across all threads & tasks.

</blockquote></details>
<details>
<summary>src/models.rs (1)</summary><blockquote>

`100-114`: _⚠️ Potential issue_

**`decrypt` returns `String` errors – this breaks callers using `?`.**  
`Node::{add_or_increment_indicator,get_indicator_by_id,list_indicators_by_tlp}` propagate the error with `?` into a `Box<dyn Error>` context.  Because `String` does **not** implement `std::error::Error`, compilation fails.

Change the signature to return a proper error type, e.g.:

```diff
-pub fn decrypt( … ) -> Result<Self, String> {
+pub fn decrypt( … ) -> Result<Self, std::io::Error> {
     let decrypted = key_mgr
         .decrypt( … )
-        .map_err(|e| format!("Decryption failed: {}", e))?;
+        .map_err(|e| std::io::Error::other(format!("Decryption failed: {e}")))?;
 
-    serde_json::from_slice(&decrypted)
-        .map_err(|e| format!("Failed to deserialize ThreatIndicator: {}", e))
+    serde_json::from_slice(&decrypted)
+        .map_err(|e| std::io::Error::other(format!("Failed to deserialize ThreatIndicator: {e}")))
 }
```

This makes the method composable with `?`, fixes the compile error in `src/node.rs`, and keeps error types uniform across `encrypt`/`decrypt`.

</blockquote></details>

</blockquote></details>

<details>
<summary>♻️ Duplicate comments (2)</summary><blockquote>

<details>
<summary>src/api.rs (1)</summary><blockquote>

`248-251`: Same concern as the public-indicator endpoint: internal database errors are discarded, making troubleshooting harder.  
Refer to previous comment for suggested handling.

</blockquote></details>
<details>
<summary>src/node.rs (1)</summary><blockquote>

`167-184`: **Same compile-error cascade for `decrypt` as above.**  
Once `decrypt` returns an `io::Error`, these `?` uses will compile.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (14)</summary><blockquote>

<details>
<summary>migrations/2025-05-21-234457_create_encrypted_indicators/up.sql (1)</summary><blockquote>

`1-7`: **Consider adding constraints on tlp_level values**

The migration creates an appropriate table structure with NOT NULL constraints. The cryptographic data is correctly stored as BYTEA.


Consider adding a CHECK constraint to ensure only valid TLP levels are stored:

```diff
CREATE TABLE encrypted_indicators (
    id CHAR(64) PRIMARY KEY,
    ciphertext BYTEA NOT NULL,
    nonce BYTEA NOT NULL,
    mac BYTEA NOT NULL,
-   tlp_level TEXT NOT NULL
+   tlp_level TEXT NOT NULL CHECK (tlp_level IN ('WHITE', 'GREEN', 'AMBER', 'RED'))
);
```

</blockquote></details>
<details>
<summary>src/db/connection.rs (2)</summary><blockquote>

`7-13`: **Good implementation, consider additional pool configuration**

The connection pool implementation is correct and follows Rust idioms with proper error handling. However, the function uses default pool settings which might not be optimal for production use.


Consider adding configuration parameters for the connection pool:

```diff
 pub fn get_connection_pool(
     conn_str: &str,
+    max_size: u32,
+    min_idle: Option<u32>,
+    connection_timeout: std::time::Duration,
 ) -> Result<Pool<ConnectionManager<PgConnection>>, Box<dyn Error + Send + Sync>> {
     let manager = ConnectionManager::<PgConnection>::new(conn_str);
-    let pool = Pool::builder().build(manager)?;
+    let pool = Pool::builder()
+        .max_size(max_size)
+        .min_idle(min_idle)
+        .connection_timeout(connection_timeout)
+        .build(manager)?;
     Ok(pool)
 }
```

If you prefer to keep the function signature simple, consider adding a separate function that accepts a configuration struct.

---

`10-12`: **Consider adding connection health checks**

The current implementation doesn't verify if connections in the pool are healthy before returning them to callers.


Add connection health checks to ensure the pool returns valid connections:

```diff
 let manager = ConnectionManager::<PgConnection>::new(conn_str);
-let pool = Pool::builder().build(manager)?;
+let pool = Pool::builder()
+    .test_on_check_out(true)
+    .build(manager)?;
 Ok(pool)
```

</blockquote></details>
<details>
<summary>src/db/models.rs (1)</summary><blockquote>

`9-13`: **Consider adding timestamp fields for better auditing**

The model currently lacks created_at/updated_at timestamp fields which would be useful for audit trails and data lifecycle management.


Add timestamp fields to the model:

```diff
 pub struct EncryptedIndicator {
     pub id: String,
     pub ciphertext: Vec<u8>,
     pub nonce: Vec<u8>,
     pub mac: Vec<u8>,
     pub tlp_level: String,
+    pub created_at: chrono::NaiveDateTime,
+    pub updated_at: chrono::NaiveDateTime,
 }
```

You'll also need to update the corresponding database schema to include these fields. The timestamps can leverage the diesel_manage_updated_at trigger you've already set up in the migrations.

</blockquote></details>
<details>
<summary>migrations/00000000000000_diesel_initial_setup/up.sql (1)</summary><blockquote>

`1-37`: **Consider documenting the purpose of the migration file**

While this is a standard Diesel setup file, it would be helpful to include comments explaining why this migration is needed and how it relates to the overall database design.


Add a comment at the top of the file explaining the purpose of these functions in the context of your application:

```diff
 -- This file was automatically created by Diesel to setup helper functions
 -- and other internal bookkeeping. This file is safe to edit, any future
 -- changes will be added to existing projects as new migrations.
+--
+-- In our application, these functions support automatic timestamp management
+-- for our encrypted_indicators table, ensuring we can track when records were
+-- last modified without manual intervention.
```

</blockquote></details>
<details>
<summary>src/api.rs (2)</summary><blockquote>

`231-234`: **Expose underlying error instead of collapsing to 500**

`list_indicators_by_tlp` can fail for various reasons (connection-pool exhaustion, deserialisation, etc.).  
All failures are currently mapped to `INTERNAL_SERVER_ERROR`, hiding actionable information from both client and logs.

Consider:

1. Logging the concrete error (`error!("{:?}", err)`) before mapping.  
2. Returning `SERVICE_UNAVAILABLE` for pool errors, `BAD_REQUEST` for illegal TLP, etc., to aid clients.

This pattern recurs in other handlers; a small helper like `map_db_err(err)?` would de-duplicate the logic.

---

`326-331`: **Avoid printing to stdout inside a request handler**

The `println!` call will write to stdout synchronously and bypass your structured logger.  
Replace with:

```rust
log::error!("Error while listing objects by TLP: {}", error);
```

or propagate the error to the caller if it should surface as an HTTP failure.

</blockquote></details>
<details>
<summary>src/main.rs (1)</summary><blockquote>

`155-156`: **Handle potential DB errors when inserting the indicator**

`add_or_increment_indicator` returns a `Result`, yet the error is dropped with `let _ = …;`.  
Silently ignoring insertion failures defeats the purpose of the earlier encryption-test logic.

Propagate or log the error as done in the API layer.

</blockquote></details>
<details>
<summary>src/crypto/symmetric_key_manager.rs (2)</summary><blockquote>

`65-77`: **`save_keys` omits errors when writing the *previous* key**

If writing `symm_prev.key` fails the manager continues silently, leaving the system without a recovery key.  
Bubble the error up:

```diff
-            let mut prev_file = File::create(SYMM_PREV_KEY_PATH)?;
-            prev_file.write_all(prev.as_slice())?;
+            let mut prev_file = File::create(SYMM_PREV_KEY_PATH)?;
+            prev_file.write_all(prev.as_slice())?;
```

→ Already returns `std::io::Result<()>`, so just propagate with `?`.

Also, consider deleting *both* key files on failure to avoid partial state.

---

`79-91`: **`rotate_key` is not concurrency-safe**

The method mutates internal state (`current_key`, `previous_key`, `key_rotation_time`) without external synchronisation.  
Cloning `SymmetricKeyManager` (which you currently do) introduces multiple independent timers and possible write races when each clone destroys/rewrites the same `symm*.key` files.

Couple this with the earlier advice: wrap a single instance in `Arc<Mutex<…>>` and remove `Clone` derivation to enforce exclusivity.

</blockquote></details>
<details>
<summary>src/models.rs (2)</summary><blockquote>

`39-43`: **`new()` almost never returns an error – simplify the signature.**  
`Self::compute_id` is the only fallible call yet it currently cannot fail (`compute_id` wraps a pure, in-memory hash → UUID conversion in an `Ok`).  Returning `Result<Self, io::Error>` from `new` therefore complicates the API and forces callers to handle an error path that will never be taken.

```diff
-pub fn new( … ) -> Result<Self, std::io::Error> {
-    let id = Self::compute_id(…)
-        .map_err(|e| std::io::Error::other(format!("Failed to compute ID: {}", e)))?;
+pub fn new( … ) -> Self {
+    let id = Self::compute_id(…);
```

If you anticipate future failure paths, add them explicitly in `compute_id`; otherwise prefer the simpler, infallible API.

---

`65-80`: **`compute_id` can’t fail – drop the `Result` wrapper.**  
Nothing inside this function returns an error.  Eliminating the `Result`:

```diff
-pub fn compute_id( … ) -> Result<Uuid, std::io::Error> {
+pub fn compute_id( … ) -> Uuid {
     …
-    Ok(Uuid::new_v5_from_hash(&hash))
+    Uuid::new_v5_from_hash(&hash)
 }
```

This cascades into a cleaner `new()` implementation and removes the need for the `map_err` dance shown above.

</blockquote></details>
<details>
<summary>src/node.rs (2)</summary><blockquote>

`152-165`: **Accept `&str` instead of `&String` for flexibility.**

```diff
-pub fn get_indicator_by_id(
-    &self,
-    indicator_id: &String,
+pub fn get_indicator_by_id(
+    &self,
+    indicator_id: &str,
 ) -> …
```

Taking a `&str` lets callers pass either a `String` or string literal without extra allocations.

---

`192-193`: **Remove stray `println!` used for debugging.**

Leaving debugging prints in library code clutters stdout for consumers (and breaks invariants in daemons without terminals).

```diff
-println!("Indicators: {:?}", indicators);
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between b88db348f1b1807c2529dee9796c78ce1875e9fe and 76eb858205964eb186d3f689dd71153d04d54531.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `Cargo.lock` is excluded by `!**/*.lock`

</details>

<details>
<summary>📒 Files selected for processing (18)</summary>

* `.env.example` (1 hunks)
* `Cargo.toml` (2 hunks)
* `diesel.toml` (1 hunks)
* `migrations/00000000000000_diesel_initial_setup/down.sql` (1 hunks)
* `migrations/00000000000000_diesel_initial_setup/up.sql` (1 hunks)
* `migrations/2025-05-21-234457_create_encrypted_indicators/down.sql` (1 hunks)
* `migrations/2025-05-21-234457_create_encrypted_indicators/up.sql` (1 hunks)
* `src/api.rs` (7 hunks)
* `src/crypto/symmetric_key_manager.rs` (4 hunks)
* `src/db/connection.rs` (1 hunks)
* `src/db/mod.rs` (1 hunks)
* `src/db/models.rs` (1 hunks)
* `src/db/schema.rs` (1 hunks)
* `src/logging.rs` (3 hunks)
* `src/main.rs` (4 hunks)
* `src/models.rs` (4 hunks)
* `src/node.rs` (3 hunks)
* `src/uuid.rs` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (3)</summary>

<details>
<summary>src/db/mod.rs (1)</summary><blockquote>

<details>
<summary>src/db/connection.rs (1)</summary>

* `get_connection_pool` (7-13)

</details>

</blockquote></details>
<details>
<summary>src/db/schema.rs (1)</summary><blockquote>

<details>
<summary>src/node.rs (3)</summary>

* `encrypted_indicators` (109-111)
* `encrypted_indicators` (159-161)
* `encrypted_indicators` (174-176)

</details>

</blockquote></details>
<details>
<summary>src/db/models.rs (1)</summary><blockquote>

<details>
<summary>src/node.rs (3)</summary>

* `encrypted_indicators` (109-111)
* `encrypted_indicators` (159-161)
* `encrypted_indicators` (174-176)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (1)</summary>

* GitHub Check: build-test-lint

</details>

<details>
<summary>🔇 Additional comments (14)</summary><blockquote>

<details>
<summary>Cargo.toml (1)</summary>

`11-11`: **Validate added Diesel dependency and UUID feature.**

The new entries look correct, but please verify that:
1. The `diesel = { version = "2.2.0", features = ["postgres", "r2d2", "chrono"] }` line matches your target database version and doesn’t introduce breaking changes.
2. The `uuid` crate’s `"v5"` feature is required alongside `"v7"` and `"serde"`.

Run `cargo update -p diesel` and your test suite to confirm compatibility.

</details>
<details>
<summary>src/db/mod.rs (1)</summary>

`1-6`: **Module re-exports look good.**

The `db` module cleanly re-exports `get_connection_pool` and organizes `connection`, `models`, and `schema`.  
No issues detected here.

</details>
<details>
<summary>migrations/00000000000000_diesel_initial_setup/down.sql (1)</summary>

`5-6`: **Make function drops idempotent.**

Currently:
```sql
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();
```
Both use `IF EXISTS`, which is correct.  
Consider adding `CASCADE` if any triggers depend on these functions, or document that no dependencies exist.

</details>
<details>
<summary>src/db/schema.rs (1)</summary>

`3-12`: **Schema design follows best practices for encrypted data storage**

The table structure appropriately stores:
- Fixed-length ID as primary key
- Binary data (BYTEA) for all cryptographic components
- Text field for the TLP level which enables filtering without decryption

The schema aligns well with the Diesel ORM queries seen in the Node implementation.

</details>
<details>
<summary>src/logging.rs (3)</summary>

`1-2`: **Add Base64 encoding support for binary data**

Adding Base64 encoding support is appropriate for storing binary cryptographic data in text-based logs.

---

`42-47`: **Properly encode binary data for log storage**

The change correctly implements Base64 encoding of binary cryptographic data before writing to logs, which prevents corruption and improves log readability.

---

`76-93`: <details>
<summary>❓ Verification inconclusive</summary>

**Robust Base64 decoding with appropriate error handling**

The implementation properly decodes Base64-encoded binary data with comprehensive error handling. The chained `and_then`/`ok_or_else` pattern is an elegant Rust solution.


Consider how key rotation might affect log file readability. If encryption keys change, older logs encrypted with previous keys might become unreadable. Is this an intentional design decision or should there be support for multiple keys?

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if there's any key rotation functionality in the codebase
rg -A 5 -B 5 "key.*rotation|rotate.*key" --type rust
```

Length of output: 5144

---

🏁 Script executed:

```shell
#!/bin/bash
# Locate the decrypt implementation in the codebase and check for previous-key fallback
rg -A15 -B5 "pub fn decrypt" --type rust
```

Length of output: 2392

---



</details>

**Key rotation includes a single-previous-key fallback**

The `SymmetricKeyManager::decrypt` implementation first tries decryption with the `current_key` and then falls back to the `previous_key` on failure, ensuring that logs encrypted in the last rotation interval remain readable. Only one past key is retained, so logs older than two rotation intervals will fail decryption.

Points for review:
- `src/crypto/symmetric_key_manager.rs`  
  • `pub fn rotate_key` (maintains `previous_key`)  
  • `pub fn decrypt` (tries current, then previous)  

Question: If you need to read logs across more than one rotation window, should we extend this to support multiple historical keys (e.g., an archived key list)?

</details>
<details>
<summary>src/db/models.rs (1)</summary>

`6-14`: **Data model structure looks good**

The `EncryptedIndicator` struct is correctly annotated with Diesel ORM traits and has appropriate fields for storing encrypted threat indicators.

</details>
<details>
<summary>src/uuid.rs (3)</summary>

`63-65`: **Good refactoring for better clarity**

Renaming the method to `new_v7()` and delegating to `now_v7()` improves code clarity by explicitly indicating the UUID version being used.

---

`67-70`: **Proper mutex handling for thread safety**

The implementation correctly locks the timestamp context mutex, ensuring thread-safe UUID generation.

---

`71-74`: **Good addition of UUIDv5 support**

The new method for generating deterministic UUIDs from hashes is a valuable addition. Using the OID namespace is appropriate for this use case.

</details>
<details>
<summary>migrations/00000000000000_diesel_initial_setup/up.sql (2)</summary>

`19-24`: **Good implementation of timestamp management function**

The `diesel_manage_updated_at` function correctly sets up a trigger that will automatically update the `updated_at` column when rows are modified.

---

`26-36`: **Properly implemented updated_at trigger function**

The `diesel_set_updated_at` trigger function correctly checks if the row has been modified and if the `updated_at` column hasn't been explicitly set before updating it.

</details>
<details>
<summary>src/node.rs (1)</summary>

`32-38`: **Constructor consumes `SymmetricKeyManager` by value – verify reuse semantics.**  
`key_mgr` is moved into `Node`.  If the same manager instance must be shared elsewhere (e.g. other nodes or services) this move will cause ownership issues.  If that’s unintended, switch to `Arc<Mutex<SymmetricKeyManager>>` (or similar) and store a cloneable handle instead.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-22T02:44:47Z

Review comments:
diff_hunk: @@ -0,0 +1 @@
+DROP TABLE encrypted_indicators;
path: migrations/2025-05-21-234457_create_encrypted_indicators/down.sql
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Ensure safe, idempotent rollback of the table.**

Using a plain `DROP TABLE` will error if the table doesn’t exist.  
Consider making this idempotent and handling dependent objects by switching to:

```diff
-DROP TABLE encrypted_indicators;
+DROP TABLE IF EXISTS encrypted_indicators CASCADE;
```

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In migrations/2025-05-21-234457_create_encrypted_indicators/down.sql at line 1,
the current DROP TABLE statement will cause an error if the table does not
exist. To make the rollback safe and idempotent, modify the statement to include
IF EXISTS so it only attempts to drop the table if it exists. Also, add CASCADE
to handle dependent objects automatically during the drop.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:46Z
author_association: NONE
diff_hunk: @@ -0,0 +1,9 @@
+# For documentation on how to configure this file,
+# see https://diesel.rs/guides/configuring-diesel-cli
+
+[print_schema]
+file = "src/db/schema.rs"
+custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
+
+[migrations_directory]
+dir = "/Users/caden/Developer/dtim/migrations"
path: diesel.toml
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Avoid absolute paths for migrations directory.**

The `migrations_directory.dir` is hardcoded to a local filesystem path. This will break for other developers and CI environments.  
Use a relative path, for example:

```toml
[migrations_directory]
-dir = "/Users/caden/Developer/dtim/migrations"
+dir = "migrations"
```

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In diesel.toml around lines 8 to 9, the migrations_directory.dir is set to an
absolute path which is not portable. Change the value from the absolute path
"/Users/caden/Developer/dtim/migrations" to a relative path "migrations" to
ensure it works across different environments and for other developers.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:46Z
author_association: NONE
diff_hunk: @@ -4,5 +4,7 @@ DTIM__DEFAULT__PORT=3030
 DTIM__DEFAULT__LOG_LEVEL="info"
 
 # Sensitive info (.env only)
-DTIM__DEFAULT__STORAGE__DATABASE_URL="postgres://user:pass@localhost/db"
-DTIM__DEFAULT__WATCHERS__VIRUSTOTAL_API_KEY="your_api_key"
\ No newline at end of file
+DATABASE_URL="postgres://postgres:@localhost:5432/postgres" # For Diesel CLI
+
+DTIM__DEFAULT__STORAGE__DATABASE_URL=${DATABASE_URL}
path: .env.example
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Review database connection string for security**

The PostgreSQL connection string uses default credentials without a password. While acceptable for a local development example, this poses security risks:

1. Uses default "postgres" superuser account
2. Has no password protection
3. Uses default "postgres" database


Add clarifying comments to guide users on securing their database:

```diff
-DATABASE_URL="postgres://postgres:@localhost:5432/postgres" # For Diesel CLI
+DATABASE_URL="postgres://postgres:@localhost:5432/postgres" # For Diesel CLI - Add password after 'postgres:' for production use
 
-DTIM__DEFAULT__STORAGE__DATABASE_URL=${DATABASE_URL}
+DTIM__DEFAULT__STORAGE__DATABASE_URL=${DATABASE_URL} # Uses same connection as Diesel CLI
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
DATABASE_URL="postgres://postgres:@localhost:5432/postgres" # For Diesel CLI - Add password after 'postgres:' for production use
DTIM__DEFAULT__STORAGE__DATABASE_URL=${DATABASE_URL} # Uses same connection as Diesel CLI
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In the .env.example file around lines 7 to 9, the PostgreSQL connection string
uses default credentials with no password and the superuser account, which is
insecure. Add comments above or beside the DATABASE_URL line to clarify that
this is for local development only, recommend changing the username, setting a
strong password, and using a dedicated database for production environments to
guide users on securing their database connection.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:46Z
author_association: NONE
diff_hunk: @@ -200,13 +198,14 @@ struct GossipIndicatorsResponse {
 
 async fn gossip_indicators_handler(
     State(state): State<Arc<AppState>>,
-    Json(indicators): Json<Vec<EncryptedThreatIndicator>>,
+    Json(indicators): Json<Vec<EncryptedIndicator>>,
 ) -> ApiResponse<GossipIndicatorsResponse> {
     let mut node = state.node.lock().await;
     let mut count = 0;
     for encrypted in indicators {
         if let Ok(indicator) = ThreatIndicator::decrypt(&encrypted, &state.key_mgr) {
-            node.add_or_increment_indicator(indicator);
+            node.add_or_increment_indicator(indicator)
+                .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
             count += 1;
         }
     }
path: src/api.rs
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Minimise lock contention by decrypting *before* acquiring the `Node` mutex**

`state.node.lock().await` is taken *before* the loop that performs CPU-bound decryption and signature checks.  
While the lock is held, every other request that needs the node (including reads) will block, lowering throughput and increasing latency under load.

```diff
-    let mut node = state.node.lock().await;
-    let mut count = 0;
-    for encrypted in indicators {
-        if let Ok(indicator) = ThreatIndicator::decrypt(&encrypted, &state.key_mgr) {
-            node.add_or_increment_indicator(indicator)
-                .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
-            count += 1;
-        }
-    }
+    // 1.  Decrypt outside the critical section
+    let mut decrypted: Vec<ThreatIndicator> = indicators
+        .into_iter()
+        .filter_map(|enc| ThreatIndicator::decrypt(&enc, &state.key_mgr).ok())
+        .collect();
+
+    // 2. Acquire the lock *once* for all DB mutations
+    let mut node = state.node.lock().await;
+    let mut count = 0;
+    for indicator in decrypted.drain(..) {
+        node.add_or_increment_indicator(indicator)
+            .map_err(|_| ApiError::INTERNAL_SERVER_ERROR)?;
+        count += 1;
+    }
```

Benefits  
• Shorter critical section → better concurrency  
• Avoids holding the lock while performing CPU work or I/O on the key manager  
• Slightly cleaner code by separating “validation/decryption” from “state mutation”.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/api.rs around lines 199 to 211, the Node mutex lock is acquired before
decrypting indicators, causing unnecessary lock contention. To fix this, move
the decryption and validation of each indicator outside the locked section by
first decrypting all indicators, collecting the valid ones, and then acquiring
the lock once to update the node state. This reduces the critical section
duration, improves concurrency, and separates CPU-bound decryption from state
mutation.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:46Z
author_association: NONE
diff_hunk: @@ -130,19 +134,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
         vec!["malicious-activity".to_string()],
         models::TlpLevel::White,
         None,
+    )
+    .unwrap();
+
+    let encrypted = indicator.encrypt(&mut key_mgr).unwrap();
+    println!(
+        "Encrypted: {:?}",
+        serde_json::to_string(&encrypted).unwrap()
     );
 
-    let encrypted = indicator.encrypt(&mut key_mgr);
-    println!("Encrypted: {:?}", encrypted);
+    let _ = key_mgr.rotate_key();
 
path: src/main.rs
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Don’t discard errors from `rotate_key()`**

`rotate_key()` persists the new key to disk – I/O failures (e.g. permission denied, full disk) are silently ignored here:

```rust
let _ = key_mgr.rotate_key(); // error lost
```

Either propagate (`rotate_key()?;`) or at minimum log the failure:

```rust
if let Err(e) = key_mgr.rotate_key() {
    log::error!("Key rotation failed: {}", e);
}
```

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/main.rs around lines 140 to 147, the call to key_mgr.rotate_key()
currently ignores any errors, which can hide I/O failures during key
persistence. Modify the code to handle the Result returned by rotate_key() by
either propagating the error using the ? operator or by explicitly matching on
the Result and logging any errors with log::error! to ensure failures are not
silently discarded.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:46Z
author_association: NONE
diff_hunk: @@ -13,18 +20,40 @@ pub struct SymmetricKeyManager {
 }
 
 impl SymmetricKeyManager {
-    pub fn new(rotation_days: u64) -> Self {
-        let current_key = Self::generate_key();
+    pub fn load_or_generate(rotation_days: u64) -> std::io::Result<Self> {
+        fs::create_dir_all("data/keys")?;
+
+        let mut current_bytes = [0u8; 32];
+        let mut prev_bytes = [0u8; 32];
+
+        let current_key = if Path::new(SYMM_KEY_PATH).exists() {
+            File::open(SYMM_KEY_PATH)?.read_exact(&mut current_bytes)?;
+            Key::<Aes256Gcm>::from_slice(&current_bytes).to_owned()
+        } else {
+            let key = Self::generate_key();
+            let mut file = File::create(SYMM_KEY_PATH)?;
+            file.write_all(key.as_slice())?;
+            key
+        };
+
+        let previous_key = if Path::new(SYMM_PREV_KEY_PATH).exists() {
+            File::open(SYMM_PREV_KEY_PATH)?.read_exact(&mut prev_bytes)?;
+            Some(Key::<Aes256Gcm>::from_slice(&prev_bytes).to_owned())
+        } else {
+            None
+        };
+
         let key_rotation_time = SystemTime::now()
             .duration_since(UNIX_EPOCH)
             .unwrap()
             .as_secs();
-        Self {
+
+        Ok(Self {
             current_key,
-            previous_key: None,
+            previous_key,
             key_rotation_time,
             rotation_interval: rotation_days * 24 * 60 * 60,
-        }
+        })
     }
path: src/crypto/symmetric_key_manager.rs
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Keys are written to disk unencrypted with default permissions**

`File::create()` will honour the process umask; on many systems this yields `0o644`, exposing secrets to other users.  
Since these are long-term AES-256 keys, apply restrictive permissions and/or encrypt the key files.

Quick fix (Unix-only; needs `std::os::unix::fs::OpenOptionsExt`):

```diff
-use std::fs::{self, File};
+use std::fs::{self, OpenOptions};
+use std::os::unix::fs::OpenOptionsExt; // cfg-gate on Unix

...
-let mut file = File::create(SYMM_KEY_PATH)?;
+let mut file = OpenOptions::new()
+    .write(true)
+    .create(true)
+    .truncate(true)
+    .mode(0o600)
+    .open(SYMM_KEY_PATH)?;
```

Long-term, consider wrapping the key with a passphrase (e.g. using `ring::pbkdf2`) or leveraging the OS key store.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/crypto/symmetric_key_manager.rs between lines 23 and 57, the key files
are created with default permissions that may expose sensitive AES-256 keys to
other users. To fix this, modify the file creation to use OpenOptions with
restrictive permissions (e.g., 0o600) by importing and using
std::os::unix::fs::OpenOptionsExt on Unix systems. This ensures the key files
are only accessible by the owner. For a longer-term solution, consider
encrypting the keys on disk or using the OS key store.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:46Z
author_association: NONE
diff_hunk: @@ -59,35 +55,58 @@ impl ThreatIndicator {
             recipients: None, // TODO: Handle recipients for TLP:RED
             custom_fields,
             marking_definitions: vec![marking_definition],
-        }
+        })
+    }
+
+    pub fn get_id(&self) -> String {
+        self.id.to_string()
     }
 
-    pub fn get_id(&self) -> Uuid {
-        self.id
+    pub fn compute_id(
+        indicator_type: &IndicatorType,
+        value: &str,
+        tlp: &TlpLevel,
+        tags: &[String],
+    ) -> Result<Uuid, std::io::Error> {
+        let mut hasher = Sha256::new();
+        hasher.update(indicator_type.to_string().as_bytes());
+        hasher.update(value.as_bytes());
+        hasher.update(tlp.to_string().as_bytes());
+        for tag in tags {
+            hasher.update(tag.as_bytes());
+        }
+        let hash = hasher.finalize();
+        Ok(Uuid::new_v5_from_hash(&hash))
     }
 
     pub fn encrypt(
         &self,
         key_mgr: &mut SymmetricKeyManager,
-    ) -> Result<EncryptedThreatIndicator, std::io::Error> {
+    ) -> Result<EncryptedIndicator, std::io::Error> {
         let serialized = serde_json::to_vec(self).expect("Failed to serialize ThreatIndicator");
         let (ciphertext, nonce, mac) = key_mgr
             .encrypt(&serialized)
             .map_err(|e| std::io::Error::other(format!("Encryption failed: {}", e)))?;
 
-        Ok(EncryptedThreatIndicator {
+        Ok(EncryptedIndicator {
+            id: self.id.to_string(),
             ciphertext,
             nonce,
             mac,
+            tlp_level: self.tlp.to_string(),
         })
     }
 
path: src/models.rs
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`expect` will panic in production – propagate the serialization error instead.**  
Using `expect` here breaks the invariant that `encrypt` returns a `Result`. Convert the panic into an error:

```diff
-let serialized = serde_json::to_vec(self).expect("Failed to serialize ThreatIndicator");
+let serialized = serde_json::to_vec(self)
+    .map_err(|e| std::io::Error::other(format!("Failed to serialize ThreatIndicator: {e}")))?;
```

This keeps the error-handling story consistent and prevents a hard crash when serialisation fails (for example, when a custom field contains a non-serialisable value).

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/models.rs around lines 82 to 99, the encrypt function uses expect on
serialization which can cause a panic in production. Replace the expect call
with proper error propagation by converting the serialization error into a
std::io::Error and returning it as a Result error variant, maintaining
consistent error handling without panics.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:47Z
author_association: NONE
diff_hunk: @@ -62,49 +77,119 @@ impl Node {
         );
     }
 
-    pub fn add_indicator(&mut self, indicator: ThreatIndicator) -> Uuid {
-        let id = indicator.get_id();
-        self.indicators.insert(id, indicator.clone());
-        let _ = self.logger.write_log(
-            log::Level::Info,
-            &format!("Adding indicator: {:?}", indicator),
-        );
-        id
+    pub fn add_indicator(
+        &mut self,
+        indicator: ThreatIndicator,
+    ) -> Result<EncryptedIndicator, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators;
+
+        let encrypted_indicator = indicator.encrypt(&mut self.key_mgr)?;
+
+        let mut conn = self.db_pool.get()?;
+        let res = diesel::insert_into(encrypted_indicators::table)
+            .values(&encrypted_indicator)
+            .returning(EncryptedIndicator::as_returning())
+            .get_result(&mut conn)?;
+
+        let _ = self
+            .logger
+            .write_log(log::Level::Info, &format!("Added indicator: {:?}", res));
+        Ok(res)
     }
 
-    pub fn add_or_increment_indicator(&mut self, new_indicator: ThreatIndicator) -> Uuid {
-        let id = new_indicator.get_id();
-        if let Some(existing) = self.indicators.get_mut(&id) {
-            existing.sightings += 1;
-            existing.updated_at = Utc::now();
+    pub fn add_or_increment_indicator(
+        &mut self,
+        new_indicator: ThreatIndicator,
+    ) -> Result<EncryptedIndicator, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators::dsl::*;
+
+        let indicator_id = new_indicator.get_id();
+
+        let mut conn = self.db_pool.get()?;
+        let existing: Option<EncryptedIndicator> = encrypted_indicators
+            .find(&indicator_id)
+            .first::<EncryptedIndicator>(&mut conn)
+            .optional()?;
+
+        if let Some(encrypted) = existing {
+            let mut indicator = ThreatIndicator::decrypt(&encrypted, &self.key_mgr)?;
+            indicator.sightings += 1;
+            indicator.updated_at = Utc::now();
+
+            let new_encrypted = indicator.encrypt(&mut self.key_mgr)?;
+            let res = diesel::update(encrypted_indicators.find(&indicator_id))
+                .set((
+                    ciphertext.eq(new_encrypted.ciphertext),
+                    nonce.eq(new_encrypted.nonce),
+                    mac.eq(new_encrypted.mac),
+                ))
+                .returning(EncryptedIndicator::as_returning())
+                .get_result(&mut conn)?;
+
             let _ = self.logger.write_log(
                 log::Level::Info,
-                &format!("Incrementing indicator: {:?}", existing),
+                &format!("Incrementing indicator: {:?}", res),
             );
+            Ok(res)
         } else {
-            self.add_indicator(new_indicator);
+            let encrypted = new_indicator.encrypt(&mut self.key_mgr)?;
+            let res = diesel::insert_into(encrypted_indicators)
+                .values(&encrypted)
+                .returning(EncryptedIndicator::as_returning())
+                .get_result(&mut conn)?;
+
+            let _ = self
+                .logger
+                .write_log(log::Level::Info, &format!("Adding indicator: {:?}", res));
+            Ok(res)
         }
-        id
     }
path: src/node.rs
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Compilation failure: propagating `String` via `?` is invalid.**  
`ThreatIndicator::decrypt` currently returns `Result<_, String>`.  The `?` operator here requires a type that implements `std::error::Error` to convert into `Box<dyn Error>`, which `String` does not.  Fixing `decrypt` (see previous file) resolves this block.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/node.rs lines 100 to 146, the method add_or_increment_indicator uses the
? operator on ThreatIndicator::decrypt which returns Result with a String error
type. Since ? requires the error to implement std::error::Error to convert into
Box<dyn Error>, you need to change ThreatIndicator::decrypt to return a Result
with an error type that implements std::error::Error instead of String. This fix
in decrypt will allow proper error propagation here without compilation errors.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:47Z
author_association: NONE
diff_hunk: @@ -1,31 +1,46 @@
 use crate::{
-    crypto::{self, MeshIdentity},
+    crypto::{self, MeshIdentity, SymmetricKeyManager},
+    db::{self, models::EncryptedIndicator},
     logging::EncryptedLogger,
     models::{PrivacyLevel, ThreatIndicator, TlpLevel},
     settings::PrivacyConfig,
-    uuid::Uuid,
 };
 use chrono::Utc;
+use diesel::{
+    pg::PgConnection,
+    query_dsl::methods::{FilterDsl, FindDsl},
+    r2d2::Pool,
+    OptionalExtension,
+};
+use diesel::{r2d2::ConnectionManager, ExpressionMethods};
+use diesel::{RunQueryDsl, SelectableHelper};
 use serde::{Deserialize, Serialize};
 use std::{collections::HashMap, io};
 
 #[derive(Clone, Debug)]
 pub struct Node {
     identity: MeshIdentity,
-    indicators: HashMap<Uuid, ThreatIndicator>,
     peers: HashMap<String, NodePeer>,
+    db_pool: Pool<ConnectionManager<PgConnection>>,
+    key_mgr: SymmetricKeyManager,
     logger: EncryptedLogger,
     privacy_level: PrivacyLevel,
     allow_custom_fields: bool,
 }
path: src/node.rs
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Sensitive material may leak through debug-level logs.**  
`Node` now stores encrypted data, yet several log messages (`Added indicator: {:?}`, etc.) still output the full `EncryptedIndicator`, revealing ciphertext, nonce, and MAC. Even though the payload is encrypted, this meta-data may assist traffic analysis or debugging tools with unrestricted access. Consider logging only the indicator ID and TLP level, or scrub/obfuscate byte arrays before logging.  
[security]

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/node.rs around lines 24 to 29, the current debug-level logs output the
full EncryptedIndicator, exposing sensitive encrypted data such as ciphertext,
nonce, and MAC. To fix this, modify the logging statements to only include
non-sensitive information like the indicator ID and TLP level, or sanitize the
byte arrays by obfuscating or removing them before logging, ensuring no
sensitive encrypted material is exposed in logs.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:47Z
author_association: NONE
diff_hunk: @@ -62,49 +77,119 @@
         );
     }
 
-    pub fn add_indicator(&mut self, indicator: ThreatIndicator) -> Uuid {
-        let id = indicator.get_id();
-        self.indicators.insert(id, indicator.clone());
-        let _ = self.logger.write_log(
-            log::Level::Info,
-            &format!("Adding indicator: {:?}", indicator),
-        );
-        id
+    pub fn add_indicator(
+        &mut self,
+        indicator: ThreatIndicator,
+    ) -> Result<EncryptedIndicator, Box<dyn std::error::Error + Send + Sync>> {
+        use self::db::schema::encrypted_indicators;
+
+        let encrypted_indicator = indicator.encrypt(&mut self.key_mgr)?;
+
+        let mut conn = self.db_pool.get()?;
+        let res = diesel::insert_into(encrypted_indicators::table)
+            .values(&encrypted_indicator)
+            .returning(EncryptedIndicator::as_returning())
+            .get_result(&mut conn)?;
+
+        let _ = self
+            .logger
+            .write_log(log::Level::Info, &format!("Added indicator: {:?}", res));
+        Ok(res)
     }
path: src/node.rs
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**`add_indicator` inserts unconditionally – handle primary-key clashes.**  
If an indicator with the same deterministic UUID already exists, this insertion will violate the primary-key constraint and bubble an error. Either:

1. Call `add_or_increment_indicator` internally, or  
2. Use `ON CONFLICT (id) DO UPDATE` (`diesel::upsert`) to increment `sightings`.

Failing to do so exposes the API to 500s on legitimate duplicate submissions.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/node.rs around lines 80 to 98, the add_indicator function inserts a new
indicator without handling primary key conflicts, which causes errors on
duplicates. Modify the function to either call add_or_increment_indicator
internally or implement an upsert using diesel's ON CONFLICT (id) DO UPDATE
clause to increment the sightings count on conflict, preventing 500 errors from
duplicate submissions.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-22T02:44:47Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-22T02:39:39Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This update introduces persistent encrypted storage for threat indicators using a PostgreSQL database via Diesel ORM. It adds database schema migrations, connection pooling, and new data models. The symmetric key manager now supports on-disk key persistence and raw byte encryption. The API, node logic, and indicator models are refactored for database-backed operations with improved error handling and deterministic UUIDs.

## Changes

| File(s)                                                                                       | Change Summary                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
|-----------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| .env.example                                                                                  | Added `DATABASE_URL` for Diesel CLI; changed `DTIM__DEFAULT__STORAGE__DATABASE_URL` to reference it.                                                                                                    |
| Cargo.toml                                                                                    | Added/updated dependencies: enabled `uuid` v5 feature, added `diesel` with PostgreSQL, r2d2, and chrono features.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| diesel.toml                                                                                   | New Diesel CLI config: schema output path, custom derives, migrations directory.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| migrations/00000000000000_diesel_initial_setup/up.sql, down.sql                               | Added initial Diesel migration: creates/drops functions for automatic `updated_at` management via triggers.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| migrations/2025-05-21-234457_create_encrypted_indicators/up.sql, down.sql                     | Migration to create/drop `encrypted_indicators` table with columns for encrypted data, nonce, MAC, TLP level, and primary key.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| src/api.rs                                                                                    | Refactored handlers: now use `EncryptedIndicator`, propagate DB errors, update imports, and standardize error handling for indicator endpoints.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| src/crypto/symmetric_key_manager.rs                                                           | Key manager now supports disk persistence (load/save), key rotation saves to disk, encryption/decryption use raw bytes, error handling improved.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| src/db/connection.rs                                                                          | New: provides function to create a Diesel PostgreSQL connection pool.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| src/db/mod.rs                                                                                 | New: declares and re-exports DB submodules and connection pool function.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| src/db/models.rs                                                                              | New: defines `EncryptedIndicator` struct for ORM and JSON (id, ciphertext, nonce, mac, tlp_level).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| src/db/schema.rs                                                                              | New: Diesel schema for `encrypted_indicators` table.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| src/logging.rs                                                                                | Log encryption components are now Base64-encoded for storage; decoding is performed on read.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| src/main.rs                                                                                   | Main initializes DB pool, loads or generates keys, updates node construction, encrypts and stores indicators, and adapts to new error handling and method signatures.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| src/models.rs                                                                                 | `ThreatIndicator` uses deterministic UUIDv5, new/updated methods for ID computation, encryption returns `EncryptedIndicator`, decryption updated, and uses unified encrypted type.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| src/node.rs                                                                                   | Node now uses DB pool and key manager, all indicator operations are DB-backed, encryption/decryption integrated, error handling updated, and in-memory storage removed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| src/uuid.rs                                                                                   | Refactored and expanded UUID generation: added v5 from hash, explicit v7 with timestamp context, clarified method names and locking.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Main
    participant KeyManager
    participant DB as Database (Diesel/Postgres)
    participant Node
    participant API

    Main->>KeyManager: load_or_generate(rotation_days)
    Main->>DB: get_connection_pool(DATABASE_URL)
    Main->>Node: new(db_pool, key_mgr, logger, privacy)
    API->>Node: add_or_increment_indicator(indicator)
    Node->>KeyManager: encrypt(indicator)
    Node->>DB: insert or update EncryptedIndicator
    Node->>API: return EncryptedIndicator
    API->>Node: get_indicator_by_id(id)
    Node->>DB: fetch EncryptedIndicator
    Node->>KeyManager: decrypt(ciphertext, nonce, mac)
    Node->>API: return ThreatIndicator
```

## Possibly related PRs

- cadenkoj/dtim#18: Adds the initial `.env.example` file with database URL and API key placeholders, which is built upon in this PR by introducing a new `DATABASE_URL` variable and referencing it for Diesel CLI and application configuration.

## Poem

> In the warren, bytes now sleep,
> Encrypted safe, in Postgres deep.
> Keys on disk, not lost in night,
> Indicators stored out of sight.
> Diesel runs, migrations grow,
> Rabbits cheer as data flows!
> 🐇🔑📦

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!NOTE]
> <details>
> <summary>⚡️ AI Code Reviews for VS Code, Cursor, Windsurf</summary>
> 
> CodeRabbit now has a plugin for VS Code, Cursor and Windsurf. This brings AI code reviews directly in the code editor. Each commit is reviewed immediately, finding bugs before the PR is raised. Seamless context handoff to your AI code agent ensures that you can easily incorporate review feedback.
> Learn more [here](http://coderabbit.ai/ide).
> 
> </details>

---

> [!NOTE]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 30th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=cadenkoj/dtim&utm_content=19):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: cadenkoj
created_at: 2025-05-22T02:39:47Z
body: * **#20** <a href="https://app.graphite.dev/github/pr/cadenkoj/dtim/20?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#19** <a href="https://app.graphite.dev/github/pr/cadenkoj/dtim/19?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/cadenkoj/dtim/19?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: OWNER
user: login: cadenkoj
created_at: 2025-05-22T04:38:27Z
body: ### Merge activity

* **May 22, 4:38 AM UTC**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/cadenkoj/dtim/19).
* **May 22, 4:38 AM UTC**: @cadenkoj merged this pull request with [Graphite](https://app.graphite.dev/github/pr/cadenkoj/dtim/19).

author_association: OWNER
user: login: cadenkoj
created_at: 2025-05-22T04:52:47Z
body: Closes: #8, #9 
author_association: OWNER