vta-service 0.10.0

Service for Verifiable Trust Agents operating in Verifiable Trust Communities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
//! `POST /bootstrap/request` — TEE first-boot sealed-bootstrap endpoint.
//!
//! This endpoint only handles **Mode B**: a fresh TEE VTA that has no admin
//! yet. The server generates an attestation quote committing to the client
//! pubkey, nonce, and its own ephemeral producer pubkey, mints an Admin
//! credential, and closes the first-boot carve-out permanently. The bundle's
//! assertion is `Attested(quote)` so the consumer can verify end-to-end
//! without any prior shared secret.
//!
//! The former Mode A (token-gated online bootstrap for non-TEE VTAs) was
//! removed: non-TEE clients now use `pnm setup`'s unified temp-did:key
//! flow (client mints locally, admin grants via `vta acl create`, PNM
//! rotates on first authenticated connect).

use axum::Json;
use axum::extract::State;
use axum::response::IntoResponse;
#[cfg(feature = "tee")]
use base64::Engine;
#[cfg(feature = "tee")]
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use serde::{Deserialize, Serialize};

#[cfg(feature = "tee")]
use sha2::{Digest, Sha256};
#[cfg(feature = "tee")]
use tracing::info;
#[cfg(feature = "tee")]
use vta_sdk::credentials::CredentialBundle;
#[cfg(feature = "tee")]
use vta_sdk::sealed_transfer::{
    AssertionProof, AttestationQuoteAssertion, ProducerAssertion, SealedPayloadV1, armor,
    bundle_digest, generate_ed25519_keypair, seal_payload,
};

#[cfg(feature = "tee")]
use crate::acl::delete_acl_entry;
#[cfg(feature = "tee")]
use crate::acl::store_acl_entry;
#[cfg(feature = "tee")]
use crate::acl::{AclEntry, Role};
#[cfg(feature = "tee")]
use crate::audit::audit;
use crate::auth::session::now_epoch;
use crate::error::AppError;
#[cfg(feature = "tee")]
use crate::sealed_nonce_store::PersistentNonceStore;
use crate::server::AppState;

/// Maximum length (in bytes) of the operator-supplied bootstrap label.
/// The wire body is already capped globally, but the label is the only
/// free-form attacker-controlled string in this DTO and ends up in audit
/// logs — keep it short so an aggressive logger can't spill MBs.
const MAX_LABEL_LEN: usize = 256;

/// Request body. `#[serde(deny_unknown_fields)]` so a client cannot smuggle
/// in `requested_role` / `allowed_contexts` — minting parameters are
/// determined entirely by attestation policy.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(utoipa::ToSchema)]
pub struct BootstrapRequestBody {
    /// Wire-format version. Currently 1.
    pub version: u8,
    /// Consumer's ephemeral `did:key` (Ed25519). The server derives the
    /// X25519 pubkey from this for the HPKE seal.
    pub client_did: String,
    /// Random 16-byte nonce, base64url-no-pad. Becomes the bundle_id.
    pub nonce: String,
    /// Optional human-readable label (operator-visible only). Echoed into
    /// server-side audit logs. Wire field stays present on non-TEE builds so
    /// older clients keep deserializing; the value is only consumed by the
    /// TEE first-boot path. Bounded length protects audit log size from a
    /// hostile caller submitting an MB-scale string.
    #[serde(default, deserialize_with = "deserialize_bounded_label")]
    #[cfg_attr(not(feature = "tee"), allow(dead_code))]
    pub label: Option<String>,
}

fn deserialize_bounded_label<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s: Option<String> = Option::deserialize(de)?;
    if let Some(label) = &s
        && label.len() > MAX_LABEL_LEN
    {
        return Err(serde::de::Error::custom(format!(
            "label exceeds {MAX_LABEL_LEN} bytes"
        )));
    }
    Ok(s)
}

/// Response body — a single armored sealed bundle as UTF-8 text, plus the
/// canonical SHA-256 digest so clients can optionally anchor on it.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BootstrapResponseBody {
    pub bundle: String,
    pub digest: String,
}

