use std::collections::BTreeMap;
use serde_json::Value;
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
use vta_sdk::provision_integration::{BootstrapAsk, DidTemplateRef, VerifiedBootstrapRequest};
use super::ProvisionIntegrationDeps;
#[derive(Debug)]
pub struct AmbiguousContext {
pub candidates: Vec<String>,
pub message: String,
}
pub async fn infer_target_context(
auth: &AuthClaims,
contexts_ks: &KeyspaceHandle,
) -> Result<Result<String, AmbiguousContext>, AppError> {
if let Some(ctx) = auth.default_context() {
return Ok(Ok(ctx.to_string()));
}
if auth.is_super_admin() {
let contexts = crate::contexts::list_contexts(contexts_ks).await?;
let mut ids: Vec<String> = contexts.iter().map(|c| c.id.clone()).collect();
ids.sort();
match ids.len() {
0 => {
return Err(AppError::NotFound(
"no contexts registered on this VTA — create one with \
'vta contexts create --id <name>' (offline) or \
'pnm contexts create' (online), then retry"
.into(),
));
}
1 => return Ok(Ok(ids.into_iter().next().expect("len == 1"))),
n => {
return Ok(Err(AmbiguousContext {
candidates: ids,
message: format!(
"super-admin grant against {n} contexts — \
specify which to provision into via payload.context"
),
}));
}
}
}
if auth.allowed_contexts.len() > 1 {
let mut ids = auth.allowed_contexts.clone();
ids.sort();
return Ok(Err(AmbiguousContext {
message: format!(
"caller holds admin in {} contexts — specify which to provision into via payload.context",
ids.len()
),
candidates: ids,
}));
}
Err(AppError::Forbidden(
"caller has admin role but no context grants — refusing to infer".into(),
))
}
pub async fn ensure_target_context_or_create(
contexts_ks: &KeyspaceHandle,
auth: &AuthClaims,
context: &str,
create_context: bool,
) -> Result<bool, AppError> {
if crate::contexts::get_context(contexts_ks, context)
.await?
.is_some()
{
return Ok(false);
}
if !create_context {
return Err(AppError::NotFound(format!(
"context '{context}' is not registered on this VTA — create it first via \
'vta contexts create --id {context}' (offline) or 'pnm contexts create' (online), \
or pass '--create-context' to provision it inline"
)));
}
crate::operations::contexts::create_context(
contexts_ks,
auth,
context,
context.to_string(),
None,
None, "provision-integration",
)
.await?;
Ok(true)
}
#[derive(Debug)]
pub enum ResolveContextError {
Ambiguous(AmbiguousContext),
Op(AppError),
}
impl From<AppError> for ResolveContextError {
fn from(e: AppError) -> Self {
Self::Op(e)
}
}
pub async fn resolve_target_context(
auth: &AuthClaims,
contexts_ks: &KeyspaceHandle,
requested: Option<String>,
create_context: bool,
) -> Result<(String, bool), ResolveContextError> {
let context = match requested {
Some(c) => c,
None => infer_target_context(auth, contexts_ks)
.await?
.map_err(ResolveContextError::Ambiguous)?,
};
let created =
ensure_target_context_or_create(contexts_ks, auth, &context, create_context).await?;
Ok((context, created))
}
pub(super) async fn preconditions(
state: &ProvisionIntegrationDeps,
auth: &AuthClaims,
context: &str,
request: &VerifiedBootstrapRequest,
) -> Result<(), AppError> {
auth.require_admin()?;
auth.require_context(context)?;
if crate::contexts::get_context(&state.contexts_ks, context)
.await?
.is_none()
{
return Err(AppError::NotFound(format!(
"context '{context}' is not registered on this VTA — create it first via \
'vta context create --id {context}' (offline) or 'pnm contexts create' (online), \
or pass '--create-context' to provision it inline"
)));
}
let hint = match request.ask() {
BootstrapAsk::TemplateBootstrap(ask) => ask.context_hint.as_deref(),
BootstrapAsk::AdminRotation(ask) => ask.context_hint.as_deref(),
};
if let Some(hint) = hint
&& hint != context
{
return Err(AppError::Validation(format!(
"request contextHint '{hint}' does not match provisioning context '{context}'"
)));
}
let (integration_template_name, admin_template_name): (Option<String>, Option<String>) =
match request.ask() {
BootstrapAsk::TemplateBootstrap(ask) => (
Some(ask.template.name.clone()),
ask.admin_template.as_ref().map(|t| t.name.clone()),
),
BootstrapAsk::AdminRotation(ask) => (None, Some(ask.admin_template.name.clone())),
};
if let Some(template_name) = integration_template_name.as_deref() {
let template_registered = crate::did_templates::get_context_template(
&state.did_templates_ks,
context,
template_name,
)
.await?
.is_some()
|| crate::did_templates::get_global_template(&state.did_templates_ks, template_name)
.await?
.is_some()
|| vta_sdk::did_templates::load_embedded(template_name).is_ok();
if !template_registered {
return Err(AppError::Validation(format!(
"template '{template_name}' is not registered on this VTA. Register it via \
'pnm did-templates create {template_name} --file <path>' then retry"
)));
}
}
if let Some(name) = admin_template_name {
let registered =
crate::did_templates::get_context_template(&state.did_templates_ks, context, &name)
.await?
.is_some()
|| crate::did_templates::get_global_template(&state.did_templates_ks, &name)
.await?
.is_some()
|| vta_sdk::did_templates::load_embedded(&name).is_ok();
if !registered {
return Err(AppError::Validation(format!(
"admin template '{name}' is not registered on this VTA. Register it via \
'pnm did-templates create {name} --file <path>' then retry, or use the \
built-in 'vta-admin' template."
)));
}
}
Ok(())
}
pub(super) fn extract_template(
ask: &BootstrapAsk,
) -> Result<Option<(String, BTreeMap<String, Value>)>, AppError> {
match ask {
BootstrapAsk::TemplateBootstrap(ask) => {
Ok(Some((ask.template.name.clone(), ask.template.vars.clone())))
}
BootstrapAsk::AdminRotation(_) => Ok(None),
}
}
pub(super) fn extract_admin_template(ask: &BootstrapAsk) -> Option<DidTemplateRef> {
match ask {
BootstrapAsk::TemplateBootstrap(ask) => ask.admin_template.clone(),
BootstrapAsk::AdminRotation(ask) => Some(ask.admin_template.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Role;
use crate::store::Store;
use vti_common::config::StoreConfig;
async fn fresh_contexts_ks() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().expect("temp dir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store
.keyspace(crate::keyspaces::CONTEXTS)
.expect("open contexts ks");
(dir, store, ks)
}
fn auth(role: Role, allowed_contexts: Vec<&str>) -> AuthClaims {
AuthClaims {
did: "did:key:zTestCaller".into(),
role,
allowed_contexts: allowed_contexts.iter().map(|s| (*s).to_string()).collect(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: vec!["synth".into()],
acr: String::new(),
}
}
async fn seed_context(ks: &KeyspaceHandle, id: &str) {
crate::contexts::create_context(ks, id, id)
.await
.expect("seed context");
}
#[tokio::test]
async fn infer_returns_single_allowed_context() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
seed_context(&ks, "ctx_a").await;
seed_context(&ks, "ctx_b").await;
let a = auth(Role::Admin, vec!["ctx_b"]);
let result = infer_target_context(&a, &ks).await.expect("ok");
assert_eq!(result.expect("not ambiguous"), "ctx_b");
}
#[tokio::test]
async fn infer_super_admin_with_single_context() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
seed_context(&ks, "only").await;
let a = auth(Role::Admin, vec![]); assert!(a.is_super_admin());
let result = infer_target_context(&a, &ks).await.expect("ok");
assert_eq!(result.expect("not ambiguous"), "only");
}
#[tokio::test]
async fn infer_super_admin_with_multiple_contexts_is_ambiguous() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
seed_context(&ks, "ctx_a").await;
seed_context(&ks, "ctx_b").await;
seed_context(&ks, "ctx_c").await;
let a = auth(Role::Admin, vec![]);
let result = infer_target_context(&a, &ks).await.expect("ok");
let ambiguous = result.expect_err("must be ambiguous with 3 contexts");
assert_eq!(
ambiguous.candidates,
vec![
"ctx_a".to_string(),
"ctx_b".to_string(),
"ctx_c".to_string()
]
);
assert!(ambiguous.message.contains("3 contexts"));
}
#[tokio::test]
async fn infer_multi_context_grant_is_ambiguous() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
let a = auth(Role::Admin, vec!["ctx_x", "ctx_y"]);
let result = infer_target_context(&a, &ks).await.expect("ok");
let ambiguous = result.expect_err("two contexts → ambiguous");
assert_eq!(
ambiguous.candidates,
vec!["ctx_x".to_string(), "ctx_y".to_string()]
);
}
#[tokio::test]
async fn infer_super_admin_with_no_contexts_returns_not_found() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
let a = auth(Role::Admin, vec![]);
let err = infer_target_context(&a, &ks)
.await
.expect_err("must NotFound");
assert!(
matches!(err, AppError::NotFound(_)),
"expected NotFound, got {err:?}"
);
}
#[tokio::test]
async fn resolve_uses_requested_existing_context() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
seed_context(&ks, "ctx_a").await;
let a = auth(Role::Admin, vec!["ctx_a"]);
let (context, created) = resolve_target_context(&a, &ks, Some("ctx_a".into()), false)
.await
.map_err(|_| ())
.expect("resolves");
assert_eq!(context, "ctx_a");
assert!(!created);
}
#[tokio::test]
async fn resolve_creates_requested_context_inline() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
let a = auth(Role::Admin, vec![]);
let (context, created) = resolve_target_context(&a, &ks, Some("ctx-new".into()), true)
.await
.expect("resolves");
assert_eq!(context, "ctx-new");
assert!(created);
assert!(
crate::contexts::get_context(&ks, "ctx-new")
.await
.unwrap()
.is_some(),
"context must have been created"
);
}
#[tokio::test]
async fn resolve_propagates_ambiguous_inference() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
let a = auth(Role::Admin, vec!["ctx_x", "ctx_y"]);
let err = resolve_target_context(&a, &ks, None, false)
.await
.err()
.expect("ambiguous");
match err {
ResolveContextError::Ambiguous(amb) => {
assert_eq!(
amb.candidates,
vec!["ctx_x".to_string(), "ctx_y".to_string()]
);
}
ResolveContextError::Op(e) => panic!("expected Ambiguous, got Op({e:?})"),
}
}
#[tokio::test]
async fn resolve_missing_context_without_create_is_op_not_found() {
let (_dir, _store, ks) = fresh_contexts_ks().await;
let a = auth(Role::Admin, vec!["ctx_absent"]);
let err = resolve_target_context(&a, &ks, Some("ctx_absent".into()), false)
.await
.err()
.expect("not found");
assert!(
matches!(err, ResolveContextError::Op(AppError::NotFound(_))),
"expected Op(NotFound)",
);
}
}