feat(server): complete domain service modules (keys, channels, users, blobs, devices, p2p, account)
This commit is contained in:
28
crates/quicproquo-server/src/domain/account.rs
Normal file
28
crates/quicproquo-server/src/domain/account.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//! Account domain logic — account deletion with KT tombstone.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use quicproquo_kt::MerkleLog;
|
||||||
|
|
||||||
|
use crate::storage::Store;
|
||||||
|
|
||||||
|
use super::types::DomainError;
|
||||||
|
|
||||||
|
/// Domain service for account lifecycle operations.
|
||||||
|
pub struct AccountService {
|
||||||
|
pub store: Arc<dyn Store>,
|
||||||
|
pub kt_log: Arc<Mutex<MerkleLog>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountService {
|
||||||
|
pub fn delete_account(&self, caller_identity_key: &[u8]) -> Result<(), DomainError> {
|
||||||
|
self.store.delete_account(caller_identity_key)?;
|
||||||
|
|
||||||
|
// Append a KT tombstone entry so the deletion is auditable.
|
||||||
|
if let Ok(mut log) = self.kt_log.lock() {
|
||||||
|
log.append("__tombstone__", caller_identity_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
193
crates/quicproquo-server/src/domain/blobs.rs
Normal file
193
crates/quicproquo-server/src/domain/blobs.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//! Blob domain logic — chunked file upload/download with SHA-256 verification.
|
||||||
|
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// Maximum blob size: 100 MB.
|
||||||
|
const MAX_BLOB_SIZE: u64 = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Maximum download chunk size: 256 KB.
|
||||||
|
const MAX_DOWNLOAD_CHUNK: u32 = 256 * 1024;
|
||||||
|
|
||||||
|
/// Metadata stored alongside each completed blob.
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct BlobMeta {
|
||||||
|
mime_type: String,
|
||||||
|
total_size: u64,
|
||||||
|
uploaded_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain service for blob (file attachment) storage.
|
||||||
|
pub struct BlobService {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobService {
|
||||||
|
fn blobs_dir(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("blobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upload_blob(
|
||||||
|
&self,
|
||||||
|
req: UploadBlobReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<UploadBlobResp, DomainError> {
|
||||||
|
if req.blob_hash.len() != 32 {
|
||||||
|
return Err(DomainError::BlobHashLength(req.blob_hash.len()));
|
||||||
|
}
|
||||||
|
if req.total_size > MAX_BLOB_SIZE {
|
||||||
|
return Err(DomainError::BlobTooLarge(req.total_size));
|
||||||
|
}
|
||||||
|
if req.total_size == 0 {
|
||||||
|
return Err(DomainError::BadParams("total_size must be > 0".into()));
|
||||||
|
}
|
||||||
|
if req
|
||||||
|
.offset
|
||||||
|
.checked_add(req.chunk.len() as u64)
|
||||||
|
.map_or(true, |end| end > req.total_size)
|
||||||
|
{
|
||||||
|
return Err(DomainError::BadParams(format!(
|
||||||
|
"chunk out of bounds: offset={} + chunk_len={} > total_size={}",
|
||||||
|
req.offset,
|
||||||
|
req.chunk.len(),
|
||||||
|
req.total_size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let blob_hex = hex::encode(&req.blob_hash);
|
||||||
|
let dir = self.blobs_dir();
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| DomainError::Io(format!("create blobs directory: {e}")))?;
|
||||||
|
|
||||||
|
let part_path = dir.join(format!("{blob_hex}.part"));
|
||||||
|
let final_path = dir.join(&blob_hex);
|
||||||
|
let meta_path = dir.join(format!("{blob_hex}.meta"));
|
||||||
|
|
||||||
|
// Already fully uploaded.
|
||||||
|
if final_path.exists() {
|
||||||
|
return Ok(UploadBlobResp {
|
||||||
|
blob_id: req.blob_hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chunk at offset.
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(&part_path)
|
||||||
|
.map_err(|e| DomainError::Io(format!("open .part file: {e}")))?;
|
||||||
|
file.seek(SeekFrom::Start(req.offset))
|
||||||
|
.map_err(|e| DomainError::Io(format!("seek: {e}")))?;
|
||||||
|
file.write_all(&req.chunk)
|
||||||
|
.map_err(|e| DomainError::Io(format!("write chunk: {e}")))?;
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| DomainError::Io(format!("sync: {e}")))?;
|
||||||
|
|
||||||
|
// Check if upload is complete.
|
||||||
|
let end = req.offset + req.chunk.len() as u64;
|
||||||
|
if end == req.total_size {
|
||||||
|
// Verify SHA-256.
|
||||||
|
let mut vfile = std::fs::File::open(&part_path)
|
||||||
|
.map_err(|e| DomainError::Io(format!("open for verify: {e}")))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = vfile
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| DomainError::Io(format!("read: {e}")))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
let computed: [u8; 32] = hasher.finalize().into();
|
||||||
|
if computed[..] != req.blob_hash[..] {
|
||||||
|
let _ = std::fs::remove_file(&part_path);
|
||||||
|
return Err(DomainError::BlobHashMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize.
|
||||||
|
std::fs::rename(&part_path, &final_path)
|
||||||
|
.map_err(|e| DomainError::Io(format!("rename .part: {e}")))?;
|
||||||
|
|
||||||
|
// Write metadata.
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let meta = BlobMeta {
|
||||||
|
mime_type: req.mime_type,
|
||||||
|
total_size: req.total_size,
|
||||||
|
uploaded_at: now,
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&meta) {
|
||||||
|
let _ = std::fs::write(&meta_path, json.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UploadBlobResp {
|
||||||
|
blob_id: req.blob_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_blob(
|
||||||
|
&self,
|
||||||
|
req: DownloadBlobReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<DownloadBlobResp, DomainError> {
|
||||||
|
if req.blob_id.len() != 32 {
|
||||||
|
return Err(DomainError::BlobHashLength(req.blob_id.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let blob_hex = hex::encode(&req.blob_id);
|
||||||
|
let dir = self.blobs_dir();
|
||||||
|
let blob_path = dir.join(&blob_hex);
|
||||||
|
let meta_path = dir.join(format!("{blob_hex}.meta"));
|
||||||
|
|
||||||
|
if !blob_path.exists() {
|
||||||
|
return Err(DomainError::BlobNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read metadata.
|
||||||
|
let meta_json = std::fs::read_to_string(&meta_path)
|
||||||
|
.map_err(|e| DomainError::Io(format!("read blob metadata: {e}")))?;
|
||||||
|
let meta: BlobMeta = serde_json::from_str(&meta_json)
|
||||||
|
.map_err(|e| DomainError::Io(format!("corrupt blob metadata: {e}")))?;
|
||||||
|
|
||||||
|
// Read chunk.
|
||||||
|
let mut file = std::fs::File::open(&blob_path)
|
||||||
|
.map_err(|e| DomainError::Io(format!("open blob: {e}")))?;
|
||||||
|
let file_len = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|e| DomainError::Io(format!("file metadata: {e}")))?
|
||||||
|
.len();
|
||||||
|
|
||||||
|
if req.offset >= file_len {
|
||||||
|
return Ok(DownloadBlobResp {
|
||||||
|
chunk: vec![],
|
||||||
|
total_size: meta.total_size,
|
||||||
|
mime_type: meta.mime_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
file.seek(SeekFrom::Start(req.offset))
|
||||||
|
.map_err(|e| DomainError::Io(format!("seek: {e}")))?;
|
||||||
|
let remaining = (file_len - req.offset) as usize;
|
||||||
|
let to_read = remaining.min(req.length.min(MAX_DOWNLOAD_CHUNK) as usize);
|
||||||
|
let mut chunk = vec![0u8; to_read];
|
||||||
|
file.read_exact(&mut chunk)
|
||||||
|
.map_err(|e| DomainError::Io(format!("read chunk: {e}")))?;
|
||||||
|
|
||||||
|
Ok(DownloadBlobResp {
|
||||||
|
chunk,
|
||||||
|
total_size: meta.total_size,
|
||||||
|
mime_type: meta.mime_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
crates/quicproquo-server/src/domain/channels.rs
Normal file
38
crates/quicproquo-server/src/domain/channels.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! Channel domain logic — 1:1 DM channel creation.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::storage::Store;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// Domain service for 1:1 channel management.
|
||||||
|
pub struct ChannelService {
|
||||||
|
pub store: Arc<dyn Store>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelService {
|
||||||
|
pub fn create_channel(
|
||||||
|
&self,
|
||||||
|
req: CreateChannelReq,
|
||||||
|
caller_identity_key: &[u8],
|
||||||
|
) -> Result<CreateChannelResp, DomainError> {
|
||||||
|
if req.peer_key.len() != 32 {
|
||||||
|
return Err(DomainError::InvalidIdentityKey(req.peer_key.len()));
|
||||||
|
}
|
||||||
|
if caller_identity_key == req.peer_key.as_slice() {
|
||||||
|
return Err(DomainError::BadParams(
|
||||||
|
"peer_key must not equal caller identity".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (channel_id, was_new) = self
|
||||||
|
.store
|
||||||
|
.create_channel(caller_identity_key, &req.peer_key)?;
|
||||||
|
|
||||||
|
Ok(CreateChannelResp {
|
||||||
|
channel_id,
|
||||||
|
was_new,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
76
crates/quicproquo-server/src/domain/devices.rs
Normal file
76
crates/quicproquo-server/src/domain/devices.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//! Device registry domain logic — register, list, revoke devices.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::storage::Store;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
const MAX_DEVICES_PER_IDENTITY: usize = 5;
|
||||||
|
|
||||||
|
/// Domain service for multi-device management.
|
||||||
|
pub struct DeviceService {
|
||||||
|
pub store: Arc<dyn Store>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceService {
|
||||||
|
pub fn register_device(
|
||||||
|
&self,
|
||||||
|
req: RegisterDeviceReq,
|
||||||
|
caller_identity_key: &[u8],
|
||||||
|
) -> Result<RegisterDeviceResp, DomainError> {
|
||||||
|
if req.device_id.is_empty() {
|
||||||
|
return Err(DomainError::BadParams(
|
||||||
|
"device_id must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = self.store.device_count(caller_identity_key)?;
|
||||||
|
if count >= MAX_DEVICES_PER_IDENTITY {
|
||||||
|
return Err(DomainError::DeviceLimit(MAX_DEVICES_PER_IDENTITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
let success =
|
||||||
|
self.store
|
||||||
|
.register_device(caller_identity_key, &req.device_id, &req.device_name)?;
|
||||||
|
|
||||||
|
Ok(RegisterDeviceResp { success })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_devices(
|
||||||
|
&self,
|
||||||
|
caller_identity_key: &[u8],
|
||||||
|
) -> Result<ListDevicesResp, DomainError> {
|
||||||
|
let raw = self.store.list_devices(caller_identity_key)?;
|
||||||
|
let devices = raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|(device_id, device_name, registered_at)| DeviceInfo {
|
||||||
|
device_id,
|
||||||
|
device_name,
|
||||||
|
registered_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(ListDevicesResp { devices })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_device(
|
||||||
|
&self,
|
||||||
|
req: RevokeDeviceReq,
|
||||||
|
caller_identity_key: &[u8],
|
||||||
|
) -> Result<RevokeDeviceResp, DomainError> {
|
||||||
|
if req.device_id.is_empty() {
|
||||||
|
return Err(DomainError::BadParams(
|
||||||
|
"device_id must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = self
|
||||||
|
.store
|
||||||
|
.revoke_device(caller_identity_key, &req.device_id)?;
|
||||||
|
if !success {
|
||||||
|
return Err(DomainError::DeviceNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RevokeDeviceResp { success })
|
||||||
|
}
|
||||||
|
}
|
||||||
93
crates/quicproquo-server/src/domain/keys.rs
Normal file
93
crates/quicproquo-server/src/domain/keys.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//! Key management domain logic — KeyPackage and hybrid key operations.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::storage::Store;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
const MAX_KEYPACKAGE_BYTES: usize = 1024 * 1024; // 1 MB
|
||||||
|
|
||||||
|
/// Domain service for MLS KeyPackage and hybrid (PQ) key management.
|
||||||
|
pub struct KeyService {
|
||||||
|
pub store: Arc<dyn Store>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyService {
|
||||||
|
pub fn upload_key_package(
|
||||||
|
&self,
|
||||||
|
req: UploadKeyPackageReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<UploadKeyPackageResp, DomainError> {
|
||||||
|
if req.identity_key.len() != 32 {
|
||||||
|
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||||
|
}
|
||||||
|
if req.package.is_empty() {
|
||||||
|
return Err(DomainError::EmptyPackage);
|
||||||
|
}
|
||||||
|
if req.package.len() > MAX_KEYPACKAGE_BYTES {
|
||||||
|
return Err(DomainError::PackageTooLarge(req.package.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fingerprint: Vec<u8> = Sha256::digest(&req.package).to_vec();
|
||||||
|
self.store
|
||||||
|
.upload_key_package(&req.identity_key, req.package)?;
|
||||||
|
|
||||||
|
Ok(UploadKeyPackageResp { fingerprint })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_key_package(
|
||||||
|
&self,
|
||||||
|
req: FetchKeyPackageReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<FetchKeyPackageResp, DomainError> {
|
||||||
|
let package = self.store.fetch_key_package(&req.identity_key)?;
|
||||||
|
Ok(FetchKeyPackageResp {
|
||||||
|
package: package.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upload_hybrid_key(
|
||||||
|
&self,
|
||||||
|
req: UploadHybridKeyReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if req.identity_key.len() != 32 {
|
||||||
|
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||||
|
}
|
||||||
|
if req.hybrid_public_key.is_empty() {
|
||||||
|
return Err(DomainError::EmptyHybridKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.store
|
||||||
|
.upload_hybrid_key(&req.identity_key, req.hybrid_public_key)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_hybrid_key(
|
||||||
|
&self,
|
||||||
|
req: FetchHybridKeyReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<FetchHybridKeyResp, DomainError> {
|
||||||
|
let hybrid_public_key = self
|
||||||
|
.store
|
||||||
|
.fetch_hybrid_key(&req.identity_key)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(FetchHybridKeyResp { hybrid_public_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_hybrid_keys(
|
||||||
|
&self,
|
||||||
|
req: FetchHybridKeysReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<FetchHybridKeysResp, DomainError> {
|
||||||
|
let mut keys = Vec::with_capacity(req.identity_keys.len());
|
||||||
|
for ik in &req.identity_keys {
|
||||||
|
let pk = self.store.fetch_hybrid_key(ik)?.unwrap_or_default();
|
||||||
|
keys.push(pk);
|
||||||
|
}
|
||||||
|
Ok(FetchHybridKeysResp { keys })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,10 @@
|
|||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod delivery;
|
pub mod delivery;
|
||||||
|
pub mod keys;
|
||||||
|
pub mod channels;
|
||||||
|
pub mod users;
|
||||||
|
pub mod blobs;
|
||||||
|
pub mod devices;
|
||||||
|
pub mod p2p;
|
||||||
|
pub mod account;
|
||||||
|
|||||||
50
crates/quicproquo-server/src/domain/p2p.rs
Normal file
50
crates/quicproquo-server/src/domain/p2p.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! P2P endpoint domain logic — publish, resolve, health.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::storage::Store;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// Domain service for P2P endpoint management and health checks.
|
||||||
|
pub struct P2pService {
|
||||||
|
pub store: Arc<dyn Store>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl P2pService {
|
||||||
|
pub fn publish_endpoint(
|
||||||
|
&self,
|
||||||
|
req: PublishEndpointReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if req.identity_key.len() != 32 {
|
||||||
|
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.store
|
||||||
|
.publish_endpoint(&req.identity_key, req.node_addr)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_endpoint(
|
||||||
|
&self,
|
||||||
|
req: ResolveEndpointReq,
|
||||||
|
_auth: &CallerAuth,
|
||||||
|
) -> Result<ResolveEndpointResp, DomainError> {
|
||||||
|
if req.identity_key.len() != 32 {
|
||||||
|
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let node_addr = self
|
||||||
|
.store
|
||||||
|
.resolve_endpoint(&req.identity_key)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(ResolveEndpointResp { node_addr })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn health() -> HealthResp {
|
||||||
|
HealthResp {
|
||||||
|
status: "ok".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,56 @@
|
|||||||
//!
|
//!
|
||||||
//! No proto, no capnp — just Rust structs.
|
//! No proto, no capnp — just Rust structs.
|
||||||
|
|
||||||
|
use crate::storage::StorageError;
|
||||||
|
|
||||||
|
// ── Domain Error ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Errors returned by domain service methods.
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("identity key must be exactly 32 bytes, got {0}")]
|
||||||
|
InvalidIdentityKey(usize),
|
||||||
|
|
||||||
|
#[error("key package must not be empty")]
|
||||||
|
EmptyPackage,
|
||||||
|
|
||||||
|
#[error("key package exceeds max size ({0} bytes)")]
|
||||||
|
PackageTooLarge(usize),
|
||||||
|
|
||||||
|
#[error("hybrid public key must not be empty")]
|
||||||
|
EmptyHybridKey,
|
||||||
|
|
||||||
|
#[error("username must not be empty")]
|
||||||
|
EmptyUsername,
|
||||||
|
|
||||||
|
#[error("blob hash must be exactly 32 bytes, got {0}")]
|
||||||
|
BlobHashLength(usize),
|
||||||
|
|
||||||
|
#[error("blob exceeds max size ({0} bytes)")]
|
||||||
|
BlobTooLarge(u64),
|
||||||
|
|
||||||
|
#[error("SHA-256 of uploaded data does not match blob hash")]
|
||||||
|
BlobHashMismatch,
|
||||||
|
|
||||||
|
#[error("blob not found")]
|
||||||
|
BlobNotFound,
|
||||||
|
|
||||||
|
#[error("maximum {0} devices per identity")]
|
||||||
|
DeviceLimit(usize),
|
||||||
|
|
||||||
|
#[error("device not found")]
|
||||||
|
DeviceNotFound,
|
||||||
|
|
||||||
|
#[error("bad parameters: {0}")]
|
||||||
|
BadParams(String),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
|
||||||
|
#[error("storage error: {0}")]
|
||||||
|
Storage(#[from] StorageError),
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Caller authentication context (resolved from session token).
|
/// Caller authentication context (resolved from session token).
|
||||||
|
|||||||
63
crates/quicproquo-server/src/domain/users.rs
Normal file
63
crates/quicproquo-server/src/domain/users.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! User resolution domain logic — username <-> identity key lookups.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use quicproquo_kt::MerkleLog;
|
||||||
|
|
||||||
|
use crate::storage::Store;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// Domain service for user/identity resolution.
|
||||||
|
pub struct UserService {
|
||||||
|
pub store: Arc<dyn Store>,
|
||||||
|
pub kt_log: Arc<Mutex<MerkleLog>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserService {
|
||||||
|
pub fn resolve_user(&self, req: ResolveUserReq) -> Result<ResolveUserResp, DomainError> {
|
||||||
|
if req.username.is_empty() {
|
||||||
|
return Err(DomainError::EmptyUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity_key = self
|
||||||
|
.store
|
||||||
|
.get_user_identity_key(&req.username)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut inclusion_proof = Vec::new();
|
||||||
|
|
||||||
|
if !identity_key.is_empty() {
|
||||||
|
if let Ok(log) = self.kt_log.lock() {
|
||||||
|
if let Some(leaf_idx) = log.find(&req.username, &identity_key) {
|
||||||
|
if let Ok(proof) = log.inclusion_proof(leaf_idx) {
|
||||||
|
if let Ok(bytes) = proof.to_bytes() {
|
||||||
|
inclusion_proof = bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResolveUserResp {
|
||||||
|
identity_key,
|
||||||
|
inclusion_proof,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_identity(
|
||||||
|
&self,
|
||||||
|
req: ResolveIdentityReq,
|
||||||
|
) -> Result<ResolveIdentityResp, DomainError> {
|
||||||
|
if req.identity_key.len() != 32 {
|
||||||
|
return Err(DomainError::InvalidIdentityKey(req.identity_key.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = self
|
||||||
|
.store
|
||||||
|
.resolve_identity_key(&req.identity_key)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(ResolveIdentityResp { username })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user