/// `POST /bootstrap/request`
#[utoipa::path(
    post, path = "/bootstrap/request", tag = "bootstrap",
    request_body = BootstrapRequestBody,
    responses(
        (status = 200, description = "Armored sealed admin bundle + digest", body = BootstrapResponseBody),
        (status = 400, description = "Unsupported version or malformed client_did/nonce"),
        (status = 403, description = "Carve-out already used or TEE first-boot unavailable"),
    ),
)]
pub async fn request(
    State(state): State<AppState>,
    Json(req): Json<BootstrapRequestBody>,
) -> Result<Json<BootstrapResponseBody>, AppError> {
    if req.version != 1 {
        return Err(AppError::Validation(format!(
            "unsupported bootstrap request version: {}",
            req.version
        )));
    }

    let client_ed25519_pub = decode_client_did(&req.client_did)?;
    let bundle_id = decode_nonce(&req.nonce)?;
    let now = now_epoch();

    #[cfg(feature = "tee")]
    let bundle = mint_mode_b(&state, &client_ed25519_pub, bundle_id, now).await?;

    #[cfg(not(feature = "tee"))]
    {
        let _ = (state, client_ed25519_pub, bundle_id, now);
        Err(AppError::Forbidden(
            "bootstrap request requires TEE first-boot attestation, which is not available on \
             this VTA build. Non-TEE VTAs use the `pnm setup` temp-did:key + ACL flow instead."
                .into(),
        ))
    }

    #[cfg(feature = "tee")]
    {
        let digest = bundle_digest(&bundle);
        let armored = armor::encode(&bundle);

        // Log a SHA-256 prefix of the operator-supplied label rather than
        // the raw string. Labels are free-form and often carry PII
        // ("glenn's iphone", "alice@example.com") — a hash preserves
        // "same label → same identifier" for operational correlation
        // without leaking the plaintext into log aggregators.
        info!(
            client_label_hash = %label_hash_prefix(req.label.as_deref()),
            "TEE first-boot bootstrap completed"
        );
        audit!(
            "bootstrap.swap",
            actor = "bootstrap-endpoint",
            resource = "bootstrap",
            outcome = "success"
        );
        let _ = crate::audit::record(
            &state.audit_ks,
            "bootstrap.swap",
            "bootstrap-endpoint",
            None,
            "success",
            Some("rest"),
            None,
        )
        .await;

        Ok(Json(BootstrapResponseBody {
            bundle: armored,
            digest,
        }))
    }
}

/// Process-wide lock that serializes the carve-out check-and-set. The
/// keyspace exposes no compare-and-swap, so two concurrent requests can
/// each pass the `is_some()` check, mint admins, and write distinct ACL
/// rows before either writes the closed-sentinel. A `tokio::sync::Mutex`
/// held across the whole sequence collapses that window — the second
/// request waits, then sees the sentinel and is refused. The mint flow
/// is single-use and rare, so contention is irrelevant.
#[cfg(feature = "tee")]
static MODE_B_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

