Skip to main content

dtg_credentials/
create.rs

1/*!
2*   Builder methods for creating new entities.
3*/
4
5#[allow(deprecated)]
6use crate::{
7    AuthorityGrant, CredentialSubject, CredentialSubjectAuthority, CredentialSubjectBasic,
8    CredentialSubjectEndorsement, CredentialSubjectMembership, CredentialSubjectRCard,
9    CredentialSubjectWitness, DTGCommon, DTGCredential, DTGCredentialError, DTGCredentialType,
10    WitnessContext,
11};
12use chrono::{DateTime, Utc};
13use serde_json::Value;
14
15impl DTGCredential {
16    /// Creates a new community-issued Verifiable Membership Credential (VMC) — the
17    /// membership **grant**, the community → member half of a membership edge.
18    ///
19    /// A membership edge is a *pair* of VMCs, and this is only one of them. The member
20    /// answers with [DTGCredential::new_member_vmc], and the edge is not complete until
21    /// they have: a community can always issue a credential naming somebody as a member,
22    /// but it cannot produce the acknowledgement without that party's signature. The pair
23    /// is what makes an unconsented membership claim unprovable.
24    ///
25    /// The grant MUST NOT carry a `digest` — that property is what marks the other
26    /// direction — and this constructor does not set one.
27    ///
28    /// issuer: The C-DID of the VTC or VTN granting membership
29    /// subject: The M-DID of the member, or the member VTC's C-DID for VTN membership
30    /// valid_from: The datetime from which this credential is valid
31    /// valid_until: Optional: The datetime this credential is valid until
32    /// personhood: Whether this VMC can be used as a form of Personhood Credential
33    ///             - Adds PersonhoodCredential to the type array if true
34    ///
35    /// # Give it an `id`
36    ///
37    /// Chain [DTGCredential::with_id] on: the member stores the grant under its `id`, and
38    /// re-issuing is only recognisable as a renewal rather than a duplicate if there is one.
39    pub fn new_vmc(
40        issuer: String,
41        subject: String,
42        valid_from: DateTime<Utc>,
43        valid_until: Option<DateTime<Utc>>,
44        personhood: bool,
45    ) -> Self {
46        let mut vmc = DTGCommon {
47            issuer,
48            valid_from,
49            valid_until,
50            credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
51                id: subject,
52                digest: None,
53            }),
54            ..Default::default()
55        };
56
57        vmc.type_.push(DTGCredentialType::Membership.to_string());
58
59        if personhood {
60            vmc.type_.push("PersonhoodCredential".to_string());
61        }
62
63        DTGCredential {
64            credential: vmc,
65            type_: DTGCredentialType::Membership,
66            version: crate::W3CVCVersion::V2_0,
67        }
68    }
69
70    /// Creates a new member-issued Verifiable Membership Credential (VMC) — the membership
71    /// **acknowledgement**, the member → community half of a membership edge.
72    ///
73    /// The roles of [DTGCredential::new_vmc] are reversed (the member issues, the community
74    /// is the subject) and the subject carries a `digest` of the grant being acknowledged.
75    /// That digest is what binds the two halves into one edge: an acknowledgement whose
76    /// digest matches no valid grant does not complete anything, and the binding forces an
77    /// order — the grant must exist before this can reference it.
78    ///
79    /// This is the member's consent artifact. Because the member is its issuer, withdrawing
80    /// consent needs no cooperation from the community.
81    ///
82    /// # Takes the grant in its wire form, deliberately
83    ///
84    /// `grant` is the JSON the community sent, not a parsed [DTGCredential]. The digest has
85    /// to cover the document the community will recompute it over, and this library does
86    /// not model every member a credential may carry — `credentialStatus`, which every VMC
87    /// issued against a status list carries, is dropped by a parse-then-re-serialise round
88    /// trip. Building the acknowledgement from a parsed grant would produce a digest that
89    /// verifies nowhere, and would do it silently.
90    ///
91    /// So: keep the bytes you were given, and pass them here.
92    ///
93    /// valid_from: The datetime from which this credential is valid
94    /// valid_until: Optional: The datetime this credential is valid until
95    ///
96    /// # Errors
97    ///
98    /// [DTGCredentialError::NotAMembershipGrant] if `grant` is not a JSON object, does not
99    /// carry `MembershipCredential` in its `type`, has no `issuer` or
100    /// `credentialSubject.id`, or already carries a `digest` — that last is an
101    /// acknowledgement, and acknowledging one does not form an edge.
102    ///
103    /// # Give it an `id`
104    ///
105    /// Chain [DTGCredential::with_id] on before signing. A community keys a member's VMC by
106    /// `id` to tell a re-send from a renewal.
107    pub fn new_member_vmc(
108        grant: &Value,
109        valid_from: DateTime<Utc>,
110        valid_until: Option<DateTime<Utc>>,
111    ) -> Result<Self, DTGCredentialError> {
112        let object = grant
113            .as_object()
114            .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("not a JSON object".into()))?;
115
116        let is_membership = object
117            .get("type")
118            .and_then(Value::as_array)
119            .is_some_and(|types| {
120                types
121                    .iter()
122                    .filter_map(Value::as_str)
123                    .any(|t| t == "MembershipCredential")
124            });
125        if !is_membership {
126            return Err(DTGCredentialError::NotAMembershipGrant(
127                "`type` does not include `MembershipCredential`".into(),
128            ));
129        }
130
131        let subject = object
132            .get("credentialSubject")
133            .and_then(Value::as_object)
134            .ok_or_else(|| {
135                DTGCredentialError::NotAMembershipGrant("no `credentialSubject`".into())
136            })?;
137
138        if subject.contains_key("digest") {
139            return Err(DTGCredentialError::NotAMembershipGrant(
140                "the credential carries a `digest`, so it is itself a member-issued \
141                 acknowledgement rather than a community-issued grant"
142                    .into(),
143            ));
144        }
145
146        // The member is the grant's subject and the community its issuer: reading both off
147        // the grant is what keeps the two halves naming the same pair. Taking them as
148        // parameters would let a caller acknowledge one grant while naming the parties of
149        // another, which verifies as a digest match and means nothing.
150        let member = subject
151            .get("id")
152            .and_then(Value::as_str)
153            .ok_or_else(|| {
154                DTGCredentialError::NotAMembershipGrant("no `credentialSubject.id`".into())
155            })?
156            .to_string();
157
158        // `issuer` is a string or an object with an `id`, per the W3C data model.
159        let community = object
160            .get("issuer")
161            .and_then(|i| {
162                i.as_str()
163                    .map(str::to_string)
164                    .or_else(|| i.get("id").and_then(Value::as_str).map(str::to_string))
165            })
166            .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("no `issuer`".into()))?;
167
168        let mut vmc = DTGCommon {
169            issuer: member,
170            valid_from,
171            valid_until,
172            credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
173                id: community,
174                digest: Some(crate::digest_json(grant)?),
175            }),
176            ..Default::default()
177        };
178
179        vmc.type_.push(DTGCredentialType::Membership.to_string());
180
181        Ok(DTGCredential {
182            credential: vmc,
183            type_: DTGCredentialType::Membership,
184            version: crate::W3CVCVersion::V2_0,
185        })
186    }
187
188    /// Creates a new Verified Relationship Credential (VRC)
189    /// issuer: The issuer DID of the credential
190    /// subject: The DID of the subject of this credential
191    /// valid_from: The datetime from which this credential is valid
192    /// valid_until: Optional: The datetime this credential is valid until
193    pub fn new_vrc(
194        issuer: String,
195        subject: String,
196        valid_from: DateTime<Utc>,
197        valid_until: Option<DateTime<Utc>>,
198    ) -> Self {
199        let mut vrc = DTGCommon {
200            issuer,
201            valid_from,
202            valid_until,
203            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
204            ..Default::default()
205        };
206
207        vrc.type_.push(DTGCredentialType::Relationship.to_string());
208
209        DTGCredential {
210            credential: vrc,
211            type_: DTGCredentialType::Relationship,
212            version: crate::W3CVCVersion::V2_0,
213        }
214    }
215
216    /// Creates a new Verified Invitation Credential (VIC)
217    /// issuer: The issuer DID of the credential
218    /// subject: The DID of the subject of this credential
219    /// valid_from: The datetime from which this credential is valid
220    /// valid_until: Optional: The datetime this credential is valid until
221    pub fn new_vic(
222        issuer: String,
223        subject: String,
224        valid_from: DateTime<Utc>,
225        valid_until: Option<DateTime<Utc>>,
226    ) -> Self {
227        let mut vic = DTGCommon {
228            issuer,
229            valid_from,
230            valid_until,
231            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
232            ..Default::default()
233        };
234
235        vic.type_.push(DTGCredentialType::Invitation.to_string());
236
237        DTGCredential {
238            credential: vic,
239            type_: DTGCredentialType::Invitation,
240            version: crate::W3CVCVersion::V2_0,
241        }
242    }
243
244    /// Creates a new Verifiable Authority Credential (VAC) — a chain root.
245    ///
246    /// The issuer is the party governing `scope`. To derive a narrower VAC from one you
247    /// already hold, use [DTGCredential::attenuate] instead: a chain root is a grant made
248    /// by the governing party, and minting one directly is how a self-issued grant of
249    /// arbitrary authority gets in.
250    ///
251    /// `actions` MUST NOT be empty — an empty list confers nothing rather than everything.
252    ///
253    /// Tracks a draft (`trustoverip/dtgwg-cred-spec` PR #29); the shape may move.
254    pub fn new_vac(
255        issuer: String,
256        subject: String,
257        scope: String,
258        actions: Vec<String>,
259        valid_from: DateTime<Utc>,
260        valid_until: Option<DateTime<Utc>>,
261    ) -> Result<Self, DTGCredentialError> {
262        if actions.is_empty() {
263            return Err(DTGCredentialError::EmptyAuthorityActions);
264        }
265        let mut vac = DTGCommon {
266            issuer,
267            valid_from,
268            valid_until,
269            credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority {
270                id: subject,
271                authority: AuthorityGrant {
272                    scope,
273                    actions,
274                    parent: None,
275                    audience: None,
276                },
277            }),
278            ..Default::default()
279        };
280
281        vac.type_.push(DTGCredentialType::Authority.to_string());
282
283        Ok(DTGCredential {
284            credential: vac,
285            type_: DTGCredentialType::Authority,
286            version: crate::W3CVCVersion::V2_0,
287        })
288    }
289
290    /// Derive a narrower VAC from one this holder already holds.
291    ///
292    /// This is what lets a member equip an agent, a device, or a short-lived session with
293    /// only the authority that task needs, rather than lending it their own. The derived
294    /// credential is issued by the *holder*, not by the party governing the scope, and
295    /// carries `parent` so a verifier can walk back to a root.
296    ///
297    /// Refuses anything that would widen. The checks here mirror
298    /// [crate::authority::verify_chain] on purpose: a holder should be unable to *build* a
299    /// chain a verifier would reject, so the failure surfaces at issue time rather than at
300    /// use — but the verifier's checks remain authoritative, because nothing stops a
301    /// different implementation constructing the JSON by hand.
302    ///
303    /// - `self` must be a VAC, and must carry an `id` (a parent with no identifier cannot
304    ///   be pointed at).
305    /// - `actions` must be a subset of what `self` confers.
306    /// - `valid_until` must not exceed `self`'s.
307    /// - `audience` binds the derived credential to one presenter; strongly recommended
308    ///   when equipping an agent, since it makes a leaked credential useless to anyone else.
309    pub fn attenuate(
310        &self,
311        subject: String,
312        actions: Vec<String>,
313        valid_from: DateTime<Utc>,
314        valid_until: Option<DateTime<Utc>>,
315        audience: Option<String>,
316    ) -> Result<Self, DTGCredentialError> {
317        let parent_grant = self
318            .credential()
319            .authority()
320            .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
321
322        let parent_id = self
323            .id()
324            .ok_or(DTGCredentialError::AttenuationParentHasNoId)?
325            .to_string();
326
327        if actions.is_empty() {
328            return Err(DTGCredentialError::EmptyAuthorityActions);
329        }
330        for action in &actions {
331            if !parent_grant.actions.contains(action) {
332                return Err(DTGCredentialError::AttenuationWidens(format!(
333                    "action `{action}` is not conferred by the parent"
334                )));
335            }
336        }
337        if let (Some(until), Some(parent_until)) = (valid_until, self.credential().valid_until())
338            && until > parent_until
339        {
340            return Err(DTGCredentialError::AttenuationWidens(format!(
341                "validUntil {until} is beyond the parent's {parent_until}"
342            )));
343        }
344
345        let mut vac = DTGCommon {
346            // The holder issues: they are the subject of the parent grant.
347            issuer: self.credential().subject().to_string(),
348            valid_from,
349            valid_until,
350            credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority {
351                id: subject,
352                authority: AuthorityGrant {
353                    // Scope never changes down a chain.
354                    scope: parent_grant.scope.clone(),
355                    actions,
356                    parent: Some(parent_id),
357                    audience,
358                },
359            }),
360            ..Default::default()
361        };
362
363        vac.type_.push(DTGCredentialType::Authority.to_string());
364
365        Ok(DTGCredential {
366            credential: vac,
367            type_: DTGCredentialType::Authority,
368            version: crate::W3CVCVersion::V2_0,
369        })
370    }
371
372    /// Creates a new Verifiable Delegation Credential (VDC).
373    ///
374    /// Establishes that `subject` may act **in the issuer's name**. This is not authority:
375    /// a VDC never supplies permission the delegator did not itself hold, and a verifier
376    /// must settle the two questions separately. See [DTGCredential::new_vac].
377    ///
378    /// Tracks a draft (`trustoverip/dtgwg-cred-spec` PR #19); the shape may move.
379    pub fn new_vdc(
380        issuer: String,
381        subject: String,
382        valid_from: DateTime<Utc>,
383        valid_until: Option<DateTime<Utc>>,
384    ) -> Self {
385        let mut vdc = DTGCommon {
386            issuer,
387            valid_from,
388            valid_until,
389            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
390            ..Default::default()
391        };
392
393        vdc.type_.push(DTGCredentialType::Delegation.to_string());
394
395        DTGCredential {
396            credential: vdc,
397            type_: DTGCredentialType::Delegation,
398            version: crate::W3CVCVersion::V2_0,
399        }
400    }
401
402    /// Creates a new Verified Persona Credential (VPC)
403    /// issuer: The issuer DID of the credential
404    /// subject: The DID of the subject of this credential
405    /// valid_from: The datetime from which this credential is valid
406    /// valid_until: Optional: The datetime this credential is valid until
407    pub fn new_vpc(
408        issuer: String,
409        subject: String,
410        valid_from: DateTime<Utc>,
411        valid_until: Option<DateTime<Utc>>,
412    ) -> Self {
413        let mut vpc = DTGCommon {
414            issuer,
415            valid_from,
416            valid_until,
417            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
418            ..Default::default()
419        };
420
421        vpc.type_.push(DTGCredentialType::Persona.to_string());
422
423        DTGCredential {
424            credential: vpc,
425            type_: DTGCredentialType::Persona,
426            version: crate::W3CVCVersion::V2_0,
427        }
428    }
429
430    /// Creates a new Verified Endorsement Credential (VEC)
431    /// issuer: The issuer DID of the credential
432    /// subject: The DID of the subject of this credential
433    /// valid_from: The datetime from which this credential is valid
434    /// valid_until: Optional: The datetime this credential is valid until
435    /// endorsement: The endorsement details for this credential
436    pub fn new_vec(
437        issuer: String,
438        subject: String,
439        valid_from: DateTime<Utc>,
440        valid_until: Option<DateTime<Utc>>,
441        endorsement: Value,
442    ) -> Self {
443        let mut vec = DTGCommon {
444            issuer,
445            valid_from,
446            valid_until,
447            credential_subject: CredentialSubject::Endorsement(CredentialSubjectEndorsement {
448                id: subject,
449                endorsement,
450            }),
451            ..Default::default()
452        };
453
454        vec.type_.push(DTGCredentialType::Endorsement.to_string());
455
456        DTGCredential {
457            credential: vec,
458            type_: DTGCredentialType::Endorsement,
459            version: crate::W3CVCVersion::V2_0,
460        }
461    }
462
463    /// Creates a new Verified Witness Credential (VWC)
464    /// issuer: The issuer DID of the credential - an M-DID, or the DID of a VTA acting
465    ///         according to VTC policy
466    /// subject: The DID of the observed party. For a witnessed bi-directional exchange this
467    ///          MUST be the issuer of the VRC that this VWC attests (the VRC referenced by
468    ///          `digest`), so that the two VWCs of an exchange are unambiguously bound to
469    ///          their respective directions. The witness should issue one VWC per direction.
470    /// valid_from: The datetime from which this credential is valid
471    /// valid_until: Optional: The datetime this credential is valid until
472    /// task_context: Required `threadId` of the trust task exchange the witnessing occurred in
473    /// digest: Cryptographic hash of the witnessed edge credential, binding this VWC to the
474    ///         specific edge. Produce it with [DTGCredential::digest] on that credential.
475    ///         REQUIRED by the specification; `Option` here because a VWC that predates the
476    ///         requirement still has to deserialize. A VWC without one identifies the
477    ///         observed party and the exchange, but not which edge was witnessed.
478    /// witness_context: Optional Semantic context for the witness
479    pub fn new_vwc(
480        issuer: String,
481        subject: String,
482        valid_from: DateTime<Utc>,
483        valid_until: Option<DateTime<Utc>>,
484        task_context: String,
485        digest: Option<String>,
486        witness_context: Option<WitnessContext>,
487    ) -> Self {
488        let mut vwc = DTGCommon {
489            issuer,
490            valid_from,
491            valid_until,
492            task_context: Some(task_context),
493            credential_subject: CredentialSubject::Witness(CredentialSubjectWitness {
494                id: subject,
495                digest,
496                witness_context,
497            }),
498            ..Default::default()
499        };
500
501        vwc.type_.push(DTGCredentialType::Witness.to_string());
502
503        DTGCredential {
504            credential: vwc,
505            type_: DTGCredentialType::Witness,
506            version: crate::W3CVCVersion::V2_0,
507        }
508    }
509
510    /// Creates a new Verified RCard Credential (VWC)
511    /// issuer: The issuer DID of the credential
512    /// subject: The DID of the subject of this credential
513    /// valid_from: The datetime from which this credential is valid
514    /// valid_until: Optional: The datetime this credential is valid until
515    /// card: JSON Value representing a Jcard (RFC 7095) format
516    #[deprecated(
517        since = "0.2.0",
518        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
519                It was removed from the DTG Core Credentials specification in Working Draft 01 \
520                and will be defined by the planned DTG Verifiable Data Structures specification. \
521                This constructor will be removed in a future release."
522    )]
523    #[allow(deprecated)]
524    pub fn new_rcard(
525        issuer: String,
526        subject: String,
527        valid_from: DateTime<Utc>,
528        valid_until: Option<DateTime<Utc>>,
529        card: Value,
530    ) -> Self {
531        let mut rcard = DTGCommon {
532            issuer,
533            valid_from,
534            valid_until,
535            credential_subject: CredentialSubject::RCard(CredentialSubjectRCard {
536                id: subject,
537                card,
538            }),
539            ..Default::default()
540        };
541
542        rcard.type_.push(DTGCredentialType::RCard.to_string());
543
544        DTGCredential {
545            credential: rcard,
546            type_: DTGCredentialType::RCard,
547            version: crate::W3CVCVersion::V2_0,
548        }
549    }
550
551    /// Sets this credential's own identifier, consuming and returning it so it chains onto
552    /// any of the `new_*` constructors above.
553    ///
554    /// `id` MUST be a single URL per the W3C VC Data Model; `urn:uuid:<uuid>` is the usual
555    /// choice for a credential with no dereferenceable home. This crate does not validate it.
556    ///
557    /// ```
558    /// # use chrono::Utc;
559    /// # use dtg_credentials::DTGCredential;
560    /// let vmc = DTGCredential::new_vmc(
561    ///     "did:example:member".to_string(),
562    ///     "did:example:community".to_string(),
563    ///     Utc::now(),
564    ///     None,
565    ///     false,
566    /// )
567    /// .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
568    /// assert_eq!(vmc.id(), Some("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"));
569    /// ```
570    ///
571    /// # Set it before signing
572    ///
573    /// A Data Integrity proof covers the credential minus its `proof`, so `id` is part of what
574    /// is signed. Chain this onto the constructor, before [DTGCredential::sign] — adding an id
575    /// to an already-signed credential leaves a document whose proof no longer verifies.
576    pub fn with_id(mut self, id: impl Into<String>) -> Self {
577        self.credential.id = Some(id.into());
578        self
579    }
580
581    /// Sets this credential's own identifier in place.
582    ///
583    /// The non-consuming form of [DTGCredential::with_id]; the same "before signing" caveat
584    /// applies.
585    pub fn set_id(&mut self, id: impl Into<String>) {
586        self.credential.id = Some(id.into());
587    }
588}
589
590#[cfg(test)]
591#[allow(deprecated)]
592mod tests {
593    use crate::{DTGCredential, WitnessContext};
594    use chrono::{DateTime, Utc};
595    use serde_json::json;
596
597    #[test]
598    fn test_vmc_serialization() {
599        let vmc = DTGCredential::new_vmc(
600            "did:example:issuer".to_string(),
601            "did:example:subject".to_string(),
602            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
603                .unwrap()
604                .with_timezone(&Utc),
605            None,
606            false,
607        );
608
609        let txt = serde_json::to_string_pretty(&vmc).unwrap();
610        let sample = r#"{
611  "@context": [
612    "https://www.w3.org/ns/credentials/v2",
613    "https://firstperson.network/credentials/dtg/v1"
614  ],
615  "type": [
616    "VerifiableCredential",
617    "DTGCredential",
618    "MembershipCredential"
619  ],
620  "issuer": "did:example:issuer",
621  "validFrom": "2025-12-11T00:00:00Z",
622  "credentialSubject": {
623    "id": "did:example:subject"
624  }
625}"#;
626
627        assert_eq!(txt, sample);
628    }
629
630    #[test]
631    fn test_vmc_phc_serialization() {
632        let vmc = DTGCredential::new_vmc(
633            "did:example:issuer".to_string(),
634            "did:example:subject".to_string(),
635            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
636                .unwrap()
637                .with_timezone(&Utc),
638            None,
639            true,
640        );
641
642        let txt = serde_json::to_string_pretty(&vmc).unwrap();
643        let sample = r#"{
644  "@context": [
645    "https://www.w3.org/ns/credentials/v2",
646    "https://firstperson.network/credentials/dtg/v1"
647  ],
648  "type": [
649    "VerifiableCredential",
650    "DTGCredential",
651    "MembershipCredential",
652    "PersonhoodCredential"
653  ],
654  "issuer": "did:example:issuer",
655  "validFrom": "2025-12-11T00:00:00Z",
656  "credentialSubject": {
657    "id": "did:example:subject"
658  }
659}"#;
660
661        assert_eq!(txt, sample);
662    }
663    /// `id` is OPTIONAL, and a credential that was never given one must keep serializing the
664    /// shape it always did — no `"id": null`, no empty string.
665    #[test]
666    fn test_vmc_without_id_omits_the_property() {
667        let vmc = DTGCredential::new_vmc(
668            "did:example:issuer".to_string(),
669            "did:example:subject".to_string(),
670            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
671                .unwrap()
672                .with_timezone(&Utc),
673            None,
674            false,
675        );
676
677        assert_eq!(vmc.id(), None);
678        let value: serde_json::Value = serde_json::to_value(&vmc).unwrap();
679        assert!(
680            value.get("id").is_none(),
681            "an unset id must not appear on the wire at all: {value}"
682        );
683    }
684
685    /// `with_id` puts the identifier at the top level of the credential — a sibling of
686    /// `issuer`, not something nested under `credentialSubject` (which carries the *subject's*
687    /// id, a different thing entirely).
688    #[test]
689    fn test_vmc_with_id_serialization() {
690        let vmc = DTGCredential::new_vmc(
691            "did:example:issuer".to_string(),
692            "did:example:subject".to_string(),
693            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
694                .unwrap()
695                .with_timezone(&Utc),
696            None,
697            false,
698        )
699        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
700
701        let txt = serde_json::to_string_pretty(&vmc).unwrap();
702        let sample = r#"{
703  "@context": [
704    "https://www.w3.org/ns/credentials/v2",
705    "https://firstperson.network/credentials/dtg/v1"
706  ],
707  "type": [
708    "VerifiableCredential",
709    "DTGCredential",
710    "MembershipCredential"
711  ],
712  "id": "urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff",
713  "issuer": "did:example:issuer",
714  "validFrom": "2025-12-11T00:00:00Z",
715  "credentialSubject": {
716    "id": "did:example:subject"
717  }
718}"#;
719
720        assert_eq!(txt, sample);
721    }
722
723    /// The identifier has to survive a round trip. It arrives on the wire and is read back
724    /// through `TryFrom<DTGCommon>`, which is where `taskContext` was previously being dropped
725    /// — a field that deserializes into nothing breaks signing and verification silently.
726    #[test]
727    fn test_id_round_trips_through_deserialization() {
728        let vmc = DTGCredential::new_vmc(
729            "did:example:issuer".to_string(),
730            "did:example:subject".to_string(),
731            Utc::now(),
732            None,
733            false,
734        )
735        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
736
737        let txt = serde_json::to_string(&vmc).unwrap();
738        let parsed: DTGCredential = serde_json::from_str(&txt).unwrap();
739        assert_eq!(
740            parsed.id(),
741            Some("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff")
742        );
743    }
744
745    /// A credential with no `id` still deserializes — the property is OPTIONAL, and every
746    /// credential issued before this field existed has none.
747    #[test]
748    fn test_missing_id_deserializes_as_none() {
749        let parsed: DTGCredential = serde_json::from_str(
750            r#"{
751              "@context": ["https://www.w3.org/ns/credentials/v2"],
752              "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
753              "issuer": "did:example:issuer",
754              "validFrom": "2025-12-11T00:00:00Z",
755              "credentialSubject": { "id": "did:example:subject" }
756            }"#,
757        )
758        .unwrap();
759        assert_eq!(parsed.id(), None);
760    }
761
762    /// `set_id` is the in-place form of `with_id`; both write the same property.
763    #[test]
764    fn test_set_id_matches_with_id() {
765        let build = || {
766            DTGCredential::new_vrc(
767                "did:example:issuer".to_string(),
768                "did:example:subject".to_string(),
769                DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
770                    .unwrap()
771                    .with_timezone(&Utc),
772                None,
773            )
774        };
775        let mut in_place = build();
776        in_place.set_id("urn:uuid:abc");
777        assert_eq!(
778            serde_json::to_value(&in_place).unwrap(),
779            serde_json::to_value(build().with_id("urn:uuid:abc")).unwrap()
780        );
781    }
782
783    #[test]
784    fn test_vrc_serialization() {
785        let vrc = DTGCredential::new_vrc(
786            "did:example:issuer".to_string(),
787            "did:example:subject".to_string(),
788            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
789                .unwrap()
790                .with_timezone(&Utc),
791            None,
792        );
793
794        let txt = serde_json::to_string_pretty(&vrc).unwrap();
795        let sample = r#"{
796  "@context": [
797    "https://www.w3.org/ns/credentials/v2",
798    "https://firstperson.network/credentials/dtg/v1"
799  ],
800  "type": [
801    "VerifiableCredential",
802    "DTGCredential",
803    "RelationshipCredential"
804  ],
805  "issuer": "did:example:issuer",
806  "validFrom": "2025-12-11T00:00:00Z",
807  "credentialSubject": {
808    "id": "did:example:subject"
809  }
810}"#;
811
812        assert_eq!(txt, sample);
813    }
814
815    #[test]
816    fn test_vic_serialization() {
817        let vic = DTGCredential::new_vic(
818            "did:example:issuer".to_string(),
819            "did:example:subject".to_string(),
820            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
821                .unwrap()
822                .with_timezone(&Utc),
823            None,
824        );
825
826        let txt = serde_json::to_string_pretty(&vic).unwrap();
827        let sample = r#"{
828  "@context": [
829    "https://www.w3.org/ns/credentials/v2",
830    "https://firstperson.network/credentials/dtg/v1"
831  ],
832  "type": [
833    "VerifiableCredential",
834    "DTGCredential",
835    "InvitationCredential"
836  ],
837  "issuer": "did:example:issuer",
838  "validFrom": "2025-12-11T00:00:00Z",
839  "credentialSubject": {
840    "id": "did:example:subject"
841  }
842}"#;
843
844        assert_eq!(txt, sample);
845    }
846
847    #[test]
848    fn test_vpc_serialization() {
849        let vpc = DTGCredential::new_vpc(
850            "did:example:issuer".to_string(),
851            "did:example:subject".to_string(),
852            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
853                .unwrap()
854                .with_timezone(&Utc),
855            None,
856        );
857
858        let txt = serde_json::to_string_pretty(&vpc).unwrap();
859        let sample = r#"{
860  "@context": [
861    "https://www.w3.org/ns/credentials/v2",
862    "https://firstperson.network/credentials/dtg/v1"
863  ],
864  "type": [
865    "VerifiableCredential",
866    "DTGCredential",
867    "PersonaCredential"
868  ],
869  "issuer": "did:example:issuer",
870  "validFrom": "2025-12-11T00:00:00Z",
871  "credentialSubject": {
872    "id": "did:example:subject"
873  }
874}"#;
875
876        assert_eq!(txt, sample);
877    }
878
879    #[test]
880    fn test_vec_serialization() {
881        let vec = DTGCredential::new_vec(
882            "did:example:issuer".to_string(),
883            "did:example:subject".to_string(),
884            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
885                .unwrap()
886                .with_timezone(&Utc),
887            None,
888            json!({
889              "type": "SkillEndorsement",
890              "name": "Software Development",
891              "competencyLevel": "expert"
892            }),
893        );
894
895        let txt = serde_json::to_string_pretty(&vec).unwrap();
896        let sample = r#"{
897  "@context": [
898    "https://www.w3.org/ns/credentials/v2",
899    "https://firstperson.network/credentials/dtg/v1"
900  ],
901  "type": [
902    "VerifiableCredential",
903    "DTGCredential",
904    "EndorsementCredential"
905  ],
906  "issuer": "did:example:issuer",
907  "validFrom": "2025-12-11T00:00:00Z",
908  "credentialSubject": {
909    "id": "did:example:subject",
910    "endorsement": {
911      "competencyLevel": "expert",
912      "name": "Software Development",
913      "type": "SkillEndorsement"
914    }
915  }
916}"#;
917
918        assert_eq!(txt, sample);
919    }
920
921    #[test]
922    fn test_vwc_serialization() {
923        let vwc = DTGCredential::new_vwc(
924            "did:example:issuer".to_string(),
925            "did:example:subject".to_string(),
926            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
927                .unwrap()
928                .with_timezone(&Utc),
929            None,
930            "thread-abc-123".to_string(),
931            Some("zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw".to_string()),
932            Some(WitnessContext {
933                event: Some("EthDenver 2024".to_string()),
934                session_id: Some("session-8822-nonce".to_string()),
935                method: Some("in-person-proximity".to_string()),
936            }),
937        );
938
939        let txt = serde_json::to_string_pretty(&vwc).unwrap();
940
941        let sample = r#"{
942  "@context": [
943    "https://www.w3.org/ns/credentials/v2",
944    "https://firstperson.network/credentials/dtg/v1"
945  ],
946  "type": [
947    "VerifiableCredential",
948    "DTGCredential",
949    "WitnessCredential"
950  ],
951  "issuer": "did:example:issuer",
952  "validFrom": "2025-12-11T00:00:00Z",
953  "taskContext": "thread-abc-123",
954  "credentialSubject": {
955    "id": "did:example:subject",
956    "digest": "zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw",
957    "witnessContext": {
958      "event": "EthDenver 2024",
959      "sessionId": "session-8822-nonce",
960      "method": "in-person-proximity"
961    }
962  }
963}"#;
964
965        assert_eq!(txt, sample);
966    }
967
968    #[test]
969    fn test_rcard_serialization() {
970        let rcard = DTGCredential::new_rcard(
971            "did:example:issuer".to_string(),
972            "did:example:subject".to_string(),
973            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
974                .unwrap()
975                .with_timezone(&Utc),
976            None,
977            json!([
978                "vcard",
979                [
980                    ["fn", {}, "text", "Alice Smith"],
981                    ["email", {}, "text", "alice@example.com"]
982                ]
983            ]),
984        );
985
986        let txt = serde_json::to_string_pretty(&rcard).unwrap();
987
988        let sample = r#"{
989  "@context": [
990    "https://www.w3.org/ns/credentials/v2",
991    "https://firstperson.network/credentials/dtg/v1"
992  ],
993  "type": [
994    "VerifiableCredential",
995    "DTGCredential",
996    "RCardCredential"
997  ],
998  "issuer": "did:example:issuer",
999  "validFrom": "2025-12-11T00:00:00Z",
1000  "credentialSubject": {
1001    "id": "did:example:subject",
1002    "card": [
1003      "vcard",
1004      [
1005        [
1006          "fn",
1007          {},
1008          "text",
1009          "Alice Smith"
1010        ],
1011        [
1012          "email",
1013          {},
1014          "text",
1015          "alice@example.com"
1016        ]
1017      ]
1018    ]
1019  }
1020}"#;
1021
1022        assert_eq!(txt, sample);
1023    }
1024}