dpp_vc/jsonld/context.rs
1//! JSON-LD context envelope: build / frame / strip a DPP passport payload.
2
3use std::sync::OnceLock;
4
5use dpp_vocab::{OWN_JSONLD_NAMESPACE, VocabularyRegister};
6use serde_json::{Value, json};
7
8/// The `gs1:` prefix IRI, read from the register rather than written here.
9///
10/// # Why this is not a literal
11///
12/// It was one, and the doc comment below carried its provenance — the date GS1's
13/// definition was read, what it said, the licence. That is the right *content*
14/// filed in the wrong *place*: a doc comment is not a record, nothing checks it,
15/// and `dpp-vocab` exists precisely so this class of claim has one home with a
16/// source and a `checkedOn` date. The register was created to unify two
17/// mechanisms held to different standards of rigour; this was the one still on
18/// the looser standard.
19///
20/// # Panics
21///
22/// If the `gs1` record is absent, carries no `namespaceIri`, or no longer
23/// permits emission. All three are build-time facts about files embedded in
24/// `dpp-vocab`, and every one of them means this context must stop claiming
25/// GS1's vocabulary. Emitting a prefix the register refuses is the failure this
26/// arrangement exists to prevent, so it is not papered over with a fallback.
27fn gs1_namespace() -> &'static str {
28 static GS1: OnceLock<String> = OnceLock::new();
29 GS1.get_or_init(|| {
30 let register = VocabularyRegister::new();
31 let record = register
32 .all()
33 .iter()
34 .find(|v| v.key == "gs1")
35 .expect("the gs1 record is embedded in dpp-vocab")
36 .clone();
37 assert!(
38 record.permits_emission(),
39 "the gs1 record no longer permits emission ({:?}/{:?}), so this context must not declare a gs1: prefix",
40 record.status,
41 record.layer
42 );
43 record
44 .namespace_iri
45 .expect("the gs1 record carries a namespaceIri")
46 })
47 .as_str()
48}
49
50/// Remote contexts this passport context references.
51///
52/// **A string entry in an `@context` array is fetched by the consumer at
53/// expansion time.** One that does not resolve is not cosmetic: a conforming
54/// processor fails the whole document with a remote-context load error, and a
55/// lenient one drops every term it cannot define. Since our payload uses bare
56/// keys, that means the `ld+json` door would convey no linked data at all —
57/// worse than serving plain JSON, because the `@context` is itself a claim that
58/// the document is semantically resolvable.
59///
60/// So this list is deliberately short and deliberately explicit: adding to it
61/// means editing this constant *and* the test that pins it, which is the point
62/// at which someone checks the URL. Two entries were removed on 2026-07-30 for
63/// returning 404 — `https://ref.gs1.org/standards/digital-link/context/`, which
64/// this crate referenced, and `https://odal-node.io/schemas/dpp/v1`, which the
65/// resolver hand-rolled.
66///
67/// Term-to-IRI mappings are a different matter and are inlined below: a prefix
68/// IRI names a vocabulary and is never dereferenced during expansion, so it
69/// carries no such obligation.
70pub const REMOTE_CONTEXTS: &[&str] = &["https://www.w3.org/ns/did/v1"];
71
72/// Build the JSON-LD context for an Odal Node passport.
73///
74/// The vocabulary is **inlined** rather than hosted. Hosting a context document
75/// is a commitment to keep a URL resolving for as long as any passport
76/// referencing it exists — years, under ESPR retention — and that is an
77/// operational obligation, not a library decision. An inline term map cannot
78/// 404, and it can be adopted later without invalidating passports issued now.
79///
80/// Every term maps to our own `dpp:` prefix, with one exception.
81///
82/// `gtin`, `createdAt` and `updatedAt` all used to borrow GS1's and
83/// Schema.org's prefixes (`gs1:gtin`, `schema:dateCreated`,
84/// `schema:dateModified`) with no provenance record, which is exactly the
85/// unsupported claim `dpp-vocab`'s rule exists to catch. All three were
86/// withdrawn to `dpp:` on 2026-08-10.
87///
88/// **`gtin` is back, and only `gtin`.** Its record is `vocabularies/gs1.json`
89/// in `dpp-vocab`, carrying what was read, when, and under what licence —
90/// `gs1_namespace` reads the prefix IRI from there rather than repeating it,
91/// so the claim and its evidence cannot drift apart. GS1's `gtin` is
92/// [`Gtin`](dpp_domain::Gtin)'s shape exactly, which is why the term says
93/// something true.
94///
95/// The other two stay `dpp:`. Schema.org is `tracked` in `dpp-vocab`: evaluated,
96/// not adopted, and its record does not permit emission. And note that what was
97/// read was **one term**, not the GS1 vocabulary — declaring the `gs1:` prefix
98/// is what makes the compact form expand, not a claim that anything else under
99/// it has been checked.
100///
101/// The literal is built once and cloned per call — callers extend the
102/// returned value (e.g. [`frame_passport`] merges passport fields into it),
103/// so it must stay an owned, independently-mutable `Value` per call site.
104pub fn passport_context() -> Value {
105 static CONTEXT: OnceLock<Value> = OnceLock::new();
106 CONTEXT
107 .get_or_init(|| {
108 json!({
109 "@context": [
110 REMOTE_CONTEXTS[0],
111 {
112 "dpp": OWN_JSONLD_NAMESPACE,
113 "gs1": gs1_namespace(),
114 "gtin": "gs1:gtin",
115 "productGroup": "dpp:product_group",
116 "passportId": "dpp:passportId",
117 "status": "dpp:status",
118 "productGroupData": "dpp:productGroupData",
119 "complianceResult": "dpp:complianceResult",
120 "createdAt": "dpp:createdAt",
121 "updatedAt": "dpp:updatedAt",
122 "jws": "dpp:jws"
123 }
124 ]
125 })
126 })
127 .clone()
128}
129
130/// The `@context` value alone, for a caller that already has a passport object
131/// and needs to stamp the context onto it.
132///
133/// Exists so the resolver stops constructing its own: two definitions of one
134/// context is how the served one came to reference a URL that 404s while this
135/// one referenced a different URL that also 404s.
136pub fn context_value() -> Value {
137 passport_context()["@context"].clone()
138}
139
140/// Wrap a passport JSON value in a JSON-LD envelope.
141///
142/// A non-object payload cannot be merged into the `@context` object; it is
143/// returned **unchanged** rather than silently discarded into a bare, empty
144/// envelope.
145pub fn frame_passport(passport: Value) -> Value {
146 match passport {
147 Value::Object(passport_map) => {
148 let mut framed = passport_context();
149 if let Value::Object(ref mut ctx_map) = framed {
150 ctx_map.extend(passport_map);
151 }
152 framed
153 }
154 other => other,
155 }
156}
157
158/// Extract the plain data from a JSON-LD framed passport (strip `@context`).
159pub fn strip_context(framed: Value) -> Value {
160 match framed {
161 Value::Object(mut map) => {
162 map.remove("@context");
163 Value::Object(map)
164 }
165 other => other,
166 }
167}