/// Mode B: TEE first-boot sealed bootstrap. No token; the attestation quote
/// is the sole authorization anchor.
///
/// On success, closes the first-boot carve-out permanently by writing the
/// `BOOTSTRAP_CARVEOUT_CLOSED_KEY` sentinel. Any subsequent request is
/// rejected.
#[cfg(feature = "tee")]
async fn mint_mode_b(
    state: &AppState,
    client_ed25519_pub: &[u8; 32],
    bundle_id: [u8; 16],
    now: u64,
) -> Result<vta_sdk::sealed_transfer::SealedBundle, AppError> {
    use crate::tee::admin_bootstrap::{BOOTSTRAP_CARVEOUT_CLOSED_KEY, LEGACY_ADMIN_CREDENTIAL_KEY};

    let tee_state =
        state.tee.as_ref().map(|tc| &tc.state).ok_or_else(|| {
            AppError::Forbidden("TEE first-boot is not available on this VTA".into())
        })?;

    // Serialize the carve-out check-and-set across all concurrent requests.
    // The lock is released when this function returns, by which point the
    // sentinel has been written (success path) or nothing has been written
    // (early-error path), so subsequent requests see a consistent view.
    let _carve_out_guard = MODE_B_LOCK.lock().await;

    // Carve-out active ⇔ neither the closed-sentinel nor the legacy
    // admin-credential row is present. (The latter is a transitional case —
    // startup migration rewrites it into the closed-sentinel before this
    // handler ever runs, but we check here too to keep the handler correct
    // even without startup migration.)
    if state
        .keys_ks
        .get_raw(BOOTSTRAP_CARVEOUT_CLOSED_KEY)
        .await?
        .is_some()
        || state
            .keys_ks
            .get_raw(LEGACY_ADMIN_CREDENTIAL_KEY)
            .await?
            .is_some()
    {
        return Err(AppError::Forbidden(
            "TEE first-boot carve-out has already been used".into(),
        ));
    }

    let cfg = state.config.read().await;
    let vta_did = cfg
        .vta_did
        .as_ref()
        .ok_or_else(|| AppError::Internal("VTA DID not configured".into()))?
        .clone();
    let vta_url = cfg.public_url.clone();
    drop(cfg);

    // Per-request ephemeral producer Ed25519 keypair. The did:key bytes are
    // bound into the attestation `user_data` alongside the client's did:key
    // bytes + nonce so the consumer can recompute against DID-visible data.
    let (_producer_seed, producer_ed_pub) = generate_ed25519_keypair();
    let producer_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&producer_ed_pub);

    // Attestation user_data binds DID-layer bytes end-to-end:
    //   SHA256(client_ed25519 || bundle_id || producer_ed25519)
    // Both halves match what the consumer sees via the `client_did` it sent
    // and the `producer_did` in the returned `ProducerAssertion`.
    let mut hasher = Sha256::new();
    hasher.update(client_ed25519_pub);
    hasher.update(bundle_id);
    hasher.update(producer_ed_pub);
    let user_data = hasher.finalize();

    // Attestation nonce: reuse the client nonce for freshness.
    let report = tee_state
        .provider
        .attest(user_data.as_slice(), &bundle_id)
        .map_err(|e| AppError::Internal(format!("tee attest failed: {e}")))?;

    let (did, private_key_multibase) = crate::auth::credentials::generate_did_key();

    let credential = CredentialBundle {
        did: did.clone(),
        private_key_multibase,
        vta_did,
        vta_url,
    };

    let assertion = ProducerAssertion {
        producer_did,
        proof: AssertionProof::Attested(AttestationQuoteAssertion {
            format: format!("{}", report.tee_type),
            quote_b64: report.evidence,
        }),
    };

    // HPKE targets the consumer's derived X25519 pubkey; the DID layer is
    // invisible to the cipher. Any decoding error here should be impossible
    // (the handler already validated `client_did`), so surface as 500.
    let client_x25519_pub =
        affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(client_ed25519_pub)
            .map_err(|e| AppError::Internal(format!("client_did X25519 derivation: {e}")))?;

    // Seal FIRST, before touching any carve-out state. A seal failure
    // here must leave the carve-out open and retryable — no ACL, no
    // sentinel written.
    let nonce_store = PersistentNonceStore::new(state.sealed_nonces_ks.clone());
    let payload = SealedPayloadV1::AdminCredential(Box::new(credential));
    let bundle = seal_payload(
        &client_x25519_pub,
        bundle_id,
        assertion,
        &payload,
        &nonce_store,
    )
    .await
    .map_err(|e| AppError::Internal(format!("sealed-transfer seal failed: {e}")))?;

    // Now commit the carve-out. Ordering and durability are load-bearing
    // (P0.8):
    //
    //   1. ACL entry is written to the journal BEFORE the sentinel, so a
    //      torn fsync recovers to {ACL, no sentinel} — a safe, self-
    //      healing reopen (no bundle was delivered) — rather than
    //      {sentinel, no ACL}, which would brick the VTA (carve-out
    //      closed, no admin).
    //   2. The sentinel is claimed with `insert_raw_if_absent`: even if a
    //      future refactor breaks the `MODE_B_LOCK` guard above, a
    //      concurrent request's claim returns `false` and fails closed,
    //      so exactly one admin is ever minted. (Defence-in-depth — the
    //      lock already serializes; this no longer relies on it.)
    //   3. `persist()` fsyncs the ACL + sentinel + replay nonce together
    //      BEFORE the bundle is returned. This is the security barrier:
    //      the admin credential never leaves the enclave until the
    //      carve-out is durably closed, so a power loss after delivery
    //      cannot reopen it and mint a second admin.
    let entry = AclEntry::new(did.clone(), Role::Admin, "tee:mode-b")
        .with_label(Some("TEE first-boot admin".to_string()))
        .with_created_at(now);
    store_acl_entry(&state.acl_ks, &entry).await?;

    if !state
        .keys_ks
        .insert_raw_if_absent(BOOTSTRAP_CARVEOUT_CLOSED_KEY, did.as_bytes().to_vec())
        .await?
    {
        // Lost the carve-out race (only reachable if MODE_B_LOCK were
        // bypassed). Roll back the ACL we just wrote so it does not
        // linger as an admin entry for an undeliverable DID, and refuse.
        let _ = delete_acl_entry(&state.acl_ks, &did).await;
        return Err(AppError::Forbidden(
            "TEE first-boot carve-out has already been used".into(),
        ));
    }

    // Durability barrier: do not return the bundle until the carve-out
    // close is on disk.
    state.keys_ks.persist().await?;

    // Re-seal the TEE integrity manifest now that the carve-out is closed, so a
    // subsequent boot detects any parent attempt to reopen it (P0.2a). The
    // carve-out sentinel write above is a direct `insert_raw_if_absent`, not an
    // ACL/counter chokepoint, so it needs its own reseal. No-op outside a TEE.
    vti_common::integrity::reseal_if_active().await?;

    info!("TEE first-boot carve-out consumed — closed for good");
    Ok(bundle)
}

