use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RegisteredPasskey {
pub credential_id: String,
pub label: String,
#[serde(default)]
pub transports: Vec<String>,
pub registered_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminEntry {
pub did: String,
#[serde(default)]
pub passkeys: Vec<RegisteredPasskey>,
#[serde(default)]
pub extensions: Value,
pub created_at: DateTime<Utc>,
}
impl AdminEntry {
pub fn new(did: impl Into<String>) -> Self {
Self {
did: did.into(),
passkeys: Vec::new(),
extensions: Value::Null,
created_at: Utc::now(),
}
}
}
const PREFIX: &[u8] = b"admin:";
fn key(did: &str) -> Vec<u8> {
let mut k = PREFIX.to_vec();
k.extend_from_slice(did.as_bytes());
k
}
pub async fn get_admin_entry(
ks: &KeyspaceHandle,
did: &str,
) -> Result<Option<AdminEntry>, AppError> {
ks.get(key(did)).await
}
pub async fn store_admin_entry(ks: &KeyspaceHandle, entry: &AdminEntry) -> Result<(), AppError> {
ks.insert(key(&entry.did), entry).await
}
pub async fn list_admin_entries(ks: &KeyspaceHandle) -> Result<Vec<AdminEntry>, AppError> {
let raw = ks.prefix_iter_raw(PREFIX.to_vec()).await?;
let mut out = Vec::with_capacity(raw.len());
for (_k, v) in raw {
match serde_json::from_slice(&v) {
Ok(e) => out.push(e),
Err(err) => tracing::warn!(error = %err, "skipping unparseable admin entry"),
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&cfg).expect("store");
let ks = store.keyspace("passkey-admin-test").expect("ks");
(ks, dir)
}
fn sample_passkey(cred_id: &str, label: &str) -> RegisteredPasskey {
RegisteredPasskey {
credential_id: cred_id.into(),
label: label.into(),
transports: vec!["internal".into()],
registered_at: DateTime::parse_from_rfc3339("2026-05-12T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
last_used_at: None,
}
}
#[tokio::test]
async fn round_trip_stores_and_retrieves() {
let (ks, _dir) = temp_ks();
let mut entry = AdminEntry::new("did:key:zAdmin");
entry.passkeys.push(sample_passkey("deadbeef", "yubikey"));
entry.extensions = serde_json::json!({"team": "platform"});
store_admin_entry(&ks, &entry).await.unwrap();
let got = get_admin_entry(&ks, "did:key:zAdmin")
.await
.unwrap()
.expect("present");
assert_eq!(got, entry);
}
#[tokio::test]
async fn list_returns_every_admin() {
let (ks, _dir) = temp_ks();
for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
store_admin_entry(&ks, &AdminEntry::new(did)).await.unwrap();
}
let entries = list_admin_entries(&ks).await.unwrap();
assert_eq!(entries.len(), 3);
let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
for d in ["did:key:zA", "did:key:zB", "did:key:zC"] {
assert!(dids.contains(d));
}
}
#[test]
fn deserialises_legacy_shape_without_extensions() {
let legacy = r#"{
"did": "did:key:zLegacy",
"createdAt": "2026-05-12T00:00:00Z"
}"#;
let entry: AdminEntry = serde_json::from_str(legacy).expect("legacy shape parses");
assert_eq!(entry.did, "did:key:zLegacy");
assert!(entry.passkeys.is_empty());
assert_eq!(entry.extensions, Value::Null);
}
#[test]
fn passkey_serialises_to_camel_case() {
let pk = sample_passkey("ab", "label");
let json = serde_json::to_value(&pk).unwrap();
assert!(json["credentialId"].is_string());
assert!(json["registeredAt"].is_string());
assert!(json["lastUsedAt"].is_null());
}
}