@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.
- package/README.md +190 -0
- package/dist/commit.d.ts +17 -0
- package/dist/commit.d.ts.map +1 -0
- package/dist/commit.js +84 -0
- package/dist/commit.js.map +1 -0
- package/dist/credential.d.ts +13 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +59 -0
- package/dist/credential.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +114 -0
- package/dist/validate.js.map +1 -0
- package/normalize/Cargo.toml +22 -0
- package/normalize/src/lib.rs +760 -0
- package/package.json +56 -0
- package/scripts/build-wasm.sh +46 -0
|
@@ -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
|
+
}
|