/// Decode the consumer's `did:key` (Ed25519) to its raw 32-byte pubkey.
/// X25519 derivation happens inside `mint_mode_b` where HPKE is actually
/// invoked; the Ed25519 pubkey is separately bound into the attestation
/// `user_data` so the consumer can verify against did:key-visible bytes.
fn decode_client_did(did: &str) -> Result<[u8; 32], AppError> {
    affinidi_crypto::did_key::did_key_to_ed25519_pub(did)
        .map_err(|e| AppError::Validation(format!("invalid client_did: {e}")))
}

/// SHA-256 the operator-supplied label, return the first 16 hex chars
/// (64 bits — enough to tell distinct labels apart in practice). When
/// no label is supplied, return `"none"` so the log field is always
/// populated.
///
/// The point is to keep free-form strings (potentially carrying PII
/// like "alice@example.com" or a device name) out of the log
/// aggregator while preserving "same label → same hash" for operator
/// correlation across requests.
#[cfg(feature = "tee")]
fn label_hash_prefix(label: Option<&str>) -> String {
    match label {
        Some(s) => {
            let digest = Sha256::digest(s.as_bytes());
            let mut hex = String::with_capacity(16);
            for b in &digest[..8] {
                hex.push_str(&format!("{b:02x}"));
            }
            hex
        }
        None => "none".to_string(),
    }
}

#[cfg(feature = "tee")]
fn decode_nonce(s: &str) -> Result<[u8; 16], AppError> {
    let raw = B64URL
        .decode(s)
        .map_err(|e| AppError::Validation(format!("invalid nonce base64: {e}")))?;
    raw.try_into()
        .map_err(|_| AppError::Validation("nonce must be 16 bytes".into()))
}

#[cfg(not(feature = "tee"))]
fn decode_nonce(_s: &str) -> Result<[u8; 16], AppError> {
    // Non-TEE builds never reach the seal path; the handler returns Forbidden
    // before calling this. Keep a stub with the right signature so the
    // top-level handler compiles without ballooning the conditional code.
    Err(AppError::Forbidden(
        "bootstrap request requires TEE first-boot attestation".into(),
    ))
}

impl IntoResponse for BootstrapResponseBody {
    fn into_response(self) -> axum::response::Response {
        Json(self).into_response()
    }
}

#[cfg(all(test, feature = "tee"))]
mod tests {
    use super::*;

