use axum::Json;
use serde::Serialize;
use serde_json::{Value as JsonValue, json};
use crate::auth::AuthClaims;
#[derive(Serialize)]
pub struct FieldOption {
pub value: &'static str,
pub label: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowWhen {
pub field: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub eq: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truthy: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldDef {
pub key: &'static str,
pub label: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<&'static str>,
#[serde(rename = "type")]
pub field_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<FieldOption>>,
pub default: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_when: Option<ShowWhen>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CeremonyManifest {
pub purpose: &'static str,
pub pkg: &'static str,
pub nature: &'static str,
pub label: &'static str,
pub wired: &'static str,
pub blurb: &'static str,
pub fields: Vec<FieldDef>,
pub facts_template: JsonValue,
}
fn role_options() -> Vec<FieldOption> {
vec![
FieldOption {
value: "member",
label: "member",
},
FieldOption {
value: "moderator",
label: "moderator",
},
FieldOption {
value: "admin",
label: "admin",
},
]
}
fn base_context() -> JsonValue {
json!({
"community_did": "did:webvh:demo.example",
"channel": "rest",
"member_count": 42,
})
}
fn manifests() -> Vec<CeremonyManifest> {
vec![
CeremonyManifest {
purpose: "directory",
pkg: "vtc.directory",
nature: "read-only",
label: "Directory",
wired: "live",
blurb: "A member views another member's record. Read-only — the verdict's allow carries a field projection, capped by the PII boundary.",
fields: vec![
FieldDef {
key: "viewerRole",
label: "Viewer's community role",
hint: Some("actor.role — admin sees the fuller record"),
field_type: "select",
options: Some(vec![
FieldOption {
value: "admin",
label: "admin",
},
FieldOption {
value: "member",
label: "member",
},
FieldOption {
value: "",
label: "none (authenticated)",
},
]),
default: json!("member"),
show_when: None,
},
FieldDef {
key: "subjectIsMember",
label: "Subject is a member",
hint: Some("state.subject_member present"),
field_type: "toggle",
options: None,
default: json!(true),
show_when: None,
},
FieldDef {
key: "subjectRole",
label: "Subject's role",
hint: Some("state.subject_member.role"),
field_type: "select",
options: Some(role_options()),
default: json!("member"),
show_when: Some(ShowWhen {
field: "subjectIsMember",
eq: None,
truthy: Some(true),
}),
},
],
facts_template: json!({
"purpose": "directory",
"now": "$now",
"actor": { "did": "did:key:zViewer", "role": "$field:viewerRole", "authenticated": true },
"subject": { "did": "did:key:zTarget" },
"context": base_context(),
"evidence": { "request": { "fields_requested": ["did", "role", "joined_at", "status"] } },
"state": {
"subject_member": {
"$if": "subjectIsMember",
"then": { "role": "$field:subjectRole", "status": "active", "joined_at": "2026-01-02T00:00:00Z" },
"else": null
}
}
}),
},
CeremonyManifest {
purpose: "join",
pkg: "vtc.join",
nature: "constructive",
label: "Join",
wired: "live",
blurb: "A DID joins the community. A trusted presented credential auto-admits (allow → issue the membership credential); everything else is referred to the moderator queue for review.",
fields: vec![FieldDef {
key: "joinTrusted",
label: "Presented credential is trusted",
hint: Some("evidence.presentation.credentials[].issuer_trusted"),
field_type: "toggle",
options: None,
default: json!(false),
show_when: None,
}],
facts_template: json!({
"purpose": "join",
"now": "$now",
"actor": { "did": "did:key:zApplicant", "authenticated": true },
"subject": { "did": "did:key:zApplicant" },
"context": base_context(),
"evidence": {
"presentation": {
"verified": true,
"holder": "did:key:zApplicant",
"credentials": [{
"type": "WitnessCredential",
"issuer": "did:webvh:notary.example",
"issuer_trusted": "$field:joinTrusted",
"status": "valid",
"claims": {}
}]
}
},
"state": { "subject_member": null }
}),
},
CeremonyManifest {
purpose: "removal",
pkg: "vtc.removal",
nature: "destructive",
label: "Leave",
wired: "live",
blurb: "A member departs or is removed. Self-leave is unconditional; an admin may remove a non-admin. The no-last-admin invariant + revocation are host-enforced in the effect.",
fields: vec![
FieldDef {
key: "selfLeave",
label: "Self-leave",
hint: Some("actor.did == subject.did — always allowed"),
field_type: "toggle",
options: None,
default: json!(false),
show_when: None,
},
FieldDef {
key: "subjectRole",
label: "Subject's role",
hint: Some("an admin may remove a non-admin only"),
field_type: "select",
options: Some(role_options()),
default: json!("member"),
show_when: Some(ShowWhen {
field: "selfLeave",
eq: None,
truthy: Some(false),
}),
},
],
facts_template: json!({
"purpose": "leave",
"now": "$now",
"actor": { "did": "did:key:zActor", "role": "admin", "authenticated": true },
"subject": { "did": { "$if": "selfLeave", "then": "did:key:zActor", "else": "did:key:zTarget" } },
"context": base_context(),
"evidence": { "request": { "disposition": "tombstone" } },
"state": { "subject_member": { "role": "$field:subjectRole", "status": "active", "joined_at": "2026-01-02T00:00:00Z" } }
}),
},
CeremonyManifest {
purpose: "roleChange",
pkg: "vtc.role_change",
nature: "mutating",
label: "Role change",
wired: "live",
blurb: "A member's role changes in place (the DID + VMC are unchanged; the role VEC is re-minted). The one ceremony whose allow may grant admin — gated by a verified step-up; demotions are guarded by no-last-admin.",
fields: vec![
FieldDef {
key: "targetRole",
label: "Target role",
hint: Some("evidence.request.target_role"),
field_type: "select",
options: Some(vec![
FieldOption {
value: "member",
label: "member",
},
FieldOption {
value: "moderator",
label: "moderator",
},
FieldOption {
value: "admin",
label: "admin (promotion)",
},
]),
default: json!("moderator"),
show_when: None,
},
FieldDef {
key: "stepUp",
label: "Step-up verified",
hint: Some("admin needs step-up — else the verdict refers"),
field_type: "toggle",
options: None,
default: json!(false),
show_when: Some(ShowWhen {
field: "targetRole",
eq: Some(json!("admin")),
truthy: None,
}),
},
],
facts_template: json!({
"purpose": "role-change",
"now": "$now",
"actor": { "did": "did:key:zAdmin", "role": "admin", "authenticated": true },
"subject": { "did": "did:key:zTarget" },
"context": base_context(),
"evidence": { "request": { "target_role": "$field:targetRole", "step_up": "$field:stepUp" } },
"state": { "subject_member": { "role": "member", "status": "active", "joined_at": "2026-01-02T00:00:00Z" } }
}),
},
]
}
pub async fn list(_claims: AuthClaims) -> Json<Vec<CeremonyManifest>> {
Json(manifests())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_ceremonies_with_stable_purposes() {
let m = manifests();
let purposes: Vec<_> = m.iter().map(|c| c.purpose).collect();
assert_eq!(purposes, vec!["directory", "join", "removal", "roleChange"]);
}
#[test]
fn every_field_default_is_present_and_typed() {
for c in manifests() {
for f in &c.fields {
assert!(!f.default.is_null(), "{}::{} default", c.purpose, f.key);
if f.field_type == "select" {
assert!(
f.options.is_some(),
"{}::{} needs options",
c.purpose,
f.key
);
}
}
}
}
#[test]
fn facts_template_carries_the_purpose() {
for c in manifests() {
let p = c.facts_template.get("purpose").and_then(|v| v.as_str());
assert!(p.is_some(), "{} facts_template purpose", c.purpose);
}
}
}