@lemmaoracle/agent 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,760 @@
1
+ use wasm_bindgen::prelude::*;
2
+ use serde::{Deserialize, Serialize};
3
+ use serde_json;
4
+
5
+ // ── Custom serde deserializers ──────────────────────────────────────
6
+
7
+ mod strict_u64 {
8
+ use serde::de::{self, Deserialize, Deserializer};
9
+ use serde_json::Value;
10
+
11
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<u64, D::Error>
12
+ where
13
+ D: Deserializer<'de>,
14
+ {
15
+ let val = Value::deserialize(deserializer)?;
16
+ val.as_u64().ok_or_else(|| {
17
+ de::Error::custom("expected a non-negative integer")
18
+ })
19
+ }
20
+ }
21
+
22
+ mod strict_optional_u64 {
23
+ use serde::de::{self, Deserialize, Deserializer};
24
+ use serde_json::Value;
25
+
26
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
27
+ where
28
+ D: Deserializer<'de>,
29
+ {
30
+ let val: Option<Value> = Option::deserialize(deserializer)?;
31
+ match val {
32
+ None => Ok(None),
33
+ Some(v) => v.as_u64().map(Some).ok_or_else(|| {
34
+ de::Error::custom("expected a non-negative integer")
35
+ }),
36
+ }
37
+ }
38
+ }
39
+
40
+ // ── Input types ──────────────────────────────────────────────────────
41
+
42
+ #[derive(Deserialize)]
43
+ #[serde(rename_all = "camelCase")]
44
+ struct AgentIdentity {
45
+ #[serde(rename = "agentId")]
46
+ agent_id: String,
47
+ #[serde(rename = "subjectId")]
48
+ subject_id: String,
49
+ #[serde(rename = "controllerId")]
50
+ controller_id: Option<String>,
51
+ #[serde(rename = "orgId")]
52
+ org_id: Option<String>,
53
+ }
54
+
55
+ #[derive(Deserialize)]
56
+ struct Role {
57
+ name: String,
58
+ }
59
+
60
+ #[derive(Deserialize)]
61
+ struct Scope {
62
+ name: String,
63
+ }
64
+
65
+ #[derive(Deserialize)]
66
+ struct Permission {
67
+ resource: String,
68
+ action: String,
69
+ }
70
+
71
+ #[derive(Deserialize)]
72
+ #[serde(rename_all = "camelCase")]
73
+ struct AgentAuthority {
74
+ roles: Vec<Role>,
75
+ scopes: Vec<Scope>,
76
+ permissions: Vec<Permission>,
77
+ }
78
+
79
+ #[derive(Deserialize)]
80
+ #[serde(rename_all = "camelCase")]
81
+ struct AgentFinancialAuthority {
82
+ #[serde(rename = "spendLimit", default, deserialize_with = "strict_optional_u64::deserialize")]
83
+ spend_limit: Option<u64>,
84
+ currency: Option<String>,
85
+ #[serde(rename = "paymentPolicy")]
86
+ payment_policy: Option<String>,
87
+ }
88
+
89
+ #[derive(Deserialize)]
90
+ #[serde(rename_all = "camelCase")]
91
+ struct AgentLifecycle {
92
+ #[serde(rename = "issuedAt", deserialize_with = "strict_u64::deserialize")]
93
+ issued_at: u64,
94
+ #[serde(rename = "expiresAt", default, deserialize_with = "strict_optional_u64::deserialize")]
95
+ expires_at: Option<u64>,
96
+ revoked: Option<bool>,
97
+ #[serde(rename = "revocationRef")]
98
+ revocation_ref: Option<String>,
99
+ }
100
+
101
+ #[derive(Deserialize)]
102
+ #[serde(rename_all = "camelCase")]
103
+ struct ChainContext {
104
+ #[serde(rename = "chainId", default, deserialize_with = "strict_optional_u64::deserialize")]
105
+ chain_id: Option<u64>,
106
+ network: Option<String>,
107
+ }
108
+
109
+ #[derive(Deserialize)]
110
+ #[serde(rename_all = "camelCase")]
111
+ struct AgentProvenance {
112
+ #[serde(rename = "issuerId")]
113
+ issuer_id: String,
114
+ #[serde(rename = "sourceSystem")]
115
+ source_system: Option<String>,
116
+ #[serde(rename = "generatorId")]
117
+ generator_id: Option<String>,
118
+ #[serde(rename = "chainContext")]
119
+ chain_context: Option<ChainContext>,
120
+ }
121
+
122
+ #[derive(Deserialize)]
123
+ #[serde(rename_all = "camelCase")]
124
+ struct AgentCredentialInput {
125
+ schema: String,
126
+ identity: AgentIdentity,
127
+ authority: AgentAuthority,
128
+ financial: Option<AgentFinancialAuthority>,
129
+ lifecycle: AgentLifecycle,
130
+ provenance: AgentProvenance,
131
+ }
132
+
133
+ // ── Normalized output types ──────────────────────────────────────────
134
+
135
+ #[derive(Serialize)]
136
+ #[serde(rename_all = "camelCase")]
137
+ struct NormalizedIdentity {
138
+ #[serde(rename = "agentId")]
139
+ agent_id: String,
140
+ #[serde(rename = "subjectId")]
141
+ subject_id: String,
142
+ #[serde(rename = "controllerId")]
143
+ controller_id: String,
144
+ #[serde(rename = "orgId")]
145
+ org_id: String,
146
+ }
147
+
148
+ #[derive(Serialize)]
149
+ #[serde(rename_all = "camelCase")]
150
+ struct NormalizedAuthority {
151
+ roles: String,
152
+ scopes: String,
153
+ permissions: String,
154
+ }
155
+
156
+ #[derive(Serialize)]
157
+ #[serde(rename_all = "camelCase")]
158
+ struct NormalizedFinancial {
159
+ #[serde(rename = "spendLimit")]
160
+ spend_limit: String,
161
+ currency: String,
162
+ #[serde(rename = "paymentPolicy")]
163
+ payment_policy: String,
164
+ }
165
+
166
+ #[derive(Serialize)]
167
+ #[serde(rename_all = "camelCase")]
168
+ struct NormalizedLifecycle {
169
+ #[serde(rename = "issuedAt")]
170
+ issued_at: String,
171
+ #[serde(rename = "expiresAt")]
172
+ expires_at: String,
173
+ revoked: String,
174
+ #[serde(rename = "revocationRef")]
175
+ revocation_ref: String,
176
+ }
177
+
178
+ #[derive(Serialize)]
179
+ #[serde(rename_all = "camelCase")]
180
+ struct NormalizedProvenance {
181
+ #[serde(rename = "issuerId")]
182
+ issuer_id: String,
183
+ #[serde(rename = "sourceSystem")]
184
+ source_system: String,
185
+ #[serde(rename = "generatorId")]
186
+ generator_id: String,
187
+ #[serde(rename = "chainId")]
188
+ chain_id: String,
189
+ network: String,
190
+ }
191
+
192
+ #[derive(Serialize)]
193
+ #[serde(rename_all = "camelCase")]
194
+ struct NormalizedAgentCredential {
195
+ schema: String,
196
+ identity: NormalizedIdentity,
197
+ authority: NormalizedAuthority,
198
+ financial: NormalizedFinancial,
199
+ lifecycle: NormalizedLifecycle,
200
+ provenance: NormalizedProvenance,
201
+ }
202
+
203
+ // ── Validation helpers ───────────────────────────────────────────────
204
+
205
+ fn canonicalize_string(s: &str) -> String {
206
+ if s.starts_with("0x") {
207
+ s.to_lowercase()
208
+ } else {
209
+ s.trim().to_string()
210
+ }
211
+ }
212
+
213
+ fn canonicalize_optional(s: &Option<String>) -> String {
214
+ match s {
215
+ Some(ref val) => canonicalize_string(val.as_str()),
216
+ None => String::new(),
217
+ }
218
+ }
219
+
220
+ fn normalize_array_field<T, F>(items: &[T], accessor: F) -> String
221
+ where
222
+ F: Fn(&T) -> String,
223
+ {
224
+ let mut strings: Vec<String> = items.iter().map(accessor).collect();
225
+ strings.sort();
226
+ strings.dedup();
227
+ strings.join(",")
228
+ }
229
+
230
+ fn normalize_timestamp(ts: u64) -> String {
231
+ let total_seconds = ts as i64;
232
+ let days = total_seconds / 86400;
233
+ let remaining = total_seconds % 86400;
234
+ let hours = remaining / 3600;
235
+ let minutes = (remaining % 3600) / 60;
236
+ let seconds = remaining % 60;
237
+
238
+ let (year, month, day) = julian_to_gregorian(days + 2440588);
239
+
240
+ format!(
241
+ "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
242
+ year, month, day, hours, minutes, seconds
243
+ )
244
+ }
245
+
246
+ fn julian_to_gregorian(jd: i64) -> (i32, i32, i32) {
247
+ let l = jd + 68569;
248
+ let n = 4 * l / 146097;
249
+ let l = l - (146097 * n + 3) / 4;
250
+ let i = 4000 * (l + 1) / 1461001;
251
+ let l = l - 1461 * i / 4 + 31;
252
+ let j = 80 * l / 2447;
253
+ let day = l - 2447 * j / 80;
254
+ let l = j / 11;
255
+ let month = j + 2 - 12 * l;
256
+ let year = 100 * (n - 49) + i + l;
257
+ (year as i32, month as i32, day as i32)
258
+ }
259
+
260
+ fn normalize_optional_timestamp(ts: &Option<u64>) -> String {
261
+ match ts {
262
+ Some(ref val) => normalize_timestamp(*val),
263
+ None => String::from("none"),
264
+ }
265
+ }
266
+
267
+ fn normalize_spend_limit(limit: &Option<u64>) -> String {
268
+ match limit {
269
+ Some(ref val) => val.to_string(),
270
+ None => String::from("unlimited"),
271
+ }
272
+ }
273
+
274
+ fn normalize_bool(b: &Option<bool>) -> String {
275
+ match b {
276
+ Some(true) => String::from("true"),
277
+ _ => String::from("false"),
278
+ }
279
+ }
280
+
281
+ // ── Structured error types ──────────────────────────────────────────
282
+
283
+ #[derive(Serialize)]
284
+ #[serde(tag = "error")]
285
+ enum NormalizeError {
286
+ StringifyFailed,
287
+ ParseFailed(String),
288
+ SerializeFailed(String),
289
+ }
290
+
291
+ #[derive(Serialize)]
292
+ #[serde(tag = "error")]
293
+ enum ValidationError {
294
+ StringifyFailed,
295
+ ParseFailed(String),
296
+ InvalidSchema(String),
297
+ EmptyAgentId,
298
+ EmptySubjectId,
299
+ EmptyRoles,
300
+ SpendLimitExceeded,
301
+ InvalidCurrency,
302
+ InvalidTimestamp(String),
303
+ EmptyIssuerId,
304
+ }
305
+
306
+ impl ValidationError {
307
+ fn to_js_value(&self) -> JsValue {
308
+ let json = serde_json::to_string(self).unwrap_or_else(|_| {
309
+ r#"{"error":"SerializeFailed"}"#.to_string()
310
+ });
311
+ let obj = js_sys::Object::new();
312
+ js_sys::Reflect::set(&obj, &"valid".into(), &false.into()).unwrap();
313
+ let parsed = js_sys::JSON::parse(&json).unwrap_or_else(|_| {
314
+ js_sys::Object::new().into()
315
+ });
316
+ if let Ok(err_obj) = parsed.dyn_into::<js_sys::Object>() {
317
+ js_sys::Reflect::set(&obj, &"error".into(), &js_sys::Reflect::get(&err_obj, &"error".into()).unwrap_or_else(|_| "unknown".into())).unwrap();
318
+ }
319
+ obj.into()
320
+ }
321
+ }
322
+
323
+ impl NormalizeError {
324
+ fn to_js_value(&self) -> JsValue {
325
+ let json = serde_json::to_string(self).unwrap_or_else(|_| {
326
+ r#"{"error":"SerializeFailed"}"#.to_string()
327
+ });
328
+ JsValue::from_str(&json)
329
+ }
330
+ }
331
+
332
+ // ── Core functions ───────────────────────────────────────────────────
333
+
334
+ #[wasm_bindgen]
335
+ pub fn normalize(input: JsValue) -> JsValue {
336
+ let input_str = match js_sys::JSON::stringify(&input) {
337
+ Ok(s) => s.as_string().unwrap_or_else(|| String::from("{}")),
338
+ Err(_) => {
339
+ return NormalizeError::StringifyFailed.to_js_value();
340
+ }
341
+ };
342
+
343
+ let cred: AgentCredentialInput = match serde_json::from_str(&input_str) {
344
+ Ok(c) => c,
345
+ Err(e) => {
346
+ return NormalizeError::ParseFailed(e.to_string()).to_js_value();
347
+ }
348
+ };
349
+
350
+ let identity = NormalizedIdentity {
351
+ agent_id: canonicalize_string(&cred.identity.agent_id),
352
+ subject_id: canonicalize_string(&cred.identity.subject_id),
353
+ controller_id: canonicalize_optional(&cred.identity.controller_id),
354
+ org_id: canonicalize_optional(&cred.identity.org_id),
355
+ };
356
+
357
+ let authority = NormalizedAuthority {
358
+ roles: normalize_array_field(&cred.authority.roles, |r| r.name.clone()),
359
+ scopes: normalize_array_field(&cred.authority.scopes, |s| s.name.clone()),
360
+ permissions: normalize_array_field(&cred.authority.permissions, |p| {
361
+ format!("{}:{}", p.resource, p.action)
362
+ }),
363
+ };
364
+
365
+ let fin = cred.financial.unwrap_or(AgentFinancialAuthority {
366
+ spend_limit: None,
367
+ currency: Some(String::from("USD")),
368
+ payment_policy: None,
369
+ });
370
+
371
+ let financial = NormalizedFinancial {
372
+ spend_limit: normalize_spend_limit(&fin.spend_limit),
373
+ currency: fin.currency.unwrap_or_else(|| String::from("USD")),
374
+ payment_policy: fin.payment_policy.unwrap_or_default(),
375
+ };
376
+
377
+ let lifecycle = NormalizedLifecycle {
378
+ issued_at: normalize_timestamp(cred.lifecycle.issued_at),
379
+ expires_at: normalize_optional_timestamp(&cred.lifecycle.expires_at),
380
+ revoked: normalize_bool(&cred.lifecycle.revoked),
381
+ revocation_ref: cred.lifecycle.revocation_ref.unwrap_or_default(),
382
+ };
383
+
384
+ let cc = cred.provenance.chain_context;
385
+
386
+ let provenance = NormalizedProvenance {
387
+ issuer_id: canonicalize_string(&cred.provenance.issuer_id),
388
+ source_system: canonicalize_optional(&cred.provenance.source_system),
389
+ generator_id: canonicalize_optional(&cred.provenance.generator_id),
390
+ chain_id: cc.as_ref()
391
+ .and_then(|c| c.chain_id)
392
+ .map(|id| id.to_string())
393
+ .unwrap_or_default(),
394
+ network: cc.as_ref()
395
+ .and_then(|c| c.network.as_ref().cloned())
396
+ .unwrap_or_default(),
397
+ };
398
+
399
+ let output = NormalizedAgentCredential {
400
+ schema: cred.schema,
401
+ identity,
402
+ authority,
403
+ financial,
404
+ lifecycle,
405
+ provenance,
406
+ };
407
+
408
+ let output_json = match serde_json::to_string(&output) {
409
+ Ok(s) => s,
410
+ Err(e) => {
411
+ return NormalizeError::SerializeFailed(e.to_string()).to_js_value();
412
+ }
413
+ };
414
+
415
+ // Return the normalized JSON as a string; Lemma SDK's define() handles JSON.parse
416
+ JsValue::from_str(&output_json)
417
+ }
418
+
419
+ #[wasm_bindgen]
420
+ pub fn validate(input: JsValue) -> JsValue {
421
+ let input_str = match js_sys::JSON::stringify(&input) {
422
+ Ok(s) => s.as_string().unwrap_or_else(|| String::from("{}")),
423
+ Err(_) => {
424
+ return ValidationError::StringifyFailed.to_js_value();
425
+ }
426
+ };
427
+
428
+ let cred: AgentCredentialInput = match serde_json::from_str(&input_str) {
429
+ Ok(c) => c,
430
+ Err(e) => {
431
+ return ValidationError::ParseFailed(e.to_string()).to_js_value();
432
+ }
433
+ };
434
+
435
+ // Validate schema field
436
+ if cred.schema != "agent-identity-authority-v1" {
437
+ return ValidationError::InvalidSchema(cred.schema).to_js_value();
438
+ }
439
+
440
+ // Validate identity
441
+ if cred.identity.agent_id.is_empty() {
442
+ return ValidationError::EmptyAgentId.to_js_value();
443
+ }
444
+ if cred.identity.subject_id.is_empty() {
445
+ return ValidationError::EmptySubjectId.to_js_value();
446
+ }
447
+
448
+ // Validate lifecycle (fract/negative checks removed — enforced by u64 deserializer)
449
+ if let Some(ref exp) = cred.lifecycle.expires_at {
450
+ if *exp > 4102444800u64 {
451
+ return ValidationError::InvalidTimestamp("lifecycle.expiresAt must be ≤ 4102444800".to_string()).to_js_value();
452
+ }
453
+ if *exp <= cred.lifecycle.issued_at {
454
+ return ValidationError::InvalidTimestamp("lifecycle.expiresAt must be > lifecycle.issuedAt".to_string()).to_js_value();
455
+ }
456
+ }
457
+
458
+ // Validate authority
459
+ if cred.authority.roles.is_empty() {
460
+ return ValidationError::EmptyRoles.to_js_value();
461
+ }
462
+
463
+ // Validate financial
464
+ if let Some(ref fin) = cred.financial {
465
+ if let Some(limit) = fin.spend_limit {
466
+ if limit > 1_000_000_000_000u64 {
467
+ return ValidationError::SpendLimitExceeded.to_js_value();
468
+ }
469
+ }
470
+ if let Some(ref currency) = fin.currency {
471
+ if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_uppercase()) {
472
+ return ValidationError::InvalidCurrency.to_js_value();
473
+ }
474
+ }
475
+ }
476
+
477
+ // Validate provenance
478
+ if cred.provenance.issuer_id.is_empty() {
479
+ return ValidationError::EmptyIssuerId.to_js_value();
480
+ }
481
+
482
+ // All validations passed
483
+ let ok = serde_json::json!({"valid": true});
484
+ JsValue::from_str(&ok.to_string())
485
+ }
486
+
487
+ /// Lemma schema entry point
488
+ /// Format: { result: normalized_json_string, valid: bool }
489
+ #[wasm_bindgen]
490
+ pub fn process(input: JsValue) -> JsValue {
491
+ let normalized = normalize(input.clone());
492
+ let is_valid = validate(input);
493
+
494
+ let obj = js_sys::Object::new();
495
+ js_sys::Reflect::set(&obj, &"result".into(), &normalized).unwrap();
496
+ js_sys::Reflect::set(&obj, &"valid".into(), &is_valid).unwrap();
497
+
498
+ obj.into()
499
+ }
500
+
501
+ #[cfg(test)]
502
+ mod tests {
503
+ use super::*;
504
+
505
+ #[derive(Deserialize)]
506
+ struct TestRequired {
507
+ #[serde(deserialize_with = "strict_u64::deserialize")]
508
+ value: u64,
509
+ }
510
+
511
+ #[derive(Deserialize)]
512
+ struct TestOptional {
513
+ #[serde(default, deserialize_with = "strict_optional_u64::deserialize")]
514
+ value: Option<u64>,
515
+ }
516
+
517
+ #[test]
518
+ fn strict_u64_accepts_valid_integer() {
519
+ let data: TestRequired = serde_json::from_str(r#"{"value": 42}"#).unwrap();
520
+ assert_eq!(data.value, 42u64);
521
+ }
522
+
523
+ #[test]
524
+ fn strict_u64_rejects_fractional() {
525
+ let result = serde_json::from_str::<TestRequired>(r#"{"value": 1.0}"#);
526
+ assert!(result.is_err());
527
+ }
528
+
529
+ #[test]
530
+ fn strict_u64_rejects_negative() {
531
+ let result = serde_json::from_str::<TestRequired>(r#"{"value": -1}"#);
532
+ assert!(result.is_err());
533
+ }
534
+
535
+ #[test]
536
+ fn strict_u64_rejects_null() {
537
+ let result = serde_json::from_str::<TestRequired>(r#"{"value": null}"#);
538
+ assert!(result.is_err());
539
+ }
540
+
541
+ #[test]
542
+ fn strict_u64_rejects_overflow() {
543
+ let result = serde_json::from_str::<TestRequired>(r#"{"value": 18446744073709551616}"#);
544
+ assert!(result.is_err());
545
+ }
546
+
547
+ #[test]
548
+ fn strict_optional_u64_accepts_valid_integer() {
549
+ let data: TestOptional = serde_json::from_str(r#"{"value": 42}"#).unwrap();
550
+ assert_eq!(data.value, Some(42u64));
551
+ }
552
+
553
+ #[test]
554
+ fn strict_optional_u64_accepts_absent() {
555
+ let data: TestOptional = serde_json::from_str(r#"{}"#).unwrap();
556
+ assert_eq!(data.value, None);
557
+ }
558
+
559
+ #[test]
560
+ fn strict_optional_u64_rejects_fractional() {
561
+ let result = serde_json::from_str::<TestOptional>(r#"{"value": 100.5}"#);
562
+ assert!(result.is_err());
563
+ }
564
+
565
+ #[test]
566
+ fn strict_optional_u64_accepts_null_as_none() {
567
+ let data: TestOptional = serde_json::from_str(r#"{"value": null}"#).unwrap();
568
+ assert_eq!(data.value, None);
569
+ }
570
+
571
+ #[test]
572
+ fn strict_optional_u64_rejects_negative() {
573
+ let result = serde_json::from_str::<TestOptional>(r#"{"value": -1}"#);
574
+ assert!(result.is_err());
575
+ }
576
+
577
+ // ── Integration tests ──────────────────────────────────────────
578
+
579
+ fn sample_credential_json() -> &'static str {
580
+ r#"{
581
+ "schema": "agent-identity-authority-v1",
582
+ "identity": {
583
+ "agentId": "agent-1",
584
+ "subjectId": "subject-1"
585
+ },
586
+ "authority": {
587
+ "roles": [{"name": "admin"}],
588
+ "scopes": [],
589
+ "permissions": []
590
+ },
591
+ "lifecycle": {
592
+ "issuedAt": 1714500000,
593
+ "expiresAt": 1717100000
594
+ },
595
+ "provenance": {
596
+ "issuerId": "issuer-1"
597
+ }
598
+ }"#
599
+ }
600
+
601
+ #[test]
602
+ fn fractional_spend_limit_rejected_at_deserialization() {
603
+ let json = r#"{
604
+ "schema": "agent-identity-authority-v1",
605
+ "identity": {"agentId": "a", "subjectId": "s"},
606
+ "authority": {"roles": [{"name": "admin"}], "scopes": [], "permissions": []},
607
+ "lifecycle": {"issuedAt": 1714500000},
608
+ "provenance": {"issuerId": "i"},
609
+ "financial": {"spendLimit": 100.5}
610
+ }"#;
611
+ let result = serde_json::from_str::<AgentCredentialInput>(json);
612
+ assert!(result.is_err(), "Expected deserialization failure for fractional spendLimit");
613
+ }
614
+
615
+ #[test]
616
+ fn valid_credential_deserializes_with_u64_fields() {
617
+ let cred: AgentCredentialInput = serde_json::from_str(sample_credential_json()).unwrap();
618
+ assert_eq!(cred.lifecycle.issued_at, 1714500000u64);
619
+ assert_eq!(cred.lifecycle.expires_at, Some(1717100000u64));
620
+ }
621
+
622
+ #[test]
623
+ fn normalize_produces_deterministic_output() {
624
+ let cred: AgentCredentialInput = serde_json::from_str(sample_credential_json()).unwrap();
625
+ let output1 = normalize_credential(&cred);
626
+ let output2 = normalize_credential(&cred);
627
+ assert_eq!(output1, output2, "Normalized output must be deterministic");
628
+ assert_eq!(output1.as_bytes(), output2.as_bytes(), "Normalized output must be byte-identical across invocations");
629
+ }
630
+
631
+ #[test]
632
+ fn normalize_output_matches_expected_format() {
633
+ let cred: AgentCredentialInput = serde_json::from_str(sample_credential_json()).unwrap();
634
+ let output = normalize_credential(&cred);
635
+ let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
636
+ assert_eq!(parsed["lifecycle"]["issuedAt"], "2024-04-30T18:00:00.000Z");
637
+ assert_eq!(parsed["lifecycle"]["expiresAt"], "2024-05-30T20:13:20.000Z");
638
+ assert_eq!(parsed["financial"]["spendLimit"], "unlimited");
639
+ assert_eq!(parsed["provenance"]["chainId"], "");
640
+ }
641
+
642
+ #[test]
643
+ fn spend_limit_normalization_uses_integer_string() {
644
+ let cred: AgentCredentialInput = serde_json::from_str(r#"{
645
+ "schema": "agent-identity-authority-v1",
646
+ "identity": {"agentId": "a", "subjectId": "s"},
647
+ "authority": {"roles": [{"name": "admin"}], "scopes": [], "permissions": []},
648
+ "lifecycle": {"issuedAt": 1714500000},
649
+ "provenance": {"issuerId": "i"},
650
+ "financial": {"spendLimit": 999999999999}
651
+ }"#).unwrap();
652
+ let output = normalize_credential(&cred);
653
+ assert!(output.contains(r#""spendLimit":"999999999999""#), "Spend limit should be plain integer string, got: {}", output);
654
+ assert!(!output.contains("e+") && !output.contains("E+"), "Spend limit must not use scientific notation");
655
+ }
656
+
657
+ #[test]
658
+ fn absent_spend_limit_normalizes_to_unlimited() {
659
+ let cred: AgentCredentialInput = serde_json::from_str(sample_credential_json()).unwrap();
660
+ let output = normalize_credential(&cred);
661
+ assert!(output.contains(r#""spendLimit":"unlimited""#), "Absent spend limit should normalize to 'unlimited'");
662
+ }
663
+
664
+ #[test]
665
+ fn zero_spend_limit_normalizes_to_zero() {
666
+ let cred: AgentCredentialInput = serde_json::from_str(r#"{
667
+ "schema": "agent-identity-authority-v1",
668
+ "identity": {"agentId": "a", "subjectId": "s"},
669
+ "authority": {"roles": [{"name": "admin"}], "scopes": [], "permissions": []},
670
+ "lifecycle": {"issuedAt": 1714500000},
671
+ "provenance": {"issuerId": "i"},
672
+ "financial": {"spendLimit": 0}
673
+ }"#).unwrap();
674
+ let output = normalize_credential(&cred);
675
+ assert!(output.contains(r#""spendLimit":"0""#), "Zero spend limit should normalize to '0'");
676
+ }
677
+
678
+ #[test]
679
+ fn chain_id_normalizes_to_integer_string() {
680
+ let cred: AgentCredentialInput = serde_json::from_str(r#"{
681
+ "schema": "agent-identity-authority-v1",
682
+ "identity": {"agentId": "a", "subjectId": "s"},
683
+ "authority": {"roles": [{"name": "admin"}], "scopes": [], "permissions": []},
684
+ "lifecycle": {"issuedAt": 1714500000},
685
+ "provenance": {"issuerId": "i", "chainContext": {"chainId": 1, "network": "ethereum"}}
686
+ }"#).unwrap();
687
+ let output = normalize_credential(&cred);
688
+ assert!(output.contains(r#""chainId":"1""#), "Chain ID should normalize to '1'");
689
+ }
690
+
691
+ #[test]
692
+ fn absent_chain_id_normalizes_to_empty() {
693
+ let cred: AgentCredentialInput = serde_json::from_str(sample_credential_json()).unwrap();
694
+ let output = normalize_credential(&cred);
695
+ assert!(output.contains(r#""chainId":""#), "Absent chain ID should normalize to empty string");
696
+ }
697
+
698
+ fn normalize_credential(cred: &AgentCredentialInput) -> String {
699
+ let identity = NormalizedIdentity {
700
+ agent_id: canonicalize_string(&cred.identity.agent_id),
701
+ subject_id: canonicalize_string(&cred.identity.subject_id),
702
+ controller_id: canonicalize_optional(&cred.identity.controller_id),
703
+ org_id: canonicalize_optional(&cred.identity.org_id),
704
+ };
705
+
706
+ let authority = NormalizedAuthority {
707
+ roles: normalize_array_field(&cred.authority.roles, |r| r.name.clone()),
708
+ scopes: normalize_array_field(&cred.authority.scopes, |s| s.name.clone()),
709
+ permissions: normalize_array_field(&cred.authority.permissions, |p| {
710
+ format!("{}:{}", p.resource, p.action)
711
+ }),
712
+ };
713
+
714
+ let default_fin = AgentFinancialAuthority {
715
+ spend_limit: None,
716
+ currency: Some(String::from("USD")),
717
+ payment_policy: None,
718
+ };
719
+ let fin = cred.financial.as_ref().unwrap_or(&default_fin);
720
+
721
+ let financial = NormalizedFinancial {
722
+ spend_limit: normalize_spend_limit(&fin.spend_limit),
723
+ currency: fin.currency.clone().unwrap_or_else(|| String::from("USD")),
724
+ payment_policy: fin.payment_policy.clone().unwrap_or_default(),
725
+ };
726
+
727
+ let lifecycle = NormalizedLifecycle {
728
+ issued_at: normalize_timestamp(cred.lifecycle.issued_at),
729
+ expires_at: normalize_optional_timestamp(&cred.lifecycle.expires_at),
730
+ revoked: normalize_bool(&cred.lifecycle.revoked),
731
+ revocation_ref: cred.lifecycle.revocation_ref.clone().unwrap_or_default(),
732
+ };
733
+
734
+ let cc = &cred.provenance.chain_context;
735
+
736
+ let provenance = NormalizedProvenance {
737
+ issuer_id: canonicalize_string(&cred.provenance.issuer_id),
738
+ source_system: canonicalize_optional(&cred.provenance.source_system),
739
+ generator_id: canonicalize_optional(&cred.provenance.generator_id),
740
+ chain_id: cc.as_ref()
741
+ .and_then(|c| c.chain_id)
742
+ .map(|id| id.to_string())
743
+ .unwrap_or_default(),
744
+ network: cc.as_ref()
745
+ .and_then(|c| c.network.clone())
746
+ .unwrap_or_default(),
747
+ };
748
+
749
+ let output = NormalizedAgentCredential {
750
+ schema: cred.schema.clone(),
751
+ identity,
752
+ authority,
753
+ financial,
754
+ lifecycle,
755
+ provenance,
756
+ };
757
+
758
+ serde_json::to_string(&output).unwrap()
759
+ }
760
+ }