    #[test]
    fn label_hash_prefix_is_stable_and_truncated() {
        // Same label → same hash. Different labels → different hashes
        // (collision at 16 hex chars is 1-in-2^64, not worth guarding).
        // None → "none" so the log field is never empty.
        let a1 = label_hash_prefix(Some("alice@example.com"));
        let a2 = label_hash_prefix(Some("alice@example.com"));
        let b = label_hash_prefix(Some("bob@example.com"));
        assert_eq!(a1, a2, "same label produces same hash");
        assert_ne!(a1, b, "different labels produce different hashes");
        assert_eq!(a1.len(), 16, "prefix is 16 hex chars (64 bits)");
        assert!(
            a1.chars().all(|c| c.is_ascii_hexdigit()),
            "hash prefix is hex"
        );
        assert_eq!(label_hash_prefix(None), "none");
    }

    #[test]
    fn label_hash_prefix_does_not_leak_raw_label() {
        // Defence-in-depth: the returned string should not contain any
        // substring of the original label. A regression here would mean
        // the hash helper got replaced with an echo / truncation by
        // someone not thinking about PII.
        let raw = "glenn's iphone";
        let hashed = label_hash_prefix(Some(raw));
        for substr_len in 3..=raw.len() {
            for start in 0..=(raw.len() - substr_len) {
                let needle = &raw[start..start + substr_len];
                assert!(
                    !hashed.contains(needle),
                    "hashed output '{hashed}' must not contain raw substring '{needle}'"
                );
            }
        }
    }

    /// Concurrency contract for the carve-out close (P0.8).
    ///
    /// Property under test: when N concurrent `mint_mode_b`-style
    /// sequences race against the same keyspace, exactly one claims the
    /// carve-out sentinel; the others see the atomic claim fail and
    /// refuse. Each task takes `MODE_B_LOCK` (the primary serializer)
    /// and then claims the sentinel via `insert_raw_if_absent` (the
    /// mechanism `mint_mode_b` now uses) — so the claim is correct
    /// *even if a future refactor drops the lock*, which is the
    /// defence-in-depth P0.8 strengthened.
    ///
    /// This is the safety invariant CLAUDE.md highlights as load-bearing
    /// — two concurrent `/bootstrap/request` calls that both minted an
    /// admin would be a privilege-escalation hole. Uses the actual
    /// `MODE_B_LOCK` static and the actual sentinel key constant so a
    /// refactor that relocates either is caught here.
    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn carveout_claim_admits_exactly_one_concurrent_minter() {
        use crate::tee::admin_bootstrap::BOOTSTRAP_CARVEOUT_CLOSED_KEY;
        use std::sync::atomic::{AtomicUsize, Ordering};
        use std::time::Duration;
        use vti_common::config::StoreConfig;
        use vti_common::store::Store;

        let dir = tempfile::tempdir().expect("tempdir");
        let store_config = StoreConfig {
            data_dir: dir.path().to_path_buf(),
        };
        let store = Store::open(&store_config).expect("open store");
        let keys_ks = store.keyspace(crate::keyspaces::KEYS).expect("keyspace");

        let n_tasks: usize = 16;
        let successes = std::sync::Arc::new(AtomicUsize::new(0));

        let mut handles = Vec::with_capacity(n_tasks);
        for i in 0..n_tasks {
            let ks = keys_ks.clone();
            let successes = std::sync::Arc::clone(&successes);
            handles.push(tokio::spawn(async move {
                // Same shape as `mint_mode_b`: take MODE_B_LOCK, do
                // "work" (the yield models the TEE attestation + seal
                // window), then claim the sentinel atomically. The
                // atomic claim is what guarantees exactly-one even
                // without the lock.
                let _guard = MODE_B_LOCK.lock().await;
                tokio::time::sleep(Duration::from_millis(2)).await;
                let claimed = ks
                    .insert_raw_if_absent(
                        BOOTSTRAP_CARVEOUT_CLOSED_KEY,
                        format!("admin-{i}").into_bytes(),
                    )
                    .await
                    .expect("claim sentinel");
                if claimed {
                    ks.persist().await.expect("persist carve-out");
                    successes.fetch_add(1, Ordering::SeqCst);
                }
            }));
        }

        for h in handles {
            h.await.expect("task joined");
        }

        assert_eq!(
            successes.load(Ordering::SeqCst),
            1,
            "exactly one task may claim the carve-out sentinel; got {} successes",
            successes.load(Ordering::SeqCst),
        );
        assert!(
            keys_ks
                .get_raw(BOOTSTRAP_CARVEOUT_CLOSED_KEY)
                .await
                .unwrap()
                .is_some(),
            "sentinel must be set after the run"
        );
    }
}

