use serde::{Deserialize, Serialize};
use vta_sdk::webvh::{WebvhDidRecord, WebvhServerRecord};
use zeroize::ZeroizeOnDrop;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
fn server_key(id: &str) -> String {
format!("server:{id}")
}
fn server_auth_key(id: &str) -> String {
format!("server-auth:{id}")
}
fn did_key(did: &str) -> String {
format!("did:{did}")
}
fn log_key(did: &str) -> String {
format!("log:{did}")
}
#[derive(Clone, Serialize, Deserialize, ZeroizeOnDrop)]
pub struct WebvhServerAuthRecord {
pub server_id: String,
pub access_token: String,
pub access_expires_at: u64,
pub refresh_token: String,
pub refresh_expires_at: u64,
}
impl std::fmt::Debug for WebvhServerAuthRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebvhServerAuthRecord")
.field("server_id", &self.server_id)
.field("access_token", &"<redacted>")
.field("access_expires_at", &self.access_expires_at)
.field("refresh_token", &"<redacted>")
.field("refresh_expires_at", &self.refresh_expires_at)
.finish()
}
}
pub async fn get_server(
ks: &KeyspaceHandle,
id: &str,
) -> Result<Option<WebvhServerRecord>, AppError> {
ks.get(server_key(id)).await
}
pub async fn store_server(ks: &KeyspaceHandle, record: &WebvhServerRecord) -> Result<(), AppError> {
ks.insert(server_key(&record.id), record).await
}
pub async fn delete_server(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
ks.remove(server_key(id)).await?;
ks.remove(server_auth_key(id)).await?;
Ok(())
}
pub async fn get_server_auth(
ks: &KeyspaceHandle,
id: &str,
) -> Result<Option<WebvhServerAuthRecord>, AppError> {
ks.get(server_auth_key(id)).await
}
pub async fn store_server_auth(
ks: &KeyspaceHandle,
record: &WebvhServerAuthRecord,
) -> Result<(), AppError> {
ks.insert(server_auth_key(&record.server_id), record).await
}
pub async fn delete_server_auth(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
ks.remove(server_auth_key(id)).await
}
pub async fn list_servers(ks: &KeyspaceHandle) -> Result<Vec<WebvhServerRecord>, AppError> {
let raw = ks.prefix_iter_raw("server:").await?;
let mut servers = Vec::with_capacity(raw.len());
for (_key, value) in raw {
let record: WebvhServerRecord = serde_json::from_slice(&value)?;
servers.push(record);
}
Ok(servers)
}
pub async fn get_did(ks: &KeyspaceHandle, did: &str) -> Result<Option<WebvhDidRecord>, AppError> {
ks.get(did_key(did)).await
}
pub async fn store_did(ks: &KeyspaceHandle, record: &WebvhDidRecord) -> Result<(), AppError> {
ks.insert(did_key(&record.did), record).await
}
pub async fn delete_did(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
ks.remove(did_key(did)).await
}
pub async fn list_dids(ks: &KeyspaceHandle) -> Result<Vec<WebvhDidRecord>, AppError> {
let raw = ks.prefix_iter_raw("did:").await?;
let mut dids = Vec::with_capacity(raw.len());
for (_key, value) in raw {
let record: WebvhDidRecord = serde_json::from_slice(&value)?;
dids.push(record);
}
Ok(dids)
}
pub async fn get_did_log(ks: &KeyspaceHandle, did: &str) -> Result<Option<String>, AppError> {
let bytes = ks.get_raw(log_key(did)).await?;
Ok(bytes.map(|b| String::from_utf8_lossy(&b).into_owned()))
}
pub async fn store_did_log(
ks: &KeyspaceHandle,
did: &str,
log_content: &str,
) -> Result<(), AppError> {
ks.insert_raw(log_key(did), log_content.as_bytes().to_vec())
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use chrono::Utc;
use vti_common::config::StoreConfig as VtiStoreConfig;
async fn setup_ks() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let ks = store.keyspace(crate::keyspaces::WEBVH).unwrap();
(dir, ks)
}
fn sample_server(id: &str) -> WebvhServerRecord {
let now = Utc::now();
WebvhServerRecord {
id: id.into(),
did: format!("did:web:{id}.example"),
label: None,
created_at: now,
updated_at: now,
}
}
fn sample_auth(id: &str) -> WebvhServerAuthRecord {
WebvhServerAuthRecord {
server_id: id.into(),
access_token: "test-access-token".into(),
access_expires_at: 9_999_999_999,
refresh_token: "test-refresh-token".into(),
refresh_expires_at: 9_999_999_999,
}
}
#[tokio::test]
async fn auth_record_round_trips_through_keyspace() {
let (_dir, ks) = setup_ks().await;
let auth = sample_auth("prod");
store_server_auth(&ks, &auth).await.unwrap();
let loaded = get_server_auth(&ks, "prod").await.unwrap().unwrap();
assert_eq!(loaded.access_token, "test-access-token");
assert_eq!(loaded.refresh_token, "test-refresh-token");
}
#[tokio::test]
async fn auth_record_uses_distinct_keyspace_prefix_from_server() {
let (_dir, ks) = setup_ks().await;
store_server(&ks, &sample_server("prod")).await.unwrap();
store_server_auth(&ks, &sample_auth("prod")).await.unwrap();
let servers = list_servers(&ks).await.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].id, "prod");
}
#[tokio::test]
async fn delete_server_cascades_to_auth_record() {
let (_dir, ks) = setup_ks().await;
store_server(&ks, &sample_server("prod")).await.unwrap();
store_server_auth(&ks, &sample_auth("prod")).await.unwrap();
assert!(get_server_auth(&ks, "prod").await.unwrap().is_some());
delete_server(&ks, "prod").await.unwrap();
assert!(get_server(&ks, "prod").await.unwrap().is_none());
assert!(
get_server_auth(&ks, "prod").await.unwrap().is_none(),
"delete_server must cascade to the auth record"
);
}
#[tokio::test]
async fn explicit_delete_server_auth_works_independently() {
let (_dir, ks) = setup_ks().await;
store_server(&ks, &sample_server("prod")).await.unwrap();
store_server_auth(&ks, &sample_auth("prod")).await.unwrap();
delete_server_auth(&ks, "prod").await.unwrap();
assert!(get_server(&ks, "prod").await.unwrap().is_some());
assert!(get_server_auth(&ks, "prod").await.unwrap().is_none());
}
#[test]
fn auth_record_debug_redacts_secret_fields() {
let auth = WebvhServerAuthRecord {
server_id: "prod".into(),
access_token: "should-not-appear-in-logs-XXXX".into(),
access_expires_at: 1234,
refresh_token: "also-not-here-YYYY".into(),
refresh_expires_at: 5678,
};
let dbg = format!("{auth:?}");
assert!(
!dbg.contains("XXXX"),
"access_token must not appear in Debug: {dbg}"
);
assert!(
!dbg.contains("YYYY"),
"refresh_token must not appear in Debug: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"Debug should mark redactions explicitly: {dbg}"
);
assert!(dbg.contains("1234"), "non-secret expiry must remain: {dbg}");
assert!(
dbg.contains("prod"),
"non-secret server_id must remain: {dbg}"
);
}
}