A lexicon-driven AppView for ATProto.
1use crate::db::{DatabaseBackend, adapt_sql};
2use crate::error::AppError;
3use serde::{Deserialize, Serialize};
4use sqlx::AnyPool;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7#[serde(rename_all = "snake_case")]
8pub enum IdentityMode {
9 DidWeb,
10 DidPlc,
11 AttachAccount,
12 NotExposed,
13}
14
15impl IdentityMode {
16 pub fn as_str(&self) -> &'static str {
17 match self {
18 Self::DidWeb => "did_web",
19 Self::DidPlc => "did_plc",
20 Self::AttachAccount => "attach_account",
21 Self::NotExposed => "not_exposed",
22 }
23 }
24
25 pub fn parse(s: &str) -> Option<Self> {
26 match s {
27 "did_web" => Some(Self::DidWeb),
28 "did_plc" => Some(Self::DidPlc),
29 "attach_account" => Some(Self::AttachAccount),
30 "not_exposed" => Some(Self::NotExposed),
31 _ => None,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ServiceIdentity {
38 pub mode: IdentityMode,
39 pub did: Option<String>,
40 pub signing_key_enc: Option<String>,
41 pub attached_account_did: Option<String>,
42 pub setup_complete: bool,
43 pub created_at: String,
44 pub updated_at: String,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct SetupStatus {
49 pub identity_mode: Option<IdentityMode>,
50 pub identity_configured: bool,
51 pub plc_verified: bool,
52 pub setup_complete: bool,
53}
54
55type ServiceIdentityRow = (
56 String,
57 Option<String>,
58 Option<String>,
59 Option<String>,
60 i32,
61 String,
62 String,
63);
64
65fn parse_row(r: ServiceIdentityRow) -> Result<ServiceIdentity, AppError> {
66 let mode = IdentityMode::parse(&r.0)
67 .ok_or_else(|| AppError::Internal(format!("invalid identity mode: {}", r.0)))?;
68 Ok(ServiceIdentity {
69 mode,
70 did: r.1,
71 signing_key_enc: r.2,
72 attached_account_did: r.3,
73 setup_complete: r.4 != 0,
74 created_at: r.5,
75 updated_at: r.6,
76 })
77}
78
79/// Fetch the service identity row (id = 1), if it exists.
80pub async fn get_identity(
81 db: &AnyPool,
82 backend: DatabaseBackend,
83) -> Result<Option<ServiceIdentity>, AppError> {
84 let sql = adapt_sql(
85 "SELECT mode, did, signing_key_enc, attached_account_did, CAST(setup_complete AS INTEGER), created_at, updated_at FROM happyview_service_identity WHERE id = 1",
86 backend,
87 );
88
89 let row: Option<ServiceIdentityRow> = crate::db::query_as(&sql)
90 .fetch_optional(db)
91 .await
92 .map_err(|e| AppError::Internal(format!("failed to get service identity: {e}")))?;
93
94 row.map(parse_row).transpose()
95}
96
97/// Derive setup status from the current identity row.
98pub async fn get_setup_status(
99 db: &AnyPool,
100 backend: DatabaseBackend,
101) -> Result<SetupStatus, AppError> {
102 let identity = get_identity(db, backend).await?;
103
104 match identity {
105 None => Ok(SetupStatus {
106 identity_mode: None,
107 identity_configured: false,
108 plc_verified: false,
109 setup_complete: false,
110 }),
111 Some(id) => {
112 let plc_verified = matches!(id.mode, IdentityMode::DidPlc) && id.setup_complete;
113 let identity_configured = match id.mode {
114 IdentityMode::DidWeb => id.signing_key_enc.is_some(),
115 _ => id.did.is_some(),
116 };
117 let setup_complete = id.setup_complete;
118 let identity_mode = Some(id.mode);
119 Ok(SetupStatus {
120 identity_mode,
121 identity_configured,
122 plc_verified,
123 setup_complete,
124 })
125 }
126 }
127}
128
129/// Insert or update the service identity row (always resets setup_complete to FALSE).
130#[allow(clippy::too_many_arguments)]
131pub async fn upsert_identity(
132 db: &AnyPool,
133 backend: DatabaseBackend,
134 mode: &IdentityMode,
135 did: Option<&str>,
136 signing_key_enc: Option<&str>,
137 rotation_key_enc: Option<&str>,
138 attached_account_did: Option<&str>,
139) -> Result<(), AppError> {
140 let now = chrono::Utc::now().to_rfc3339();
141 let sql = adapt_sql(
142 "INSERT INTO happyview_service_identity (id, mode, did, signing_key_enc, rotation_key_enc, attached_account_did, setup_complete, created_at, updated_at)
143 VALUES (1, ?, ?, ?, ?, ?, FALSE, ?, ?)
144 ON CONFLICT (id) DO UPDATE SET
145 mode = excluded.mode,
146 did = excluded.did,
147 signing_key_enc = excluded.signing_key_enc,
148 rotation_key_enc = excluded.rotation_key_enc,
149 attached_account_did = excluded.attached_account_did,
150 setup_complete = excluded.setup_complete,
151 updated_at = excluded.updated_at",
152 backend,
153 );
154
155 crate::db::query(&sql)
156 .bind(mode.as_str())
157 .bind(did)
158 .bind(signing_key_enc)
159 .bind(rotation_key_enc)
160 .bind(attached_account_did)
161 .bind(&now)
162 .bind(&now)
163 .execute(db)
164 .await
165 .map_err(|e| AppError::Internal(format!("failed to upsert service identity: {e}")))?;
166
167 Ok(())
168}
169
170/// Mark setup as complete for the service identity row.
171pub async fn mark_setup_complete(db: &AnyPool, backend: DatabaseBackend) -> Result<(), AppError> {
172 let now = chrono::Utc::now().to_rfc3339();
173 let sql = adapt_sql(
174 "UPDATE happyview_service_identity SET setup_complete = TRUE, updated_at = ? WHERE id = 1",
175 backend,
176 );
177
178 crate::db::query(&sql)
179 .bind(&now)
180 .execute(db)
181 .await
182 .map_err(|e| AppError::Internal(format!("failed to mark setup complete: {e}")))?;
183
184 Ok(())
185}
186
187/// Generate a DID document for did:web identity mode.
188/// The DID is derived dynamically from the request host rather than stored,
189/// so the same signing key works across any domain pointing at this server.
190/// Returns None if the identity mode is not DidWeb.
191///
192/// `extra_verification_methods` is a slice of (fragment_id, key_type, public_key_multibase)
193/// tuples for additional verification methods (e.g. `#atproto_space`).
194pub fn generate_did_document(
195 identity: &ServiceIdentity,
196 host: &str,
197 signing_key_multibase: &str,
198 service_entries: &[(String, String)],
199 service_endpoint: &str,
200 extra_verification_methods: &[(String, String, String)],
201) -> Option<serde_json::Value> {
202 if identity.mode != IdentityMode::DidWeb {
203 return None;
204 }
205
206 let did = format!("did:web:{}", host.replace(':', "%3A"));
207
208 let mut verification_methods: Vec<serde_json::Value> = vec![serde_json::json!({
209 "id": format!("{did}#atproto"),
210 "type": "Multikey",
211 "controller": &did,
212 "publicKeyMultibase": signing_key_multibase
213 })];
214
215 for (fragment_id, key_type, public_key_multibase) in extra_verification_methods {
216 verification_methods.push(serde_json::json!({
217 "id": format!("{did}{fragment_id}"),
218 "type": key_type,
219 "controller": &did,
220 "publicKeyMultibase": public_key_multibase
221 }));
222 }
223
224 let services: Vec<serde_json::Value> = service_entries
225 .iter()
226 .map(|(fragment, svc_type)| {
227 serde_json::json!({
228 "id": fragment,
229 "type": svc_type,
230 "serviceEndpoint": service_endpoint
231 })
232 })
233 .collect();
234
235 Some(serde_json::json!({
236 "@context": [
237 "https://www.w3.org/ns/did/v1",
238 "https://w3id.org/security/multikey/v1"
239 ],
240 "id": &did,
241 "verificationMethod": verification_methods,
242 "service": services
243 }))
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn make_identity(mode: IdentityMode, did: Option<&str>) -> ServiceIdentity {
251 ServiceIdentity {
252 mode,
253 did: did.map(String::from),
254 signing_key_enc: None,
255 attached_account_did: None,
256 setup_complete: true,
257 created_at: "2024-01-01".into(),
258 updated_at: "2024-01-01".into(),
259 }
260 }
261
262 #[test]
263 fn identity_mode_roundtrip() {
264 for mode in [
265 IdentityMode::DidWeb,
266 IdentityMode::DidPlc,
267 IdentityMode::AttachAccount,
268 IdentityMode::NotExposed,
269 ] {
270 let s = mode.as_str();
271 let parsed = IdentityMode::parse(s).unwrap();
272 assert_eq!(parsed, mode);
273 }
274 }
275
276 #[test]
277 fn identity_mode_from_str_invalid() {
278 assert!(IdentityMode::parse("invalid").is_none());
279 assert!(IdentityMode::parse("").is_none());
280 }
281
282 #[test]
283 fn generate_did_document_returns_none_for_non_web() {
284 let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123"));
285 assert!(
286 generate_did_document(
287 &identity,
288 "example.com",
289 "zKey",
290 &[],
291 "https://example.com",
292 &[]
293 )
294 .is_none()
295 );
296 }
297
298 #[test]
299 fn generate_did_document_derives_did_from_host() {
300 let identity = make_identity(IdentityMode::DidWeb, None);
301 let doc = generate_did_document(
302 &identity,
303 "example.com",
304 "zKey123",
305 &[],
306 "https://example.com",
307 &[],
308 )
309 .unwrap();
310 assert_eq!(doc["id"], "did:web:example.com");
311 }
312
313 #[test]
314 fn generate_did_document_with_no_entries() {
315 let identity = make_identity(IdentityMode::DidWeb, None);
316 let doc = generate_did_document(
317 &identity,
318 "example.com",
319 "zKey123",
320 &[],
321 "https://example.com",
322 &[],
323 )
324 .unwrap();
325 assert_eq!(doc["id"], "did:web:example.com");
326 assert_eq!(
327 doc["verificationMethod"][0]["publicKeyMultibase"],
328 "zKey123"
329 );
330 assert_eq!(doc["service"].as_array().unwrap().len(), 0);
331 }
332
333 #[test]
334 fn generate_did_document_with_entries() {
335 let identity = make_identity(IdentityMode::DidWeb, None);
336 let entries = vec![
337 ("#chess".to_string(), "ChessService".to_string()),
338 ("#checkers".to_string(), "CheckersService".to_string()),
339 ];
340 let doc = generate_did_document(
341 &identity,
342 "example.com",
343 "zKey123",
344 &entries,
345 "https://example.com",
346 &[],
347 )
348 .unwrap();
349 let services = doc["service"].as_array().unwrap();
350 assert_eq!(services.len(), 2);
351 assert_eq!(services[0]["id"], "#chess");
352 assert_eq!(services[0]["type"], "ChessService");
353 assert_eq!(services[0]["serviceEndpoint"], "https://example.com");
354 assert_eq!(services[1]["id"], "#checkers");
355 }
356
357 #[test]
358 fn generate_did_document_context_and_structure() {
359 let identity = make_identity(IdentityMode::DidWeb, None);
360 let doc = generate_did_document(
361 &identity,
362 "example.com",
363 "zKey",
364 &[],
365 "https://example.com",
366 &[],
367 )
368 .unwrap();
369 let context = doc["@context"].as_array().unwrap();
370 assert_eq!(context.len(), 2);
371 assert_eq!(context[0], "https://www.w3.org/ns/did/v1");
372 assert_eq!(context[1], "https://w3id.org/security/multikey/v1");
373
374 let vm = &doc["verificationMethod"][0];
375 assert_eq!(vm["id"], "did:web:example.com#atproto");
376 assert_eq!(vm["type"], "Multikey");
377 assert_eq!(vm["controller"], "did:web:example.com");
378 }
379
380 #[test]
381 fn generate_did_document_includes_extra_verification_methods() {
382 let identity = make_identity(IdentityMode::DidWeb, None);
383 let extra = vec![(
384 "#atproto_space".to_string(),
385 "Multikey".to_string(),
386 "zExtraKey".to_string(),
387 )];
388 let doc = generate_did_document(
389 &identity,
390 "example.com",
391 "zKey123",
392 &[],
393 "https://example.com",
394 &extra,
395 )
396 .unwrap();
397 let vms = doc["verificationMethod"].as_array().unwrap();
398 assert_eq!(vms.len(), 2);
399 assert_eq!(vms[0]["id"], "did:web:example.com#atproto");
400 assert_eq!(vms[1]["id"], "did:web:example.com#atproto_space");
401 assert_eq!(vms[1]["publicKeyMultibase"], "zExtraKey");
402 assert_eq!(vms[1]["type"], "Multikey");
403 }
404}