// ─────────────────────────────────────────────────────────────────────
// POST /bootstrap/provision-integration
// ─────────────────────────────────────────────────────────────────────
//
// Authenticated counterpart to the offline `vta bootstrap
// provision-integration` CLI. The same shared library fn under
// `operations::provision_integration` backs both; only the I/O differs.

#[cfg(feature = "webvh")]
// Re-export the handler and its utoipa path-item type (generated by
// `#[utoipa::path]`) so the router's `routes!(bootstrap::provision_integration)`
// resolves both from this module.
#[cfg(feature = "webvh")]
pub use provision::{__path_provision_integration, provision_integration};

#[cfg(feature = "webvh")]
mod provision {
    use axum::Json;
    use axum::extract::State;
    use serde::{Deserialize, Serialize};

    use crate::auth::AdminAuth;
    use crate::error::AppError;
    use crate::operations::provision_integration::{
        AssertionMode, ProvisionIntegrationParams,
        provision_integration as provision_integration_lib,
    };
    use crate::server::AppState;
    use vta_sdk::provision_integration::BootstrapRequest;

    /// Request body for `POST /bootstrap/provision-integration`.
    #[derive(Debug, Deserialize, utoipa::ToSchema)]
    pub struct ProvisionIntegrationRequestBody {
        /// The integration's VP-framed bootstrap request (signed by its
        /// ephemeral `client_did`).
        pub request: BootstrapRequest,
        /// VTA context to provision into. **Optional** per the canonical
        /// Trust Task spec; omit to let the VTA infer from the caller's
        /// ACL grant or its own contexts state. See
        /// `vta_sdk::provision_integration::http::ProvisionIntegrationRequest`
        /// for the full inference rules + error semantics when ambiguous.
        #[serde(default)]
        pub context: Option<String>,
        /// Optional — default `DidSigned`. Rejected unless the assertion
        /// mode is one the server is happy to sign (pinned-only is
        /// accepted on the HTTP surface because dev/test HTTP use is
        /// legitimate).
        #[serde(default)]
        pub assertion: Option<AssertionModeWire>,
        /// Optional override for the VC's validity window, in seconds.
        /// Omit for the 1-hour default.
        #[serde(default)]
        pub vc_validity_seconds: Option<i64>,
        /// Create the target context as part of provisioning if it
        /// doesn't already exist. **Requires super-admin** — the
        /// op-layer `create_context` enforces this. Idempotent when
        /// the context already exists. Defaults to `false`.
        #[serde(default)]
        pub create_context: bool,
    }

    /// Wire-form enum for `assertion` (camelCase-serialised via
    /// `#[serde(rename_all = ...)]`).
    #[derive(Debug, Clone, Copy, Deserialize)]
    #[serde(rename_all = "kebab-case")]
    #[derive(utoipa::ToSchema)]
    pub enum AssertionModeWire {
        DidSigned,
        PinnedOnly,
    }

    impl From<AssertionModeWire> for AssertionMode {
        fn from(m: AssertionModeWire) -> Self {
            match m {
                AssertionModeWire::DidSigned => AssertionMode::DidSigned,
                AssertionModeWire::PinnedOnly => AssertionMode::PinnedOnly,
            }
        }
    }

    /// Response body.
    #[derive(Debug, Serialize, utoipa::ToSchema)]
    pub struct ProvisionIntegrationResponseBody {
        /// Armored sealed bundle (PGP-style BEGIN/END blocks).
        pub bundle: String,
        /// SHA-256 digest of the sealed ciphertext (lowercase hex).
        pub digest: String,
        /// Operator-readable summary.
        pub summary: ProvisionSummaryWire,
    }

