Skip to main content

vta_sdk/
webvh.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Operator-visible metadata for a registered webvh hosting server.
5///
6/// **Public surface — never carry secret material.** Bearer tokens,
7/// refresh tokens, and token-expiry timestamps for the daemon REST
8/// auth flow live in a separate service-internal record
9/// (`vta_service::webvh_store::WebvhServerAuthRecord`, keyspace prefix
10/// `server-auth:`), not on this type. The split keeps tokens out of:
11///
12/// - REST `GET /webvh/servers` list responses,
13/// - DIDComm `webvh.servers.list` results,
14/// - Backup export payloads,
15/// - Any future SDK consumer that reads `WebvhServerRecord`.
16///
17/// Legacy records on disk may still carry `access_token` /
18/// `access_expires_at` / `refresh_token` fields embedded inline.
19/// Serde's default behaviour ignores unknown fields, so those
20/// legacy records deserialize cleanly into the new shape — the
21/// embedded tokens are silently dropped on read. The VTA's restore
22/// path explicitly wipes the `server-auth:` keyspace so a backup
23/// from another VTA can't replay stale tokens here.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26pub struct WebvhServerRecord {
27    pub id: String,
28    pub did: String,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub label: Option<String>,
31    pub created_at: DateTime<Utc>,
32    pub updated_at: DateTime<Utc>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37pub struct WebvhDidRecord {
38    pub did: String,
39    pub server_id: String,
40    pub mnemonic: String,
41    pub scid: String,
42    pub context_id: String,
43    pub portable: bool,
44    pub log_entry_count: u32,
45    /// Number of pre-rotation keys committed by the most recent log
46    /// entry (matches `next_key_hashes.len()` of that entry). `0` means
47    /// pre-rotation is disabled. Defaults to `0` for legacy records
48    /// written before this field existed; the next update reads the
49    /// effective value off the loaded log entry and persists it back.
50    #[serde(default)]
51    pub pre_rotation_count: u32,
52    /// Next monotonically-increasing fragment id to use when minting a
53    /// new verificationMethod (`#key-{n}`). Stays stable for the
54    /// lifetime of the DID; never decremented. Defaults to `1` for
55    /// legacy records — the next rotate-keys call performs a one-shot
56    /// scan of the existing document and persists the correct value.
57    #[serde(default = "default_next_fragment_id")]
58    pub next_fragment_id: u32,
59    pub created_at: DateTime<Utc>,
60    pub updated_at: DateTime<Utc>,
61}
62
63fn default_next_fragment_id() -> u32 {
64    1
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    /// `WebvhServerRecord` must never serialise token material on the
72    /// wire, even if somehow given a record with those fields populated.
73    /// We removed the fields from the struct entirely so the type
74    /// system itself enforces this — pin the invariant.
75    #[test]
76    fn server_record_round_trips_without_token_fields() {
77        let now = Utc::now();
78        let r = WebvhServerRecord {
79            id: "prod".into(),
80            did: "did:web:daemon.example".into(),
81            label: Some("prod hosting".into()),
82            created_at: now,
83            updated_at: now,
84        };
85        let json = serde_json::to_string(&r).unwrap();
86        assert!(
87            !json.contains("access_token"),
88            "access_token must not appear in serialised form: {json}"
89        );
90        assert!(
91            !json.contains("refresh_token"),
92            "refresh_token must not appear: {json}"
93        );
94        assert!(
95            !json.contains("access_expires_at"),
96            "access_expires_at must not appear: {json}"
97        );
98    }
99
100    /// Legacy on-disk records (and legacy backups) may have embedded
101    /// token fields. Serde's default behaviour ignores unknown fields,
102    /// so the load path drops them silently — the new shape doesn't
103    /// hold those values. Pin the invariant so a future
104    /// `#[serde(deny_unknown_fields)]` doesn't reintroduce the leak.
105    #[test]
106    fn legacy_server_record_with_embedded_tokens_deserializes_cleanly() {
107        let json = r#"{
108            "id": "prod",
109            "did": "did:web:daemon.example",
110            "label": "prod",
111            "access_token": "leaky-access-token",
112            "access_expires_at": 9999999999,
113            "refresh_token": "leaky-refresh-token",
114            "created_at": "2026-05-01T00:00:00Z",
115            "updated_at": "2026-05-01T00:00:00Z"
116        }"#;
117        let r: WebvhServerRecord = serde_json::from_str(json).expect("must accept legacy fields");
118        assert_eq!(r.id, "prod");
119        assert_eq!(r.did, "did:web:daemon.example");
120        // Round-tripping the deserialised form drops the legacy fields.
121        let reserialised = serde_json::to_string(&r).unwrap();
122        assert!(
123            !reserialised.contains("access_token"),
124            "legacy fields must be dropped on re-serialise: {reserialised}"
125        );
126        assert!(
127            !reserialised.contains("refresh_token"),
128            "legacy fields must be dropped: {reserialised}"
129        );
130    }
131
132    #[test]
133    fn legacy_record_loads_with_default_pre_rotation_and_fragment_id() {
134        // A record written by an earlier version of the VTA carries
135        // neither `pre_rotation_count` nor `next_fragment_id`. Serde
136        // defaults must let it deserialize cleanly so existing DIDs
137        // keep working.
138        let json = r#"{
139            "did": "did:webvh:Q...:vta.example.com:primary",
140            "server_id": "serverless",
141            "mnemonic": "",
142            "scid": "Q...",
143            "context_id": "primary",
144            "portable": false,
145            "log_entry_count": 1,
146            "created_at": "2026-01-01T00:00:00Z",
147            "updated_at": "2026-01-01T00:00:00Z"
148        }"#;
149        let r: WebvhDidRecord = serde_json::from_str(json).unwrap();
150        assert_eq!(r.pre_rotation_count, 0);
151        assert_eq!(r.next_fragment_id, 1);
152    }
153
154    #[test]
155    fn record_with_new_fields_round_trips() {
156        let r = WebvhDidRecord {
157            did: "did:webvh:abc:vta.example.com:primary".into(),
158            server_id: "serverless".into(),
159            mnemonic: "".into(),
160            scid: "abc".into(),
161            context_id: "primary".into(),
162            portable: false,
163            log_entry_count: 3,
164            pre_rotation_count: 2,
165            next_fragment_id: 5,
166            created_at: Utc::now(),
167            updated_at: Utc::now(),
168        };
169        let json = serde_json::to_string(&r).unwrap();
170        let restored: WebvhDidRecord = serde_json::from_str(&json).unwrap();
171        assert_eq!(restored.pre_rotation_count, 2);
172        assert_eq!(restored.next_fragment_id, 5);
173        assert_eq!(restored.log_entry_count, 3);
174    }
175}