1pub use vta_sdk::contexts::ContextRecord;
2
3use chrono::Utc;
4use vta_sdk::context_path::parent_path;
5use vta_sdk::context_policy::ContextPolicy;
6
7use vti_common::error::AppError;
8use vti_common::store::KeyspaceHandle;
9
10fn ctx_key(id: &str) -> String {
11 format!("ctx:{id}")
12}
13
14pub async fn get_context(ks: &KeyspaceHandle, id: &str) -> Result<Option<ContextRecord>, AppError> {
16 ks.get(ctx_key(id)).await
17}
18
19pub async fn effective_context_policy(
28 ks: &KeyspaceHandle,
29 context_id: &str,
30) -> Result<ContextPolicy, AppError> {
31 let mut ids: Vec<String> = Vec::new();
33 let mut cur: Option<String> = Some(context_id.to_string());
34 while let Some(id) = cur {
35 cur = parent_path(&id).map(str::to_string);
36 ids.push(id);
37 }
38 ids.reverse();
39
40 let mut policies: Vec<ContextPolicy> = Vec::new();
41 for id in &ids {
42 if let Some(rec) = get_context(ks, id).await?
43 && let Some(policy) = rec.context_policy
44 {
45 policies.push(policy);
46 }
47 }
48 Ok(ContextPolicy::resolve(policies.iter()))
49}
50
51pub async fn enforce_daily_quota(
59 ks: &KeyspaceHandle,
60 context_id: &str,
61 op_class: &str,
62 limit: u64,
63) -> Result<(), AppError> {
64 let day = Utc::now().format("%Y-%m-%d");
65 let key = format!("quota:{context_id}:{op_class}:{day}");
66 let slot = vti_common::store::counter::allocate_u32(ks, &key).await?;
67 if u64::from(slot) >= limit {
68 return Err(AppError::Forbidden(format!(
69 "daily quota exceeded for {op_class} in context {context_id} ({limit}/day)"
70 )));
71 }
72 Ok(())
73}
74
75pub async fn store_context(ks: &KeyspaceHandle, record: &ContextRecord) -> Result<(), AppError> {
77 ks.insert(ctx_key(&record.id), record).await
78}
79
80pub async fn store_new_context(
85 ks: &KeyspaceHandle,
86 record: &ContextRecord,
87) -> Result<bool, AppError> {
88 ks.insert_if_absent(ctx_key(&record.id), record).await
89}
90
91pub async fn delete_context(ks: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
93 ks.remove(ctx_key(id)).await
94}
95
96pub async fn list_contexts(ks: &KeyspaceHandle) -> Result<Vec<ContextRecord>, AppError> {
102 let raw = ks.prefix_iter_raw("ctx:").await?;
103 let mut records = Vec::with_capacity(raw.len());
104 let mut skipped = 0usize;
105 for (key, value) in raw {
106 match serde_json::from_slice::<ContextRecord>(&value) {
107 Ok(record) => records.push(record),
108 Err(e) => {
109 skipped += 1;
110 tracing::warn!(
111 key = %String::from_utf8_lossy(&key),
112 error = %e,
113 "skipping undeserializable context row in list_contexts"
114 );
115 }
116 }
117 }
118 if skipped > 0 {
119 tracing::warn!(skipped, "list_contexts skipped corrupt rows");
120 }
121 Ok(records)
122}
123
124pub async fn allocate_context_index(
135 ks: &KeyspaceHandle,
136 base_prefix: &str,
137 counter_key: &str,
138) -> Result<(u32, String), AppError> {
139 let current = vti_common::store::counter::allocate_u32(ks, counter_key).await?;
143 let base_path = format!("{base_prefix}/{current}'");
144 Ok((current, base_path))
145}
146
147pub fn validate_slug(id: &str) -> Result<(), AppError> {
161 if id.is_empty() {
162 return Err(AppError::Validation("context id cannot be empty".into()));
163 }
164 if id.len() > 64 {
165 return Err(AppError::Validation(
166 "context id must be 64 characters or fewer".into(),
167 ));
168 }
169 if !id
170 .chars()
171 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
172 {
173 return Err(AppError::Validation(
174 "context id must contain only lowercase alphanumeric characters and hyphens".into(),
175 ));
176 }
177 if id.starts_with('-') || id.ends_with('-') {
178 return Err(AppError::Validation(
179 "context id must not start or end with a hyphen".into(),
180 ));
181 }
182 Ok(())
183}
184
185pub async fn create_context(
193 contexts_ks: &KeyspaceHandle,
194 id: &str,
195 name: &str,
196) -> Result<ContextRecord, Box<dyn std::error::Error>> {
197 validate_slug(id).map_err(|e| format!("{e}"))?;
198 let (index, base_path) = allocate_context_index(contexts_ks, CONTEXT_KEY_BASE, "ctx_counter")
199 .await
200 .map_err(|e| format!("{e}"))?;
201 let now = Utc::now();
202 let record = ContextRecord {
203 id: id.to_string(),
204 name: name.to_string(),
205 did: None,
206 description: None,
207 parent: None,
208 base_path,
209 index,
210 created_at: now,
211 updated_at: now,
212 context_policy: None,
213 };
214 if !store_new_context(contexts_ks, &record)
215 .await
216 .map_err(|e| format!("{e}"))?
217 {
218 return Err(format!("context already exists: {id}").into());
221 }
222 Ok(record)
223}
224
225pub const CONTEXT_KEY_BASE: &str = "m/26'/2'";
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use vti_common::config::StoreConfig;
232 use vti_common::store::Store;
233
234 fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
235 let dir = tempfile::tempdir().expect("tempdir");
236 let store = Store::open(&StoreConfig {
237 data_dir: dir.path().to_path_buf(),
238 })
239 .expect("open store");
240 (
241 store.keyspace(vta_keyspaces::CONTEXTS).expect("keyspace"),
242 dir,
243 )
244 }
245
246 #[tokio::test]
247 async fn daily_quota_allows_up_to_limit_then_forbids() {
248 let (ks, _dir) = temp_ks();
249 enforce_daily_quota(&ks, "sales", "sign", 2).await.unwrap();
251 enforce_daily_quota(&ks, "sales", "sign", 2).await.unwrap();
252 let err = enforce_daily_quota(&ks, "sales", "sign", 2).await;
253 assert!(matches!(err, Err(AppError::Forbidden(_))), "{err:?}");
254
255 enforce_daily_quota(&ks, "sales", "vault/release", 1)
258 .await
259 .unwrap();
260 let err2 = enforce_daily_quota(&ks, "sales", "vault/release", 1).await;
261 assert!(matches!(err2, Err(AppError::Forbidden(_))), "{err2:?}");
262 enforce_daily_quota(&ks, "eng", "sign", 2).await.unwrap();
263 }
264
265 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
270 async fn allocate_context_index_is_collision_free_under_concurrency() {
271 let (ks, _dir) = temp_ks();
272
273 let n = 64usize;
274 let mut handles = Vec::with_capacity(n);
275 for _ in 0..n {
276 let ks = ks.clone();
277 handles.push(tokio::spawn(async move {
278 allocate_context_index(&ks, CONTEXT_KEY_BASE, "ctx_counter")
279 .await
280 .expect("alloc")
281 }));
282 }
283 let mut paths = std::collections::HashSet::with_capacity(n);
284 for h in handles {
285 let (_, base_path) = h.await.expect("join");
286 assert!(
287 paths.insert(base_path.clone()),
288 "duplicate context base path {base_path}"
289 );
290 }
291 assert_eq!(paths.len(), n);
292 }
293
294 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
298 async fn concurrent_same_id_creates_admit_exactly_one() {
299 let (ks, _dir) = temp_ks();
300
301 let mut handles = Vec::new();
302 for _ in 0..16 {
303 let ks = ks.clone();
304 handles.push(tokio::spawn(async move {
305 create_context(&ks, "contested", "Contested").await.ok()
306 }));
307 }
308 let mut winners = Vec::new();
309 for h in handles {
310 if let Some(rec) = h.await.expect("join") {
311 winners.push(rec);
312 }
313 }
314 assert_eq!(winners.len(), 1, "exactly one same-id create may win");
315
316 let stored = get_context(&ks, "contested")
317 .await
318 .expect("get")
319 .expect("record exists");
320 assert_eq!(
321 stored.base_path, winners[0].base_path,
322 "stored record must be the winner's — no overwrite by losers"
323 );
324 }
325
326 #[test]
327 fn validate_slug_accepts_canonical_shapes() {
328 for ok in ["a", "vta", "trust-registry", "ctx-1", "a1-b2-c3"] {
329 validate_slug(ok).unwrap_or_else(|e| panic!("{ok:?} should be valid: {e}"));
330 }
331 validate_slug(&"a".repeat(64)).expect("64 chars is the limit, not over it");
332 }
333
334 #[test]
335 fn validate_slug_rejects_non_canonical_shapes() {
336 for bad in [
341 "MyApp", "ctx.v2", "my_app", "-lead", "trail-", "", "a/b", "a b",
342 ] {
343 assert!(
344 validate_slug(bad).is_err(),
345 "{bad:?} must be rejected as a context id"
346 );
347 }
348 assert!(validate_slug(&"a".repeat(65)).is_err(), "65 chars is over");
349 }
350
351 #[tokio::test]
356 async fn low_level_create_context_enforces_the_slug_rule() {
357 let (ks, _dir) = temp_ks();
358
359 let err = create_context(&ks, "MyApp", "My App")
360 .await
361 .expect_err("non-slug id must be refused");
362 assert!(
363 err.to_string().contains("lowercase"),
364 "error should name the rule, got: {err}"
365 );
366
367 assert!(
368 get_context(&ks, "MyApp").await.expect("get").is_none(),
369 "a rejected id must not have been persisted"
370 );
371
372 create_context(&ks, "myapp", "My App")
373 .await
374 .expect("the slug form is accepted");
375 }
376}