    #[derive(Debug, Serialize, utoipa::ToSchema)]
    pub struct ProvisionSummaryWire {
        pub client_did: String,
        pub admin_did: String,
        pub admin_rolled_over: bool,
        /// `None` for the `AdminRotation` ask.
        #[serde(skip_serializing_if = "Option::is_none")]
        pub integration_did: Option<String>,
        /// `None` for the `AdminRotation` ask.
        #[serde(skip_serializing_if = "Option::is_none")]
        pub template_name: Option<String>,
        /// `None` for the `AdminRotation` ask.
        #[serde(skip_serializing_if = "Option::is_none")]
        pub template_kind: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        pub admin_template_name: Option<String>,
        pub bundle_id_hex: String,
        pub secret_count: usize,
        pub output_count: usize,
        #[serde(skip_serializing_if = "Option::is_none")]
        pub webvh_server_id: Option<String>,
        /// `true` when the target context didn't exist before this
        /// call and was created inline because the caller passed
        /// `create_context: true`. Lets operators see whether
        /// `--create-context` actually did something.
        #[serde(default)]
        pub context_created: bool,
    }

    /// Handler. Gated by `AdminAuth` — the caller must have admin role
    /// and the target context in `allowed_contexts` (enforced inside
    /// the library fn's preconditions). Super-admin passes through.
    #[utoipa::path(
        post, path = "/bootstrap/provision-integration", tag = "bootstrap",
        security(("bearer_jwt" = [])),
        request_body = ProvisionIntegrationRequestBody,
        responses(
            (status = 200, description = "Sealed provisioned bundle (content-negotiated)"),
            (status = 401, description = "Missing or invalid bearer token"),
            (status = 403, description = "Caller is not an admin in the target context"),
        ),
    )]
    pub async fn provision_integration(
        auth: AdminAuth,
        State(state): State<AppState>,
        Json(req): Json<ProvisionIntegrationRequestBody>,
    ) -> Result<Json<ProvisionIntegrationResponseBody>, AppError> {
        let verified = req
            .request
            .verify()
            .map_err(|e| AppError::Validation(format!("verify BootstrapRequest: {e}")))?;

        let assertion_mode = req.assertion.map(AssertionMode::from).unwrap_or_default();

        let vc_validity = req.vc_validity_seconds.map(chrono::Duration::seconds);

        let deps = crate::operations::provision_integration::ProvisionIntegrationDeps::from(&state);

        // Resolve + ensure the target context via the shared preamble. On
        // ambiguity we collapse into Validation here — REST clients (pnm-cli,
        // scripts) get the message + candidates inline. The DIDComm path emits
        // the canonical `provision/integration:context_required` code so
        // structured clients can branch on it; REST's typed-error vocabulary
        // wasn't designed for arbitrary new codes, so we stay with the existing
        // 400 shape. `--create-context` (super-admin gate inside
        // `create_context`) is honoured inside the helper.
        use crate::operations::provision_integration::ResolveContextError;
        let (context, context_created) =
            match crate::operations::provision_integration::resolve_target_context(
                &auth.0,
                &deps.contexts_ks,
                req.context,
                req.create_context,
            )
            .await
            {
                Ok(v) => v,
                Err(ResolveContextError::Ambiguous(
                    crate::operations::provision_integration::AmbiguousContext {
                        candidates,
                        message,
                    },
                )) => {
                    return Err(AppError::Validation(format!(
                        "{message} (candidates: {})",
                        candidates.join(", "),
                    )));
                }
                Err(ResolveContextError::Op(e)) => return Err(e),
            };
        let output = provision_integration_lib(
            &deps,
            &auth.0,
            ProvisionIntegrationParams {
                request: verified,
                context,
                assertion_mode,
                vc_validity,
            },
        )
        .await?;

        Ok(Json(ProvisionIntegrationResponseBody {
            bundle: output.armored,
            digest: output.digest,
            summary: ProvisionSummaryWire {
                client_did: output.summary.client_did,
                admin_did: output.summary.admin_did,
                admin_rolled_over: output.summary.admin_rolled_over,
                integration_did: output.summary.integration_did,
                template_name: output.summary.template_name,
                template_kind: output.summary.template_kind,
                admin_template_name: output.summary.admin_template_name,
                bundle_id_hex: output.summary.bundle_id_hex,
                secret_count: output.summary.secret_count,
                output_count: output.summary.output_count,
                webvh_server_id: output.summary.webvh_server_id,
                context_created,
            },
        }))
    }
}