Skip to main content

auths_cli/commands/
org.rs

1use anyhow::{Context, Result, anyhow};
2use auths_sdk::attestation::create_signed_attestation;
3use auths_sdk::attestation::create_signed_revocation;
4use auths_sdk::identity::DidResolver;
5use chrono::{DateTime, Utc};
6use clap::{ArgAction, Parser, Subcommand};
7use serde_json;
8use std::fs;
9use std::path::PathBuf;
10
11use auths_sdk::attestation::AttestationGroup;
12use auths_sdk::identity::DefaultDidResolver;
13use auths_sdk::keychain::{KeyAlias, get_platform_keychain};
14use auths_sdk::ports::{AttestationMetadata, AttestationSource, IdentityStorage, RegistryBackend};
15use auths_sdk::signing::StorageSigner;
16use auths_sdk::storage_layout::{StorageLayoutConfig, layout};
17
18use auths_sdk::storage::{
19    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
20};
21use auths_sdk::workflows::commit_trust::commit_signer_trailers;
22use auths_sdk::workflows::org::{
23    AuthorityAtSigning, Role, add_member, build_org_bundle, classify_authority_at_signing,
24    create_org, fleet_metrics, list_members, list_offboarding_records, load_offboarding_record,
25    load_org_policy, member_role_order, org_slug_alias, resolve_org_signing_alias, revoke_member,
26    set_org_oidc_policy, set_org_policy, walk_delegation_chain,
27};
28
29use crate::factories::storage::build_auths_context;
30use auths_verifier::Prefix;
31use auths_verifier::types::CanonicalDid;
32
33use clap::ValueEnum;
34
35/// CLI-level role wrapper that derives `ValueEnum` for argument parsing.
36///
37/// Converts to `auths_sdk::workflows::org::Role` at the CLI boundary.
38#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
39pub enum CliRole {
40    Admin,
41    Member,
42    Readonly,
43}
44
45impl From<CliRole> for Role {
46    fn from(r: CliRole) -> Self {
47        match r {
48            CliRole::Admin => Role::Admin,
49            CliRole::Member => Role::Member,
50            CliRole::Readonly => Role::Readonly,
51        }
52    }
53}
54
55/// The `org` subcommand, handling member authorizations.
56#[derive(Parser, Debug, Clone)]
57#[command(
58    about = "Manage organization identities and memberships",
59    after_help = "Examples:
60  auths org create --name 'My Organization'
61                        # Create a new org identity
62  auths org add-member --org did:keri:EOrg --member did:keri:EMember --role admin --key orgkey
63                        # Add a member with admin role
64  auths org revoke-member --org did:keri:EOrg --member did:keri:EMember --key orgkey
65                        # Revoke a member's access
66
67Related:
68  auths id        — Manage individual identities
69  auths namespace — Claim and manage package namespaces
70  auths policy    — Define capability policies"
71)]
72pub struct OrgCommand {
73    #[clap(subcommand)]
74    pub subcommand: OrgSubcommand,
75
76    #[command(flatten)]
77    pub overrides: crate::commands::registry_overrides::RegistryOverrides,
78}
79
80/// Subcommands for managing authorizations issued by this identity.
81#[derive(Subcommand, Debug, Clone)]
82pub enum OrgSubcommand {
83    /// Create a new organization identity
84    #[command(visible_alias = "init")]
85    Create {
86        /// Organization name
87        #[arg(long)]
88        name: String,
89
90        /// Alias for the local signing key (auto-generated if not provided)
91        #[arg(long)]
92        key: Option<String>,
93
94        /// Optional metadata file (if provided, merged with org metadata)
95        #[arg(long)]
96        metadata_file: Option<PathBuf>,
97    },
98    Attest {
99        #[arg(long = "subject", visible_alias = "subject-did")]
100        subject_did: String,
101        #[arg(long)]
102        payload_file: PathBuf,
103        #[arg(long)]
104        note: Option<String>,
105        #[arg(long)]
106        expires_at: Option<String>,
107        #[arg(long)]
108        key: Option<String>,
109    },
110    Revoke {
111        #[arg(long = "subject", visible_alias = "subject-did")]
112        subject_did: String,
113        #[arg(long)]
114        note: Option<String>,
115        #[arg(long)]
116        key: Option<String>,
117    },
118    Show {
119        #[arg(long = "subject", visible_alias = "subject-did")]
120        subject_did: String,
121        #[arg(long, action = ArgAction::SetTrue)]
122        include_revoked: bool,
123    },
124    List {
125        #[arg(long, action = ArgAction::SetTrue)]
126        include_revoked: bool,
127    },
128    /// Add a member to an organization
129    AddMember {
130        /// Organization identity ID
131        #[arg(long)]
132        org: String,
133
134        /// Member identity ID to add
135        #[arg(long = "member", visible_alias = "member-did")]
136        member_did: String,
137
138        /// Role to assign (admin, member, readonly)
139        #[arg(long, value_enum)]
140        role: CliRole,
141
142        /// Override default capabilities (comma-separated)
143        #[arg(long, value_delimiter = ',')]
144        capabilities: Option<Vec<auths_keri::Capability>>,
145
146        /// Alias of the signing key in keychain
147        #[arg(long)]
148        key: Option<String>,
149
150        /// Optional note for the authorization
151        #[arg(long)]
152        note: Option<String>,
153    },
154
155    /// Revoke a member from an organization
156    RevokeMember {
157        /// Organization identity ID
158        #[arg(long)]
159        org: String,
160
161        /// Member identity ID to revoke
162        #[arg(long = "member", visible_alias = "member-did")]
163        member_did: String,
164
165        /// Reason for revocation
166        #[arg(long)]
167        note: Option<String>,
168
169        /// Alias of the signing key in keychain
170        #[arg(long)]
171        key: Option<String>,
172
173        /// Preview actions without making changes.
174        #[arg(long)]
175        dry_run: bool,
176    },
177
178    /// List members of an organization
179    ListMembers {
180        /// Organization identity ID
181        #[arg(long)]
182        org: String,
183
184        /// Include revoked members
185        #[arg(long, action = ArgAction::SetTrue)]
186        include_revoked: bool,
187    },
188
189    /// Classify a member's authority at an artifact's signing position (by KEL position)
190    Audit {
191        /// Organization identity ID
192        #[arg(long)]
193        org: String,
194
195        /// Member identity ID to classify
196        #[arg(long = "member", visible_alias = "member-did")]
197        member_did: String,
198
199        /// Artifact path (shown in the report for context)
200        #[arg(long)]
201        artifact: Option<PathBuf>,
202
203        /// The artifact's in-band signing KEL position (e.g. a commit's `Auths-Anchor-Seq`)
204        #[arg(long)]
205        signed_at: Option<u128>,
206
207        /// Emit the typed verdict as JSON
208        #[arg(long, action = ArgAction::SetTrue)]
209        json: bool,
210    },
211
212    /// List durable off-boarding records for an organization
213    OffboardingLog {
214        /// Organization identity ID
215        #[arg(long)]
216        org: String,
217
218        /// Restrict to a single member
219        #[arg(long = "member", visible_alias = "member-did")]
220        member_did: Option<String>,
221
222        /// Emit the records as JSON
223        #[arg(long, action = ArgAction::SetTrue)]
224        json: bool,
225    },
226
227    /// Produce a self-contained, air-gapped provenance bundle for an organization
228    Bundle {
229        /// Organization identity ID
230        #[arg(long)]
231        org: String,
232
233        /// Output path for the bundle file
234        #[arg(long)]
235        out: PathBuf,
236    },
237
238    /// Join an organization using an invite code
239    Join {
240        /// Invite code (e.g. from `auths org join --code C23BD59F`)
241        #[arg(long)]
242        code: String,
243
244        /// Registry URL to contact
245        #[arg(long, env = "AUTHS_REGISTRY_URL")]
246        registry: Option<String>,
247    },
248
249    /// Manage the org-wide authorization policy (anchored on the org KEL)
250    Policy {
251        /// Policy action (set/show)
252        #[command(subcommand)]
253        action: PolicyAction,
254    },
255
256    /// Anchor the org's OIDC-subject policy on its KEL (who may sign keylessly).
257    /// Verifiers resolve it with `auths artifact verify --oidc-policy-did <org-did>`
258    /// — the witnessed log is the policy's source of truth, not a pinned file.
259    AnchorOidcPolicy {
260        /// Organization identity ID
261        #[arg(long)]
262        org: String,
263
264        /// Path to the OIDC-subject policy JSON (issuer + repository, optional workflow_ref)
265        #[arg(long)]
266        file: PathBuf,
267
268        /// Org signing key alias (defaults to the org slug alias)
269        #[arg(long)]
270        key: Option<String>,
271    },
272
273    /// Show fleet governance metrics for an organization
274    Metrics {
275        /// Organization identity ID
276        #[arg(long)]
277        org: String,
278
279        /// Emit the metrics as JSON
280        #[arg(long, action = ArgAction::SetTrue)]
281        json: bool,
282    },
283
284    /// Trace an agent's delegation chain to the authorizing root + live-at-signing
285    Trace {
286        /// A signed commit SHA — traces its signer (`Auths-Device`) at its anchor-seq
287        #[arg(long)]
288        commit: Option<String>,
289
290        /// A member/agent identity ID to trace directly
291        #[arg(long = "member", visible_alias = "member-did")]
292        member: Option<String>,
293
294        /// The in-band signing KEL position (used with `--member`; `--commit` reads it
295        /// from the commit's `Auths-Anchor-Seq` trailer)
296        #[arg(long)]
297        signed_at: Option<u128>,
298
299        /// Emit the chain as JSON
300        #[arg(long, action = ArgAction::SetTrue)]
301        json: bool,
302    },
303}
304
305/// Actions for the org-wide authorization policy.
306#[derive(Subcommand, Debug, Clone)]
307pub enum PolicyAction {
308    /// Anchor a new org-wide policy from a JSON file (a serialized policy `Expr`)
309    Set {
310        /// Organization identity ID
311        #[arg(long)]
312        org: String,
313
314        /// Path to the policy JSON file (a serialized `Expr`)
315        #[arg(long)]
316        file: PathBuf,
317
318        /// Org signing key alias (defaults to the org slug alias)
319        #[arg(long)]
320        key: Option<String>,
321    },
322
323    /// Show the org's currently-anchored policy
324    Show {
325        /// Organization identity ID
326        #[arg(long)]
327        org: String,
328
329        /// Emit the raw policy JSON
330        #[arg(long, action = ArgAction::SetTrue)]
331        json: bool,
332    },
333}
334
335/// single-verifier helper. Resolves the issuer DID,
336/// constructs a typed `DevicePublicKey`, and calls `auths_verifier::verify_with_keys`.
337/// Returns one of: "✅ valid", "🛑 revoked", "⌛ expired", "❌ invalid".
338fn verify_attestation_via_resolver(
339    att: &auths_verifier::Attestation,
340    resolver: &auths_sdk::identity::DefaultDidResolver,
341    anchor_set: Option<&std::collections::HashSet<auths_keri::Said>>,
342) -> String {
343    use auths_sdk::identity::DidResolver;
344    let resolved = match resolver.resolve(att.issuer.as_str()) {
345        Ok(r) => r,
346        Err(_) => return "❌ invalid".to_string(),
347    };
348    let pk_bytes: Vec<u8> = resolved.public_key_bytes().to_vec();
349    let resolved_curve = resolved.curve();
350    let issuer_pk = match auths_verifier::decode_public_key_bytes(&pk_bytes, resolved_curve) {
351        Ok(pk) => pk,
352        Err(_) => return "❌ invalid".to_string(),
353    };
354    #[allow(clippy::expect_used)]
355    // INVARIANT: current-thread runtime creation only fails on resource
356    // exhaustion, which is unrecoverable at the CLI boundary.
357    let rt = tokio::runtime::Builder::new_current_thread()
358        .enable_all()
359        .build()
360        .expect("tokio runtime");
361    let base = match rt.block_on(auths_verifier::verify_with_keys(att, &issuer_pk)) {
362        Ok(_) => "✅ valid",
363        Err(e) if e.to_string().contains("revoked") => "🛑 revoked",
364        Err(e) if e.to_string().contains("expired") => "⌛ expired",
365        Err(_) => "❌ invalid",
366    };
367    let anchor_suffix = match anchor_set {
368        Some(set) => {
369            let anchored =
370                auths_sdk::attestation::canonical_said(att).is_some_and(|said| set.contains(&said));
371            if anchored { "" } else { " (unanchored)" }
372        }
373        None => "",
374    };
375    format!("{base}{anchor_suffix}")
376}
377
378/// Handles `org` commands for issuing or revoking member authorizations.
379pub fn handle_org(
380    cmd: OrgCommand,
381    ctx: &crate::config::CliConfig,
382    now: chrono::DateTime<chrono::Utc>,
383) -> Result<()> {
384    let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
385    let passphrase_provider = ctx.passphrase_provider.clone();
386
387    let mut config = StorageLayoutConfig::default();
388    if let Some(r) = &cmd.overrides.identity_ref {
389        config.identity_ref = r.clone().into();
390    }
391    if let Some(b) = &cmd.overrides.identity_blob {
392        config.identity_blob_name = b.clone().into();
393    }
394    if let Some(p) = &cmd.overrides.attestation_prefix {
395        config.device_attestation_prefix = p.clone().into();
396    }
397    if let Some(b) = &cmd.overrides.attestation_blob {
398        config.attestation_blob_name = b.clone().into();
399    }
400
401    let _attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
402    let resolver: DefaultDidResolver = DefaultDidResolver::with_repo(&repo_path);
403
404    match cmd.subcommand {
405        OrgSubcommand::Create {
406            name,
407            key,
408            metadata_file,
409        } => {
410            let key_alias = key.unwrap_or_else(|| org_slug_alias(&name));
411
412            println!("🏛️  Initializing new organization identity...");
413            println!("   Organization Name: {name}");
414            println!("   Repository path:   {repo_path:?}");
415            println!("   Local Key Alias:   {key_alias}");
416
417            use crate::factories::storage::ensure_git_repo;
418            ensure_git_repo(&repo_path).context("Failed to initialize Git repository")?;
419
420            let extra_metadata = match &metadata_file {
421                Some(mf) if mf.exists() => {
422                    let raw = fs::read_to_string(mf)
423                        .with_context(|| format!("Failed to read metadata file: {mf:?}"))?;
424                    let value: serde_json::Value = serde_json::from_str(&raw)
425                        .with_context(|| format!("Invalid JSON in metadata file: {mf:?}"))?;
426                    println!("   Merged additional metadata from {mf:?}");
427                    Some(value)
428                }
429                _ => None,
430            };
431
432            let sdk_ctx = build_auths_context(
433                &repo_path,
434                &ctx.env_config,
435                Some(passphrase_provider.clone()),
436            )?;
437            let created = create_org(
438                &sdk_ctx,
439                &name,
440                &KeyAlias::new_unchecked(key_alias),
441                auths_crypto::CurveType::default(),
442                extra_metadata,
443            )
444            .with_context(|| format!("Failed to create organization '{name}'"))?;
445
446            println!("\n✅ Organization identity initialized successfully!");
447            println!("   Org Identity ID:    {}", created.org_did);
448            println!("   Org Name:           {name}");
449            println!("   Repo Path:          {repo_path:?}");
450            println!("   Key Alias:          {}", created.key_alias);
451            println!("   Admin Role:         Granted with all capabilities");
452            println!(
453                "   KEL Ref:            '{}'",
454                layout::keri_kel_ref(&Prefix::new_unchecked(created.org_prefix.clone()))
455            );
456            println!("   Identity Ref:       '{}'", config.identity_ref);
457            if let Ok(org_did) = CanonicalDid::parse(&created.org_did) {
458                println!(
459                    "   Member Ref:         '{}'",
460                    config.org_member_ref(&created.org_did, &org_did)
461                );
462            }
463            println!("\n🔑 Store your key passphrase securely.");
464            println!(
465                "   You can now add members with: auths org add-member --org {} --member <identity-id> --role <role>",
466                created.org_did
467            );
468
469            Ok(())
470        }
471
472        OrgSubcommand::Attest {
473            subject_did,  // The subject DID (String)
474            payload_file, // Path to the JSON payload
475            note,         // Optional note (String)
476            expires_at,   // Optional RFC3339 expiration string
477            key,          // Alias of the org's signing key in keychain
478        } => {
479            let signer_alias =
480                key.ok_or_else(|| anyhow!("Signer key alias must be provided with --key"))?;
481            let signer_alias = KeyAlias::new_unchecked(signer_alias);
482
483            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
484            let managed_identity = identity_storage
485                .load_identity()
486                .context("Failed to load org identity from Git repository")?;
487            let controller_did = managed_identity.controller_did;
488            let rid = managed_identity.storage_id;
489
490            let payload_str = fs::read_to_string(&payload_file)
491                .with_context(|| format!("Failed to read payload file {:?}", payload_file))?;
492            let payload: serde_json::Value =
493                serde_json::from_str(&payload_str).context("Invalid JSON in payload file")?;
494
495            let key_storage = get_platform_keychain()?;
496            let (stored_did, _role, _encrypted_key) = key_storage
497                .load_key(&signer_alias)
498                .with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
499
500            if stored_did != controller_did {
501                return Err(anyhow!(
502                    "Signer key alias '{}' belongs to DID '{}', but loaded org identity is '{}'",
503                    signer_alias,
504                    stored_did,
505                    controller_did
506                ));
507            }
508
509            #[allow(clippy::disallowed_methods)]
510            // INVARIANT: subject_did accepts both did:key and did:keri
511            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
512
513            // --- Resolve device public key using the custom resolver IF did:key ---
514            let device_resolved = resolver.resolve(&subject_did).with_context(|| {
515                format!("Failed to resolve public key for subject: {}", subject_did)
516            })?;
517            let device_pk_bytes = device_resolved.public_key_bytes().to_vec();
518            let device_curve = device_resolved.curve();
519
520            let meta = AttestationMetadata {
521                note,
522                timestamp: Some(now),
523                expires_at: expires_at
524                    .as_deref()
525                    .map(DateTime::parse_from_rfc3339)
526                    .transpose()
527                    .map_err(|e| anyhow!("Invalid RFC3339 datetime string: {}", e))?
528                    .map(|dt| dt.with_timezone(&Utc)),
529            };
530
531            let signer = StorageSigner::new(key_storage);
532            let issuer_canonical = CanonicalDid::from(controller_did.clone());
533            let attestation = create_signed_attestation(
534                now,
535                auths_sdk::attestation::AttestationInput {
536                    rid: &rid,
537                    issuer: &issuer_canonical,
538                    subject: &subject_device_did,
539                    device_public_key: &device_pk_bytes,
540                    device_curve,
541                    payload: Some(payload),
542                    meta: &meta,
543                    identity_alias: Some(&signer_alias),
544                    device_alias: None, // No device signature for org attestations
545                    delegated_by: None,
546                    commit_sha: None,
547                    signer_type: None,
548                    oidc_binding: None,
549                },
550                &signer,
551                passphrase_provider.as_ref(),
552            )
553            .context("Failed to create signed attestation object")?;
554
555            {
556                let backend = GitRegistryBackend::from_config_unchecked(
557                    RegistryConfig::single_tenant(&repo_path),
558                );
559                let mut batch = auths_sdk::keri::AtomicWriteBatch::new();
560                batch.stage_attestation(attestation);
561                if let Ok(prefix) = auths_sdk::keri::parse_did_keri(controller_did.as_str()) {
562                    let _ = auths_sdk::keri::try_stage_anchor(
563                        &backend,
564                        &signer,
565                        &signer_alias,
566                        passphrase_provider.as_ref(),
567                        &prefix,
568                        &serde_json::json!({}),
569                        &mut batch,
570                    );
571                }
572                backend
573                    .commit_batch(&batch)
574                    .context("Failed to write attestation")?;
575            }
576
577            println!(
578                "\n✅ Org attestation created successfully from '{}' → '{}'",
579                controller_did, subject_device_did
580            );
581
582            Ok(())
583        }
584
585        OrgSubcommand::Revoke {
586            subject_did,
587            note,
588            key,
589        } => {
590            println!("🛑 Revoking org authorization for subject: {subject_did}");
591            println!("   Using Repository:         {:?}", repo_path);
592            println!("   Using Identity Ref:       '{}'", config.identity_ref);
593            println!(
594                "   Using Attestation Prefix: '{}'",
595                config.device_attestation_prefix
596            );
597
598            let signer_alias =
599                key.ok_or_else(|| anyhow!("Signer key alias must be provided for revocation"))?;
600            let signer_alias = KeyAlias::new_unchecked(signer_alias);
601
602            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
603            let managed_identity = identity_storage
604                .load_identity()
605                .context("Failed to load identity from Git repository")?;
606            let controller_did = managed_identity.controller_did;
607            let rid = managed_identity.storage_id;
608
609            #[allow(clippy::disallowed_methods)] // INVARIANT: accepts both did:key and did:keri
610            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
611
612            // Look up the subject's public key from existing attestations
613            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
614            let existing = attestation_storage
615                .load_attestations_for_device(&subject_device_did)
616                .context("Failed to load attestations for subject")?;
617            let device_public_key = existing
618                .iter()
619                .find(|a| !a.device_public_key.is_zero())
620                .map(|a| a.device_public_key.clone())
621                .unwrap_or_default();
622
623            println!("🔏 Creating signed revocation...");
624            let signer = StorageSigner::new(get_platform_keychain()?);
625            let attestation = create_signed_revocation(
626                auths_sdk::attestation::RevocationInput {
627                    rid: &rid,
628                    identity_did: &controller_did,
629                    subject: &subject_device_did,
630                    device_public_key: device_public_key.as_bytes(),
631                    device_curve: device_public_key.curve(),
632                    note,
633                    payload: None,
634                    timestamp: now,
635                    identity_alias: &signer_alias,
636                },
637                &signer,
638                passphrase_provider.as_ref(),
639            )
640            .context("Failed to create revocation")?;
641
642            println!("💾 Writing revocation to Git...");
643            {
644                let backend = GitRegistryBackend::from_config_unchecked(
645                    RegistryConfig::single_tenant(&repo_path),
646                );
647                let mut batch = auths_sdk::keri::AtomicWriteBatch::new();
648                batch.stage_attestation(attestation);
649                if let Ok(prefix) = auths_sdk::keri::parse_did_keri(controller_did.as_str()) {
650                    let _ = auths_sdk::keri::try_stage_anchor(
651                        &backend,
652                        &signer,
653                        &signer_alias,
654                        passphrase_provider.as_ref(),
655                        &prefix,
656                        &serde_json::json!({}),
657                        &mut batch,
658                    );
659                }
660                backend
661                    .commit_batch(&batch)
662                    .context("Failed to write revocation")?;
663            }
664
665            println!("\n✅ Revoked authorization for subject {subject_did}");
666
667            Ok(())
668        }
669
670        OrgSubcommand::Show {
671            subject_did,
672            include_revoked,
673        } => {
674            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
675            let resolver = DefaultDidResolver::with_repo(&repo_path);
676            let group = AttestationGroup::from_list(
677                attestation_storage
678                    .load_all_enriched()
679                    .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())?,
680            );
681
682            #[allow(clippy::disallowed_methods)]
683            // INVARIANT: subject_did from CLI arg, used for lookup only
684            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
685            if let Some(list) = group.by_device.get(subject_device_did.as_str()) {
686                for (i, att) in list.iter().enumerate() {
687                    if !include_revoked
688                        && (att.is_revoked() || att.expires_at.is_some_and(|e| now > e))
689                    {
690                        continue;
691                    }
692
693                    let status = verify_attestation_via_resolver(att, &resolver, None);
694
695                    println!("{i}. [{}] @ {}", status, att.timestamp.unwrap_or(now));
696                    if let Some(note) = &att.note {
697                        println!("   📝 {}", note);
698                    }
699                    if let Some(payload) = &att.payload {
700                        println!("   📦 {}", serde_json::to_string_pretty(payload)?);
701                    }
702                }
703            } else {
704                println!("No authorizations found for subject: {}", subject_did);
705            }
706
707            Ok(())
708        }
709
710        OrgSubcommand::List { include_revoked } => {
711            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
712            let resolver = DefaultDidResolver::with_repo(&repo_path);
713            let group = AttestationGroup::from_list(
714                attestation_storage
715                    .load_all_enriched()
716                    .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())?,
717            );
718
719            for (subject, list) in group.by_device.iter() {
720                let Some(latest) = list.last() else {
721                    continue;
722                };
723                if !include_revoked
724                    && (latest.is_revoked() || latest.expires_at.is_some_and(|e| now > e))
725                {
726                    continue;
727                }
728
729                let status = verify_attestation_via_resolver(latest, &resolver, None);
730
731                println!("- {} [{}]", subject, status);
732            }
733
734            Ok(())
735        }
736
737        OrgSubcommand::AddMember {
738            org,
739            member_did: member_label,
740            role: cli_role,
741            capabilities,
742            key,
743            note: _note,
744        } => {
745            let role = Role::from(cli_role);
746            println!("👥 Adding member to organization...");
747            println!("   Org:   {}", org);
748            println!("   Label: {}", member_label);
749            println!("   Role:  {}", role);
750
751            let org_prefix =
752                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
753            let member_alias = KeyAlias::new_unchecked(member_label.clone());
754
755            let capability_strings = capabilities.unwrap_or_else(|| role.default_capabilities());
756
757            let sdk_ctx = build_auths_context(
758                &repo_path,
759                &ctx.env_config,
760                Some(passphrase_provider.clone()),
761            )?;
762            let org_alias =
763                resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix.as_str(), key)?;
764            let result = add_member(
765                &sdk_ctx,
766                &org_prefix,
767                &org_alias,
768                &member_alias,
769                auths_crypto::CurveType::Ed25519,
770                role,
771                &capability_strings,
772                None,
773            )
774            .context("Failed to add member")?;
775
776            println!("\n✅ Member added as a KERI delegated identifier!");
777            println!("   Member DID:   {}", result.member_did);
778            println!("   Role:         {}", role);
779            println!(
780                "   Capabilities: {}",
781                capability_strings
782                    .iter()
783                    .map(|c| c.as_str())
784                    .collect::<Vec<_>>()
785                    .join(", ")
786            );
787            println!("\nThe org anchored this member's delegation in its KEL.");
788
789            Ok(())
790        }
791
792        OrgSubcommand::RevokeMember {
793            org,
794            member_did: member,
795            note,
796            key,
797            dry_run,
798        } => {
799            println!("🛑 Revoking member from organization...");
800            println!("   Org:    {}", org);
801            println!("   Member: {}", member);
802
803            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
804            let invoker_did = identity_storage
805                .load_identity()
806                .context("Failed to load identity. Are you running this from an org repository?")?
807                .controller_did;
808
809            if dry_run {
810                return display_dry_run_revoke_member(&org, &member, invoker_did.as_ref());
811            }
812
813            let org_prefix =
814                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
815
816            let sdk_ctx = build_auths_context(
817                &repo_path,
818                &ctx.env_config,
819                Some(passphrase_provider.clone()),
820            )?;
821            let org_alias =
822                resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix.as_str(), key)?;
823            let record = revoke_member(&sdk_ctx, &org_prefix, &org_alias, &member, note)
824                .context("Failed to revoke member")?;
825
826            match record {
827                Some(signed) => {
828                    println!("\n✅ Member revoked (revocation anchored in the org KEL):");
829                    println!("   Member:        {}", member);
830                    println!("   Revoked by:    {}", invoker_did);
831                    println!(
832                        "   Revoked at:    KEL seq {} (authority ends after this position, not by wall-clock)",
833                        signed.record.revoked_at_seq
834                    );
835                    println!("   Seal SAID:     {}", signed.record.revocation_seal_said);
836                    let role = signed.record.prior_role.as_deref().unwrap_or("unknown");
837                    println!("   Lost role:     {role}");
838                    if !signed.record.prior_caps.is_empty() {
839                        println!(
840                            "   Lost caps:     {}",
841                            signed
842                                .record
843                                .prior_caps
844                                .iter()
845                                .map(|c| c.as_str())
846                                .collect::<Vec<_>>()
847                                .join(", ")
848                        );
849                    }
850                    println!("   Off-boarding record stored (signed, retrievable by org/member).");
851                }
852                None => {
853                    println!("\nℹ️  Member already revoked — no change (idempotent).");
854                    println!("   Member: {member}");
855                }
856            }
857
858            Ok(())
859        }
860
861        OrgSubcommand::ListMembers {
862            org,
863            include_revoked,
864        } => {
865            println!("📋 Listing members of organization: {}", org);
866
867            // KEL-authoritative: members are `dip`s the org anchored. Revocation and
868            // role/capabilities are read from the org KEL, never from an attestation.
869            let org_prefix =
870                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
871            let sdk_ctx = build_auths_context(
872                &repo_path,
873                &ctx.env_config,
874                Some(passphrase_provider.clone()),
875            )?;
876            let all_members =
877                list_members(&sdk_ctx, &org_prefix).context("Failed to list members")?;
878            let revoked_count = all_members.iter().filter(|m| m.revoked).count();
879
880            let mut members: Vec<_> = all_members
881                .into_iter()
882                .filter(|m| include_revoked || !m.revoked)
883                .collect();
884
885            if members.is_empty() {
886                println!("\nNo members found for organization.");
887                return Ok(());
888            }
889
890            members.sort_by(|a, b| {
891                member_role_order(&a.role)
892                    .cmp(&member_role_order(&b.role))
893                    .then_with(|| a.member_did.cmp(&b.member_did))
894            });
895
896            println!("\nOrg: {}", org);
897            println!("\nMembers ({} total):", members.len());
898            println!("─────────────────────────────────────────");
899
900            for m in &members {
901                let role_str = m.role.as_ref().map(|r| r.as_str()).unwrap_or("unknown");
902                let status = if m.revoked { " (revoked)" } else { "" };
903                let caps_str = if m.capabilities.is_empty() {
904                    String::new()
905                } else {
906                    format!(
907                        " [{}]",
908                        m.capabilities
909                            .iter()
910                            .map(|c| c.as_str())
911                            .collect::<Vec<_>>()
912                            .join(", ")
913                    )
914                };
915
916                println!("├─ {} [{}]{}{}", m.member_did, role_str, status, caps_str);
917                println!("│     delegated by: {}", m.delegated_by_org);
918            }
919
920            println!("─────────────────────────────────────────");
921
922            if !include_revoked && revoked_count > 0 {
923                println!(
924                    "\n({} revoked member(s) hidden. Use --include-revoked to show.)",
925                    revoked_count
926                );
927            }
928
929            Ok(())
930        }
931
932        OrgSubcommand::Audit {
933            org,
934            member_did,
935            artifact,
936            signed_at,
937            json,
938        } => {
939            let org_prefix =
940                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
941            let member_prefix = Prefix::new_unchecked(
942                member_did
943                    .strip_prefix("did:keri:")
944                    .unwrap_or(&member_did)
945                    .to_string(),
946            );
947            let sdk_ctx = build_auths_context(
948                &repo_path,
949                &ctx.env_config,
950                Some(passphrase_provider.clone()),
951            )?;
952            let verdict =
953                classify_authority_at_signing(&sdk_ctx, &org_prefix, &member_prefix, signed_at)
954                    .context("Failed to classify authority at signing")?;
955
956            if json {
957                println!("{}", serde_json::to_string_pretty(&verdict)?);
958                return Ok(());
959            }
960
961            if let Some(path) = &artifact {
962                println!("Artifact: {path:?}");
963            }
964            println!("Member:   {member_did}");
965            match &verdict {
966                AuthorityAtSigning::AuthorizedBeforeRevocation => println!(
967                    "Verdict:  ✅ AuthorizedBeforeRevocation — authority was live at the signing position"
968                ),
969                AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => println!(
970                    "Verdict:  🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }} — signed at/after the revocation KEL position"
971                ),
972                AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => println!(
973                    "Verdict:  🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }} — revoked; artifact carries no in-band signing position"
974                ),
975                AuthorityAtSigning::NeverDelegated => {
976                    println!("Verdict:  ❌ NeverDelegated — the org never delegated this member")
977                }
978            }
979            Ok(())
980        }
981
982        OrgSubcommand::OffboardingLog {
983            org,
984            member_did,
985            json,
986        } => {
987            let org_prefix =
988                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
989            let sdk_ctx = build_auths_context(
990                &repo_path,
991                &ctx.env_config,
992                Some(passphrase_provider.clone()),
993            )?;
994
995            let records = match &member_did {
996                Some(m) => {
997                    let member_prefix =
998                        Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string());
999                    load_offboarding_record(&sdk_ctx, &org_prefix, &member_prefix)
1000                        .context("Failed to load off-boarding record")?
1001                        .into_iter()
1002                        .collect::<Vec<_>>()
1003                }
1004                None => list_offboarding_records(&sdk_ctx, &org_prefix)
1005                    .context("Failed to list off-boarding records")?,
1006            };
1007
1008            if json {
1009                println!("{}", serde_json::to_string_pretty(&records)?);
1010                return Ok(());
1011            }
1012
1013            if records.is_empty() {
1014                println!("No off-boarding records for organization {org}.");
1015                return Ok(());
1016            }
1017
1018            println!("Off-boarding records for {org} ({} total):", records.len());
1019            for r in &records {
1020                println!("─────────────────────────────────────────");
1021                println!("  Member:     {}", r.record.member_did);
1022                println!(
1023                    "  Revoked at: KEL seq {} (by position, not wall-clock)",
1024                    r.record.revoked_at_seq
1025                );
1026                println!("  Seal SAID:  {}", r.record.revocation_seal_said);
1027                if let Some(reason) = &r.record.reason {
1028                    println!("  Reason:     {reason}");
1029                }
1030                let role = r.record.prior_role.as_deref().unwrap_or("unknown");
1031                println!("  Lost role:  {role}");
1032                if !r.record.prior_caps.is_empty() {
1033                    println!(
1034                        "  Lost caps:  {}",
1035                        r.record
1036                            .prior_caps
1037                            .iter()
1038                            .map(|c| c.as_str())
1039                            .collect::<Vec<_>>()
1040                            .join(", ")
1041                    );
1042                }
1043                println!("  Recorded:   {}", r.record.recorded_at);
1044            }
1045            Ok(())
1046        }
1047
1048        OrgSubcommand::Bundle { org, out } => {
1049            let org_prefix =
1050                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1051            let sdk_ctx = build_auths_context(
1052                &repo_path,
1053                &ctx.env_config,
1054                Some(passphrase_provider.clone()),
1055            )?;
1056            let bundle =
1057                build_org_bundle(&sdk_ctx, &org_prefix).context("Failed to build org bundle")?;
1058            let json = bundle
1059                .to_canonical_json()
1060                .context("Failed to canonicalize org bundle")?;
1061            fs::write(&out, json).with_context(|| format!("Failed to write bundle to {out:?}"))?;
1062
1063            println!("✅ Air-gapped org bundle written to {out:?}");
1064            println!("   Org:            {}", bundle.org_did.as_str());
1065            println!("   Built as-of:    KEL seq {}", bundle.built_at_org_seq);
1066            println!("   Member KELs:    {}", bundle.member_kels.len());
1067            println!("   Off-boardings:  {}", bundle.offboarding_records.len());
1068            println!("   Pinned roots:   {}", bundle.pinned_roots.len());
1069            println!(
1070                "   Verifies offline (no network): auths artifact verify {out:?} --offline --roots .auths/roots"
1071            );
1072            Ok(())
1073        }
1074
1075        OrgSubcommand::Join { code, registry } => {
1076            let registry = crate::commands::verify_helpers::require_registry(registry)?;
1077            handle_join(&code, &registry, passphrase_provider.as_ref())
1078        }
1079
1080        OrgSubcommand::Policy { action } => match action {
1081            PolicyAction::Set { org, file, key } => {
1082                let org_prefix = Prefix::new_unchecked(
1083                    org.strip_prefix("did:keri:").unwrap_or(&org).to_string(),
1084                );
1085                let policy_json = fs::read(&file)
1086                    .with_context(|| format!("Failed to read policy file {file:?}"))?;
1087
1088                let sdk_ctx = build_auths_context(
1089                    &repo_path,
1090                    &ctx.env_config,
1091                    Some(passphrase_provider.clone()),
1092                )?;
1093                let org_alias = resolve_org_signing_alias(
1094                    sdk_ctx.key_storage.as_ref(),
1095                    org_prefix.as_str(),
1096                    key,
1097                )?;
1098                let result = set_org_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json)
1099                    .context("Failed to set org policy")?;
1100
1101                println!("✅ Org policy anchored on the KEL:");
1102                println!("   Org:         {}", result.org_did);
1103                println!("   Policy hash: {}", result.policy_hash);
1104                println!("   Requires:\n{}", result.description);
1105                Ok(())
1106            }
1107
1108            PolicyAction::Show { org, json } => {
1109                let org_prefix = Prefix::new_unchecked(
1110                    org.strip_prefix("did:keri:").unwrap_or(&org).to_string(),
1111                );
1112                let sdk_ctx = build_auths_context(
1113                    &repo_path,
1114                    &ctx.env_config,
1115                    Some(passphrase_provider.clone()),
1116                )?;
1117
1118                match load_org_policy(&sdk_ctx, &org_prefix).context("Failed to load org policy")? {
1119                    Some(policy) => {
1120                        if json {
1121                            println!("{}", policy.source_json);
1122                        } else {
1123                            println!("Org:         did:keri:{}", org_prefix.as_str());
1124                            println!("Policy hash: {}", policy.policy_hash);
1125                            println!("Requires:\n{}", policy.compiled.describe());
1126                        }
1127                    }
1128                    None => println!("No policy anchored for organization {org}."),
1129                }
1130                Ok(())
1131            }
1132        },
1133
1134        OrgSubcommand::AnchorOidcPolicy { org, file, key } => {
1135            let org_prefix =
1136                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1137            let policy_json = fs::read(&file)
1138                .with_context(|| format!("Failed to read OIDC policy file {file:?}"))?;
1139
1140            let sdk_ctx = build_auths_context(
1141                &repo_path,
1142                &ctx.env_config,
1143                Some(passphrase_provider.clone()),
1144            )?;
1145            let org_alias =
1146                resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix.as_str(), key)?;
1147            let result = set_org_oidc_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json)
1148                .context("Failed to anchor OIDC-subject policy")?;
1149
1150            println!("✅ OIDC-subject policy anchored on the org KEL:");
1151            println!("   Org:           {}", result.org_did);
1152            println!("   Policy digest: {}", result.policy_digest);
1153            println!(
1154                "   Trusts:        {} via issuer {}",
1155                result.policy.repository(),
1156                result.policy.issuer()
1157            );
1158            println!(
1159                "\nVerifiers resolve it from the witnessed log:\n   auths artifact verify <artifact> --oidc-policy-did {}",
1160                result.org_did
1161            );
1162            Ok(())
1163        }
1164
1165        OrgSubcommand::Metrics { org, json } => {
1166            let org_prefix =
1167                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1168            let sdk_ctx = build_auths_context(
1169                &repo_path,
1170                &ctx.env_config,
1171                Some(passphrase_provider.clone()),
1172            )?;
1173            let m =
1174                fleet_metrics(&sdk_ctx, &org_prefix).context("Failed to compute fleet metrics")?;
1175            if json {
1176                println!("{}", serde_json::to_string_pretty(&m)?);
1177                return Ok(());
1178            }
1179            println!("Fleet metrics for {}", m.org_did);
1180            println!("  Agents (total):           {}", m.agents_total);
1181            println!("  Agents (live):            {}", m.agents_live);
1182            println!("  Agents (revoked):         {}", m.agents_revoked);
1183            println!(
1184                "  Traceable to a human:     {}/{} ({:.0}%)",
1185                m.agents_traceable_to_human,
1186                m.agents_live,
1187                m.traceability_fraction * 100.0
1188            );
1189            println!(
1190                "  Revocation-to-effect:     {} KEL positions (positional — effective at the anchor)",
1191                m.revocation_effect_latency_positions
1192            );
1193            Ok(())
1194        }
1195
1196        OrgSubcommand::Trace {
1197            commit,
1198            member,
1199            signed_at,
1200            json,
1201        } => {
1202            let sdk_ctx = build_auths_context(
1203                &repo_path,
1204                &ctx.env_config,
1205                Some(passphrase_provider.clone()),
1206            )?;
1207
1208            let (leaf_prefix, position) = if let Some(sha) = commit {
1209                let raw = read_commit_object(&sha)?;
1210                let (_root, device) = commit_signer_trailers(&raw).ok_or_else(|| {
1211                    anyhow!("commit {sha} carries no Auths-Id/Auths-Device trailer")
1212                })?;
1213                let pos = parse_anchor_seq_trailer(&raw);
1214                let prefix = Prefix::new_unchecked(
1215                    device
1216                        .strip_prefix("did:keri:")
1217                        .unwrap_or(&device)
1218                        .to_string(),
1219                );
1220                (prefix, pos)
1221            } else if let Some(m) = member {
1222                let prefix =
1223                    Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(&m).to_string());
1224                (prefix, signed_at)
1225            } else {
1226                return Err(anyhow!("provide --commit <sha> or --member <did> to trace"));
1227            };
1228
1229            let chain = walk_delegation_chain(&sdk_ctx, &leaf_prefix, position)
1230                .context("Failed to walk the delegation chain")?;
1231
1232            if json {
1233                println!("{}", serde_json::to_string_pretty(&chain)?);
1234                return Ok(());
1235            }
1236
1237            println!("Trace: {}", chain.leaf_did);
1238            match position {
1239                Some(p) => println!("  Signed at:       KEL position {p}"),
1240                None => println!(
1241                    "  Signed at:       (no in-band position — upstream revocations fail closed)"
1242                ),
1243            }
1244            println!("  Root:            {}", chain.root_did);
1245            println!("  Depth:           {} hop(s)", chain.depth);
1246            for hop in &chain.hops {
1247                let role = hop.role.as_deref().unwrap_or("-");
1248                let caps = if hop.capabilities.is_empty() {
1249                    String::new()
1250                } else {
1251                    format!(
1252                        " caps=[{}]",
1253                        hop.capabilities
1254                            .iter()
1255                            .map(|c| c.as_str())
1256                            .collect::<Vec<_>>()
1257                            .join(", ")
1258                    )
1259                };
1260                println!(
1261                    "    {} ← {} [{}]{}  authority={:?}",
1262                    hop.child_did, hop.delegator_did, role, caps, hop.authority_at_signing
1263                );
1264            }
1265            if chain.live_at_signing {
1266                println!("  Live at signing: ✅ yes");
1267            } else {
1268                println!(
1269                    "  Live at signing: 🛑 no (a chain authority was revoked at/by the signing position)"
1270                );
1271            }
1272            Ok(())
1273        }
1274    }
1275}
1276
1277/// Read a raw git commit object (`git cat-file commit <sha>`).
1278fn read_commit_object(sha: &str) -> Result<String> {
1279    let out = std::process::Command::new("git")
1280        .args(["cat-file", "commit", sha])
1281        .output()
1282        .context("failed to run git cat-file")?;
1283    if !out.status.success() {
1284        return Err(anyhow!(
1285            "git cat-file commit {sha} failed: {}",
1286            String::from_utf8_lossy(&out.stderr).trim()
1287        ));
1288    }
1289    String::from_utf8(out.stdout).context("commit object is not valid UTF-8")
1290}
1291
1292/// Parse the `Auths-Anchor-Seq` trailer value from a raw commit, if present.
1293fn parse_anchor_seq_trailer(raw: &str) -> Option<u128> {
1294    raw.lines()
1295        .rev()
1296        .find_map(|l| l.strip_prefix("Auths-Anchor-Seq:"))
1297        .and_then(|v| v.trim().parse().ok())
1298}
1299
1300/// Handles the `org join` subcommand by looking up and accepting an invite
1301/// via the registry HTTP API.
1302///
1303/// Args:
1304/// * `code`: Invite code to redeem.
1305/// * `registry`: Base URL of the registry HTTP API.
1306/// * `passphrase_provider`: Injected provider used to unlock the signing key
1307///   when producing the bearer token; respects SE-backed and P-256 keys.
1308///
1309/// Usage:
1310/// ```ignore
1311/// handle_join(&code, &registry, ctx.passphrase_provider.as_ref())?;
1312/// ```
1313fn handle_join(
1314    code: &str,
1315    registry: &str,
1316    passphrase_provider: &dyn auths_sdk::signing::PassphraseProvider,
1317) -> Result<()> {
1318    let rt = tokio::runtime::Runtime::new()?;
1319    let client = reqwest::Client::new();
1320    let base = registry.trim_end_matches('/');
1321
1322    // 1. Look up invite details.
1323    let details_url = format!("{}/v1/invites/{}", base, code);
1324    let details_resp = rt
1325        .block_on(async { client.get(&details_url).send().await })
1326        .context("failed to contact registry")?;
1327
1328    if details_resp.status() == reqwest::StatusCode::NOT_FOUND {
1329        anyhow::bail!(
1330            "Invite code '{}' not found. Check the code and try again.",
1331            code
1332        );
1333    }
1334    if !details_resp.status().is_success() {
1335        let status = details_resp.status();
1336        let body = rt.block_on(details_resp.text()).unwrap_or_default();
1337        anyhow::bail!("Failed to look up invite ({}): {}", status, body);
1338    }
1339
1340    let details: serde_json::Value = rt
1341        .block_on(details_resp.json())
1342        .context("invalid response from registry")?;
1343
1344    let org_name = details["display_name"].as_str().unwrap_or("Unknown");
1345    let role = details["role"].as_str().unwrap_or("member");
1346    let status = details["status"].as_str().unwrap_or("unknown");
1347
1348    if status == "expired" {
1349        anyhow::bail!("This invite has expired. Ask the org admin for a new one.");
1350    }
1351    if status == "accepted" {
1352        anyhow::bail!("This invite has already been accepted.");
1353    }
1354
1355    println!("Organization: {}", org_name);
1356    println!("Role:         {}", role);
1357    println!("Status:       {}", status);
1358    println!();
1359
1360    // 2. Accept the invite. This requires auth — build a signed bearer token.
1361    let repo_path = layout::resolve_repo_path(None)?;
1362    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
1363    let managed_identity = identity_storage
1364        .load_identity()
1365        .context("no local identity found — run `auths init` first")?;
1366    let did = managed_identity.controller_did.to_string();
1367
1368    let key_storage = get_platform_keychain()?;
1369    let primary_alias = KeyAlias::new_unchecked("main");
1370
1371    #[allow(clippy::disallowed_methods)] // CLI is the presentation boundary
1372    let timestamp = Utc::now().to_rfc3339();
1373    let message = format!("{}\n{}", did, timestamp);
1374
1375    let (sig_bytes, _pubkey, _curve) = auths_sdk::keychain::sign_with_key(
1376        key_storage.as_ref(),
1377        &primary_alias,
1378        passphrase_provider,
1379        message.as_bytes(),
1380    )
1381    .context("failed to sign invite bearer token")?;
1382
1383    use base64::Engine;
1384    let signature = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
1385
1386    let bearer_payload = serde_json::json!({
1387        "did": did,
1388        "timestamp": timestamp,
1389        "signature": signature,
1390    });
1391    let bearer_token = serde_json::to_string(&bearer_payload)?;
1392
1393    let accept_url = format!("{}/v1/invites/{}/accept", base, code);
1394    let accept_resp = rt
1395        .block_on(async {
1396            client
1397                .post(&accept_url)
1398                .header("Authorization", format!("Bearer {}", bearer_token))
1399                .header("Content-Type", "application/json")
1400                .send()
1401                .await
1402        })
1403        .context("failed to contact registry")?;
1404
1405    if !accept_resp.status().is_success() {
1406        let status = accept_resp.status();
1407        let body = rt.block_on(accept_resp.text()).unwrap_or_default();
1408        anyhow::bail!("Failed to accept invite ({}): {}", status, body);
1409    }
1410
1411    println!("✅ Successfully joined {} as {}", org_name, role);
1412    println!("   Your DID: {}", did);
1413
1414    Ok(())
1415}
1416
1417fn display_dry_run_revoke_member(org: &str, member: &str, invoker_did: &str) -> Result<()> {
1418    use crate::ux::format::{JsonResponse, is_json_mode};
1419
1420    if is_json_mode() {
1421        JsonResponse::success(
1422            "org revoke-member",
1423            &serde_json::json!({
1424                "dry_run": true,
1425                "org": org,
1426                "member_did": member,
1427                "invoker_did": invoker_did,
1428                "actions": [
1429                    "Create signed revocation for member",
1430                    "Store revocation in Git repository",
1431                    "Member will lose all org capabilities"
1432                ]
1433            }),
1434        )
1435        .print()
1436        .map_err(anyhow::Error::from)
1437    } else {
1438        let out = crate::ux::format::Output::new();
1439        out.print_info("Dry run mode — no changes will be made");
1440        out.newline();
1441        out.println(&format!("   Org:    {}", org));
1442        out.println(&format!("   Member: {}", member));
1443        out.newline();
1444        out.println("Would perform the following actions:");
1445        out.println(&format!(
1446            "  1. Create signed revocation for member {}",
1447            member
1448        ));
1449        out.println("  2. Store revocation in Git repository");
1450        out.println("  3. Member will lose all org capabilities");
1451        Ok(())
1452    }
1453}
1454
1455use crate::commands::executable::ExecutableCommand;
1456use crate::config::CliConfig;
1457
1458impl ExecutableCommand for OrgCommand {
1459    #[allow(clippy::disallowed_methods)]
1460    fn execute(&self, ctx: &CliConfig) -> Result<()> {
1461        handle_org(self.clone(), ctx, Utc::now())
1462    }
1463}