boatramp-server 0.4.9

boatramp HTTP server + API library (streaming static-site publishing)
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
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
//! The control-plane identity and cluster admin API: mint and revoke
//! control-plane tokens, run the bootstrap and cluster join/promote/revoke
//! membership dance, rotate the mesh signing key, and manage the Cedar authz
//! policy plus its root trust anchors. Always-on (`/api/tokens`,
//! `/api/cluster/*`, `/api/authz/*`, `/api/auth/whoami`). Pulls the serve
//! scope in via `use super::*`.

use super::*;

#[derive(Deserialize)]
pub(super) struct CreateTokenRequest {
    label: String,
    /// Role specs (`"<role>"` or `"<role>:<site>"`); at least one required.
    #[serde(default)]
    roles: Vec<String>,
    /// Optional TTL in seconds; omitted ⇒ no expiry.
    #[serde(default)]
    ttl_secs: Option<u64>,
    /// Optional holder public key (`"<alg>:<hex>"`) making the token
    /// **delegatable** (RFC 8747 `cnf`): the holder of the
    /// matching private key can mint narrowing delegation blocks offline. Absent ⇒
    /// a plain, non-delegatable token.
    #[serde(default)]
    holder_pubkey: Option<String>,
}

#[derive(Serialize)]
struct CreateTokenResponse {
    /// The minted token (base64url `COSE_Sign1` CWT) — shown once, never stored.
    token: String,
    /// The revocation id (`cti`) — the `token rm` argument.
    id: String,
}

/// Mint a token carrying the requested roles and record its metadata. Needs the
/// token signer (the issuer); a verify-only node returns `501`. The token is
/// returned once and never stored — only its metadata is.
pub(super) async fn create_token(
    State(deploy): State<DeployStore>,
    Extension(issuer): Extension<Issuer>,
    Json(request): Json<CreateTokenRequest>,
) -> Response {
    let Some(signer) = issuer.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node has no root private key and cannot issue tokens\n",
        )
            .into_response();
    };
    let roles: Vec<GrantedRole> = request
        .roles
        .iter()
        .map(|s| GrantedRole::parse(s))
        .collect();
    if roles.is_empty() {
        return (StatusCode::BAD_REQUEST, "at least one role is required\n").into_response();
    }
    let now = now_unix();
    let claims = Claims {
        roles: roles.clone(),
        kind: cose::KIND_ROLE.to_string(),
        ttl_secs: request.ttl_secs,
        now_unix: now,
    };
    // A `holder_pubkey` makes the token delegatable (embeds the holder `cnf`).
    let holder = match &request.holder_pubkey {
        Some(hex) => match cose::TokenPublicKey::from_hex(hex) {
            Ok(pk) => Some(pk),
            Err(err) => {
                return (
                    StatusCode::BAD_REQUEST,
                    format!("invalid holder key: {err}\n"),
                )
                    .into_response()
            }
        },
        None => None,
    };
    let minted = match &holder {
        Some(holder) => cose::mint_delegatable(&claims, holder, &*signer).await,
        None => cose::mint(&claims, &*signer).await,
    };
    let token = match minted {
        Ok(t) => t,
        Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
    };
    // The revocation id is the token's `cti`; read it back by verifying the
    // just-minted token against our own public key (always valid, unexpired).
    let id = match cose::verify(&token, &signer.public_key(), now) {
        Ok(v) => v.cti,
        Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
    };
    let meta = TokenMeta {
        version: boatramp_core::SCHEMA_VERSION,
        label: request.label,
        roles,
        created_at: now,
        expires_at: request.ttl_secs.map(|t| now.saturating_add(t)),
        revocation_id: id.clone(),
    };
    match deploy.put_token_meta(&meta).await {
        Ok(()) => (StatusCode::CREATED, Json(CreateTokenResponse { token, id })).into_response(),
        Err(err) => deploy_error_response(err),
    }
}

#[derive(Deserialize)]
pub(super) struct BootstrapRequest {
    /// Roles for the first token. Defaults to `["admin"]` — the bootstrap token
    /// exists to configure the system (set policy, mint scoped tokens).
    #[serde(default)]
    pub(super) roles: Vec<String>,
    /// TTL in seconds; defaults to 1 h so an unused first token expires on its own.
    pub(super) ttl_secs: Option<u64>,
}

/// `POST /api/tokens/bootstrap` — mint the FIRST control-plane token by presenting
/// the operator-set, single-use **bootstrap secret** (as `Authorization: Bearer`),
/// not an admin token. RBAC-exempt at the router (`Right::required` → `None` for
/// exactly this path); this handler does the real verification. The token is
/// minted through the issuer (the root key never leaves the server), recorded as
/// [`TokenMeta`] (listable + revocable), and returned in the response — never
/// logged. `501` if bootstrap isn't enabled / this node can't issue; `401` on a
/// bad secret; `409` once the secret is spent (rotate it to re-bootstrap).
pub(super) async fn bootstrap_token(
    State(deploy): State<DeployStore>,
    Extension(issuer): Extension<Issuer>,
    Extension(gate): Extension<BootstrapGate>,
    headers: axum::http::HeaderMap,
    Json(request): Json<BootstrapRequest>,
) -> Response {
    let Some(inner) = gate.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "bootstrap is not enabled on this node (set a bootstrap secret)\n",
        )
            .into_response();
    };
    let Some(signer) = issuer.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node has no root private key and cannot issue tokens\n",
        )
            .into_response();
    };
    // The presented secret arrives as the bearer (so `require_auth`'s presence
    // check passes). Compare by SHA-256 — the hash also keys the single-use marker.
    let presented = headers
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .unwrap_or("");
    if boatramp_core::deploy::sha256_hex(presented.as_bytes()) != inner.secret_hash {
        return (StatusCode::UNAUTHORIZED, "invalid bootstrap secret\n").into_response();
    }
    // Serialize check-and-spend; the persisted marker makes it single-use across
    // restarts. Rotating the secret yields a fresh hash → re-enabled (recovery).
    let _guard = inner.lock.lock().await;
    match deploy.bootstrap_consumed(&inner.secret_hash).await {
        Ok(true) => {
            return (
                StatusCode::CONFLICT,
                "bootstrap secret already used — rotate it to re-bootstrap\n",
            )
                .into_response()
        }
        Ok(false) => {}
        Err(err) => return deploy_error_response(err),
    }
    let roles: Vec<GrantedRole> = if request.roles.is_empty() {
        vec![GrantedRole::parse("admin")]
    } else {
        request
            .roles
            .iter()
            .map(|s| GrantedRole::parse(s))
            .collect()
    };
    let now = now_unix();
    let ttl = request.ttl_secs.or(Some(3600));
    let claims = Claims {
        roles: roles.clone(),
        kind: cose::KIND_ROLE.to_string(),
        ttl_secs: ttl,
        now_unix: now,
    };
    let token = match cose::mint(&claims, &*signer).await {
        Ok(t) => t,
        Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
    };
    let id = match cose::verify(&token, &signer.public_key(), now) {
        Ok(v) => v.cti,
        Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
    };
    let meta = TokenMeta {
        version: boatramp_core::SCHEMA_VERSION,
        label: "bootstrap".to_string(),
        roles,
        created_at: now,
        expires_at: ttl.map(|t| now.saturating_add(t)),
        revocation_id: id.clone(),
    };
    if let Err(err) = deploy.put_token_meta(&meta).await {
        return deploy_error_response(err);
    }
    if let Err(err) = deploy.mark_bootstrap_consumed(&inner.secret_hash).await {
        return deploy_error_response(err);
    }
    tracing::warn!(cti = %id, "control-plane bootstrapped — first token minted via bootstrap secret");
    (StatusCode::CREATED, Json(CreateTokenResponse { token, id })).into_response()
}

#[derive(Deserialize)]
pub(super) struct JoinRequest {
    /// The single-use **bearer** mesh join token (base64url), from `cluster add`.
    pub(super) token: String,
    /// The joining node's own mesh public key (SPKI hex) — its self-derived
    /// identity. Not pre-authorized by the token; possession is proven below.
    pub(super) mesh_pubkey: String,
    /// A **possession proof**: an Ed25519 signature (hex) over
    /// `cose::join_challenge(jti, mesh_pubkey, proof_iat)`, proving the joiner
    /// controls `mesh_pubkey` — so a token + an observed key admits nothing.
    pub(super) possession_proof: String,
    /// The proof's issued-at (Unix seconds); must be fresh (anti-replay).
    pub(super) proof_iat: u64,
    /// The joiner's own mesh base URL (e.g. `https://10.0.0.4:7000`) so the leader
    /// can dial it for Raft replication. Advisory routing only — the mesh TLS
    /// re-authenticates every dial by key. Absent ⇒ the joiner isn't reachable by
    /// address (the leader still admits it, but can't replicate until it learns one).
    #[serde(default)]
    pub(super) advertise_addr: Option<String>,
}

#[derive(Serialize)]
struct JoinResponse {
    /// The cluster's current members as **root-signed** mesh-member assertions
    /// (base64url `COSE_Sign1`). The joiner verifies each against the root anchor
    /// before adding it to its trust set — so a malicious/stale seed can't inject a
    /// fabricated member (PLAN-cluster-join F3).
    members: Vec<String>,
    /// Advisory `node_id -> mesh URL` routing for the members, so the joiner can
    /// dial each one. Not signed (addressing is advisory; the mesh TLS
    /// re-authenticates by key), and only trusted for a `node_id` the joiner also
    /// verified via a root-signed member assertion above.
    #[serde(default)]
    member_addrs: std::collections::BTreeMap<u64, String>,
}

/// Admit a joining node presenting a mesh join token. Gated by
/// the token itself (`Right::required` returns `None` for this exact path), not
/// an admin bearer: the handler verifies the join token (signature + TTL),
/// confirms the presented `(node_id, pubkey)` is exactly the one the token
/// authorizes (a stolen token can't admit a different node/key), and hands the
/// verified claim to the cluster's [`MeshControl`] — which trusts the key
/// cluster-wide and adds membership, single-use enforced in the state machine.
/// `501` on a non-cluster node (no control hook) or a node without a root key.
pub(super) async fn cluster_join(
    Extension(auth): Extension<Auth>,
    Extension(mesh_control): Extension<MeshControlHandle>,
    Json(request): Json<JoinRequest>,
) -> Response {
    let Some(admitter) = mesh_control.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node is not a cluster node\n",
        )
            .into_response();
    };
    let Some(public) = auth.public_key() else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "join requires control-plane auth (no root key configured)\n",
        )
            .into_response();
    };
    let _ = public; // presence gates 501; verification tries the anchor set below.
    let now = now_unix();
    let jti = match auth.verify_join_token(&request.token, now).await {
        Ok(jti) => jti,
        Err(err) => {
            // A signature/framing failure is unauthenticated (401); an authentic
            // token that is expired or the wrong kind is forbidden (403).
            let code = match err {
                cose::TokenError::Invalid(_) => StatusCode::UNAUTHORIZED,
                _ => StatusCode::FORBIDDEN,
            };
            return (code, format!("invalid join token: {err}\n")).into_response();
        }
    };
    let Ok(proof) = hex::decode(request.possession_proof.trim()) else {
        return (StatusCode::BAD_REQUEST, "possession_proof must be hex\n").into_response();
    };
    // The cluster verifies the possession proof against the presented key + spends
    // the token, then vouches for its members with root-signed assertions.
    match admitter
        .admit(
            request.mesh_pubkey.trim(),
            &jti,
            &proof,
            request.proof_iat,
            now,
            request.advertise_addr.as_deref(),
        )
        .await
    {
        Ok(JoinOutcome::Admitted { members, addrs }) => (
            StatusCode::OK,
            Json(JoinResponse {
                members,
                member_addrs: addrs,
            }),
        )
            .into_response(),
        Ok(JoinOutcome::TokenSpent) => {
            (StatusCode::CONFLICT, "join token already spent\n").into_response()
        }
        Ok(JoinOutcome::ProofInvalid) => (
            StatusCode::FORBIDDEN,
            "join possession proof is missing, stale, or invalid\n",
        )
            .into_response(),
        Ok(JoinOutcome::Revoked) => (
            StatusCode::FORBIDDEN,
            "this mesh key is revoked; an explicit un-revoke is required before it can rejoin\n",
        )
            .into_response(),
        Err(err) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("admit failed: {err}\n"),
        )
            .into_response(),
    }
}

#[derive(Serialize)]
struct RotateKeyResponse {
    /// The node's new mesh public key (SPKI hex) after rotation.
    pubkey: String,
}

/// Rotate **this node's** mesh identity, make-before-break.
/// Admin-scoped (the deny-safe `Right::required` default for `/api/cluster/*`).
/// Node-local: only the node itself can mint + persist its private key, so this
/// rotates the key of the node whose API is hit. `501` on a non-cluster node.
pub(super) async fn cluster_rotate_key(
    Extension(mesh_control): Extension<MeshControlHandle>,
) -> Response {
    let Some(control) = mesh_control.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node is not a cluster node\n",
        )
            .into_response();
    };
    match control.rotate_key().await {
        Ok(pubkey) => (StatusCode::OK, Json(RotateKeyResponse { pubkey })).into_response(),
        Err(err) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("rotation failed: {err}\n"),
        )
            .into_response(),
    }
}

#[derive(Deserialize)]
pub(super) struct RevokeRequest {
    /// The node id to revoke from the mesh.
    node_id: u64,
}

/// Revoke a node from the mesh: delete its trust cluster-wide (so
/// it can no longer authenticate — the live verifier rejects it on reconnect) and
/// drop it from the quorum. Admin-scoped (the deny-safe `Right::required`
/// default). `501` on a non-cluster node.
pub(super) async fn cluster_revoke(
    Extension(mesh_control): Extension<MeshControlHandle>,
    Json(request): Json<RevokeRequest>,
) -> Response {
    let Some(control) = mesh_control.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node is not a cluster node\n",
        )
            .into_response();
    };
    match control.revoke(request.node_id).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(err) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("revocation failed: {err}\n"),
        )
            .into_response(),
    }
}

/// List the current Raft membership (`GET /api/cluster/members`) — voters +
/// learners with catch-up + leader flags. Admin-scoped (the deny-safe
/// `Right::required` default). `501` on a non-cluster node. The Kubernetes
/// operator reconciles this against the desired replica count.
pub(super) async fn cluster_members(
    Extension(mesh_control): Extension<MeshControlHandle>,
) -> Response {
    let Some(control) = mesh_control.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node is not a cluster node\n",
        )
            .into_response();
    };
    match control.members().await {
        Ok(members) => Json(members).into_response(),
        Err(err) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("listing membership failed: {err}\n"),
        )
            .into_response(),
    }
}

#[derive(Deserialize)]
pub(super) struct PromoteRequest {
    /// The node id (a caught-up learner) to promote to a voter.
    node_id: u64,
}

/// Promote a caught-up learner to a voter (`POST /api/cluster/promote`) — the
/// scale-up completion step the operator drives once a joined node has caught up.
/// Leader-only server-side (a no-op on a follower). Admin-scoped. `501` on a
/// non-cluster node.
pub(super) async fn cluster_promote(
    Extension(mesh_control): Extension<MeshControlHandle>,
    Json(request): Json<PromoteRequest>,
) -> Response {
    let Some(control) = mesh_control.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node is not a cluster node\n",
        )
            .into_response();
    };
    match control.promote(request.node_id).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(err) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("promotion failed: {err}\n"),
        )
            .into_response(),
    }
}

/// List issued-token metadata (id, label, roles, timestamps — never the token).
pub(super) async fn list_tokens(State(deploy): State<DeployStore>) -> Response {
    match deploy.list_token_meta().await {
        Ok(mut tokens) => {
            tokens.sort_by_key(|m| m.created_at);
            Json(tokens).into_response()
        }
        Err(err) => deploy_error_response(err),
    }
}

/// Revoke a token by its revocation id or a unique id prefix.
pub(super) async fn revoke_token(
    State(deploy): State<DeployStore>,
    Path(id): Path<String>,
) -> Response {
    match deploy.revoke_token(&id).await {
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => (StatusCode::NOT_FOUND, "no matching token\n").into_response(),
        Err(err) => deploy_error_response(err),
    }
}

/// Default mesh-join-token TTL when the request omits one (1 hour). A join is a
/// prompt operator action, so the admission window stays short.
const DEFAULT_JOIN_TOKEN_TTL_SECS: u64 = 3600;

#[derive(Deserialize, Default)]
pub(super) struct CreateJoinTokenRequest {
    /// Optional TTL in seconds; omitted ⇒ [`DEFAULT_JOIN_TOKEN_TTL_SECS`].
    #[serde(default)]
    pub(super) ttl_secs: Option<u64>,
}

#[derive(Serialize)]
struct CreateJoinTokenResponse {
    /// The minted join token, base64url — shown once, never stored.
    token: String,
    /// The token's expiry (Unix seconds).
    expires_at: u64,
}

/// Mint a **single-use bearer mesh join token** with a TTL. It is not bound to a
/// node/key (the operator can't know a not-yet-booted node's key); the joiner
/// proves possession of its own mesh key at redemption, and the `jti` is spent
/// single-use cluster-side. Needs the root private key (the issuer); a verify-only
/// node returns `501`. Admin-scoped (the deny-safe `Right::required` default gates
/// `/api/cluster/*`). Returned once, never stored.
pub(super) async fn create_join_token(
    Extension(issuer): Extension<Issuer>,
    Json(request): Json<CreateJoinTokenRequest>,
) -> Response {
    let Some(signer) = issuer.0 else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            "this node has no root private key and cannot issue join tokens\n",
        )
            .into_response();
    };
    let ttl = request.ttl_secs.unwrap_or(DEFAULT_JOIN_TOKEN_TTL_SECS);
    let now = now_unix();
    match cose::mint_join(ttl, now, &*signer).await {
        Ok(token) => (
            StatusCode::CREATED,
            Json(CreateJoinTokenResponse {
                token,
                expires_at: now.saturating_add(ttl),
            }),
        )
            .into_response(),
        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
    }
}

/// A principal's own identity (`GET /api/auth/whoami`): the roles its token
/// grants. Gated only by holding a valid token (the handler verifies it).
#[derive(Serialize)]
struct WhoAmI {
    /// Whether control-plane auth is enabled on this node.
    auth_enabled: bool,
    /// The roles carried by the presented token.
    roles: Vec<GrantedRole>,
}

pub(super) async fn auth_whoami(Extension(auth): Extension<Auth>, headers: HeaderMap) -> Response {
    if auth.is_disabled() {
        // Auth disabled (dev): no identity to report.
        return Json(WhoAmI {
            auth_enabled: false,
            roles: Vec::new(),
        })
        .into_response();
    }
    let Some(bearer) = headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
    else {
        return (StatusCode::UNAUTHORIZED, "missing bearer token\n").into_response();
    };
    // Full validation (signature + TTL + revocation + caveats), not a bare
    // signature check, so an expired/revoked token can't disclose its roles.
    match auth.verify_bearer_roles(bearer).await {
        Some(roles) => Json(WhoAmI {
            auth_enabled: true,
            roles,
        })
        .into_response(),
        None => (StatusCode::UNAUTHORIZED, "invalid token\n").into_response(),
    }
}

/// Return the active RBAC policy (`authz/policy`), or the built-in default when
/// none is stored — so a `get` always shows the effective policy.
pub(super) async fn get_authz_policy(State(deploy): State<DeployStore>) -> Response {
    match deploy.get_authz_policy().await {
        Ok(Some(policy)) => Json(policy).into_response(),
        Ok(None) => Json(boatramp_core::authz::AuthzPolicy::default_policy()).into_response(),
        Err(err) => deploy_error_response(err),
    }
}

/// Replace the RBAC policy. Rejected (`400`) unless it compiles to a valid Cedar
/// policy set, so a bad policy can never be stored and brick the edge.
pub(super) async fn put_authz_policy(
    State(deploy): State<DeployStore>,
    Json(policy): Json<boatramp_core::authz::AuthzPolicy>,
) -> Response {
    if let Err(err) = boatramp_core::cedar::CompiledCedar::compile(&policy) {
        return (StatusCode::BAD_REQUEST, format!("invalid policy: {err}\n")).into_response();
    }
    match deploy.set_authz_policy(&policy).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(err) => deploy_error_response(err),
    }
}

/// The extra trusted root anchors (make-before-break rotation).
pub(super) async fn list_root_anchors(State(deploy): State<DeployStore>) -> Response {
    match deploy.list_root_anchors().await {
        Ok(anchors) => (StatusCode::OK, Json(anchors)).into_response(),
        Err(err) => deploy_error_response(err),
    }
}

#[derive(Deserialize)]
pub(super) struct RootAnchorRequest {
    /// The `alg:hex`-encoded root public key to trust alongside the primary.
    pubkey: String,
}

/// Trust an additional root anchor — rejects anything that isn't a valid
/// `TokenPublicKey` so a malformed anchor can never be added.
pub(super) async fn add_root_anchor(
    State(deploy): State<DeployStore>,
    Json(req): Json<RootAnchorRequest>,
) -> Response {
    let pubkey = req.pubkey.trim();
    if cose::TokenPublicKey::from_hex(pubkey).is_err() {
        return (
            StatusCode::BAD_REQUEST,
            "pubkey must be an alg:hex TokenPublicKey (e.g. es256:…)\n",
        )
            .into_response();
    }
    match deploy.add_root_anchor(pubkey).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(err) => deploy_error_response(err),
    }
}

/// Retire a root anchor (the old key, after a rotation propagates).
pub(super) async fn remove_root_anchor(
    State(deploy): State<DeployStore>,
    Path(pubkey): Path<String>,
) -> Response {
    match deploy.remove_root_anchor(pubkey.trim()).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(err) => deploy_error_response(err),
    }
}

// ---- Project-scoped internal secret store (admin API) --------------------
//
// The `/api/projects/{proj}/secrets{,/{name}}` surface is rewritten by
// `project_scope` onto the global `/api/secrets{,/{name}}` handlers below, which
// read the tenant from the `ProjectContext` extension (the same shape as the
// site/function/compute handlers). Authorization is enforced upstream by
// `require_auth` against the *original* project-scoped path
// (`Secrets·Read`/`Secrets·Write`).
//
// The store seals every value with the `[secrets]` envelope; these handlers only
// ever set/list/delete **names + metadata** and NEVER return a value. There is no
// value-GET endpoint — a value leaves the store only into a guest at instantiation
// (the resolver), never over the API.

/// When no `[secrets]` key envelope is configured there is no sealed store, so
/// every secrets endpoint returns this clear `501` — never a panic or a `500`.
fn no_secret_store_response() -> Response {
    (
        StatusCode::NOT_IMPLEMENTED,
        "no [secrets] key envelope configured; secrets require sealing at rest \
         (set [secrets] envelope = \"local\" or \"vault\" in boatramp.cfg)\n",
    )
        .into_response()
}

/// Map a [`SecretError`] to a response without disclosing backend internals: a
/// **client** error (invalid name, oversized value — the message is about the request)
/// returns `400` with that message; a **backend** error (a KV / envelope failure whose
/// detail could carry key shapes or a KMS endpoint) is logged server-side and returned
/// as a generic `500`.
fn secret_error_response(err: boatramp_core::secret_store::SecretError) -> Response {
    if err.is_client_error() {
        (StatusCode::BAD_REQUEST, format!("{err}\n")).into_response()
    } else {
        tracing::warn!(%err, "secret store backend error");
        (StatusCode::INTERNAL_SERVER_ERROR, "secret store error\n").into_response()
    }
}

/// The secret store, injected as an extension by the node when a `[secrets]`
/// envelope is present. `None` ⇒ the endpoints fail closed with a clear `501`.
type SecretStoreExt = Option<Arc<boatramp_core::secret_store::SecretStore>>;

#[derive(Deserialize)]
pub(super) struct SetSecretRequest {
    /// The secret name (a KV key segment; validated inside the store).
    name: String,
    /// The plaintext value — sealed server-side; never stored in the clear,
    /// logged, or returned.
    value: String,
}

/// `POST /api/projects/{proj}/secrets` — seal `value` server-side under `name`
/// (rotation = POST an existing name) and return the value-free [`SecretMeta`] as
/// `201`. Never echoes the value. `501` when no envelope is configured; `400` on an
/// invalid name (rejected fail-closed inside the store).
pub(super) async fn set_secret(
    Extension(store): Extension<SecretStoreExt>,
    Extension(project): Extension<ProjectContext>,
    Json(request): Json<SetSecretRequest>,
) -> Response {
    let Some(store) = store else {
        return no_secret_store_response();
    };
    match store
        .set(project.as_ref(), &request.name, request.value.as_bytes())
        .await
    {
        Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
        Err(err) => secret_error_response(err),
    }
}

/// `GET /api/projects/{proj}/secrets` — list every secret's **name + metadata**
/// (sorted), never a value. `501` when no envelope is configured.
pub(super) async fn list_secrets(
    Extension(store): Extension<SecretStoreExt>,
    Extension(project): Extension<ProjectContext>,
) -> Response {
    let Some(store) = store else {
        return no_secret_store_response();
    };
    match store.list(project.as_ref()).await {
        Ok(metas) => Json(metas).into_response(),
        Err(err) => secret_error_response(err),
    }
}

/// `DELETE /api/projects/{proj}/secrets/{name}` — remove a secret; `204` if it
/// existed, `404` if not. `501` when no envelope is configured; `400` on an invalid
/// name.
pub(super) async fn delete_secret(
    Extension(store): Extension<SecretStoreExt>,
    Extension(project): Extension<ProjectContext>,
    Path(name): Path<String>,
) -> Response {
    let Some(store) = store else {
        return no_secret_store_response();
    };
    match store.delete(project.as_ref(), &name).await {
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => (StatusCode::NOT_FOUND, "no matching secret\n").into_response(),
        Err(err) => secret_error_response(err),
    }
}

// ---- Project-scoped SMTP email profiles (admin API) ----------------------
//
// The `/api/projects/{proj}/email/profiles{,/{name}}` surface is rewritten by
// `project_scope` onto the global `/api/email/profiles{,/{name}}` handlers below,
// reading the tenant from the `ProjectContext`. Authorization is enforced upstream
// against the original project-scoped path with the same `Resource::Secrets` right
// as the secret store (email profiles are credential config).
//
// The store seals every profile's **password** with the `[secrets]` envelope; these
// handlers return only the **redacted** config (host/port/from/…, never the
// password). There is no password-GET endpoint — the password leaves the store only
// host-side, into the send binding at instantiation, never over the API.

/// When no `[secrets]` key envelope is configured there is no sealed store, so
/// every email-profile endpoint returns this clear `501` — never a panic or `500`.
fn no_email_store_response() -> Response {
    (
        StatusCode::NOT_IMPLEMENTED,
        "no [secrets] key envelope configured; email profiles seal their SMTP \
         password at rest (set [secrets] envelope = \"local\" or \"vault\" in boatramp.cfg)\n",
    )
        .into_response()
}

/// Map an [`EmailProfileError`](boatramp_core::email_config::EmailProfileError) to a
/// response without disclosing backend internals: a client error (invalid name /
/// config) returns `400` with the message; a backend error is logged and returned
/// as a generic `500`.
fn email_error_response(err: boatramp_core::email_config::EmailProfileError) -> Response {
    if err.is_client_error() {
        (StatusCode::BAD_REQUEST, format!("{err}\n")).into_response()
    } else {
        tracing::warn!(%err, "email profile store backend error");
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            "email profile store error\n",
        )
            .into_response()
    }
}

/// The email-profile store, injected as an extension by the node when a `[secrets]`
/// envelope is present. `None` ⇒ the endpoints fail closed with a clear `501`.
type EmailStoreExt = Option<Arc<boatramp_core::email_config::EmailProfileStore>>;

#[derive(Deserialize)]
pub(super) struct SetEmailProfileRequest {
    /// SMTP relay hostname. Required on create; **omit to keep** the stored value on update.
    #[serde(default)]
    host: Option<String>,
    /// SMTP relay port; omitted ⇒ kept (or, on create, the conventional port for `security`).
    #[serde(default)]
    port: Option<u16>,
    /// Transport security: `starttls` | `tls` | `plaintext`. Omitted ⇒ kept.
    #[serde(default)]
    security: Option<String>,
    /// SMTP AUTH username. Omitted ⇒ kept; use `clear_auth` to drop it.
    #[serde(default)]
    username: Option<String>,
    /// SMTP AUTH password — sealed server-side; never stored clear, logged, or returned.
    /// **Omitted ⇒ the stored password is kept** (so a host/from edit can't silently wipe
    /// auth); provide it only to rotate. `clear_auth` drops it.
    #[serde(default)]
    password: Option<String>,
    /// The default (and only permitted) `From` address. Required on create; omit to keep.
    #[serde(default)]
    from: Option<String>,
    /// Whether sends through this profile default to the durable spool. Omitted ⇒ kept.
    #[serde(default)]
    durable: Option<bool>,
    /// Drop the username + password entirely (an unauthenticated relay).
    #[serde(default)]
    clear_auth: bool,
}

/// `PUT /api/projects/{proj}/email/profiles/{name}` — create-or-**merge** a profile. Fields
/// present overwrite; fields omitted keep their stored value (the sealed password included, so
/// changing one parameter never re-transmits or wipes it); `clear_auth` drops the credentials.
/// Seals any supplied password server-side and returns the **redacted**
/// [`EmailProfileInfo`](boatramp_core::email_config::EmailProfileInfo) as `201`. On create,
/// host + from must be present. Never echoes the password. `501` with no envelope; `400` on an
/// invalid name/config.
pub(super) async fn set_email_profile(
    Extension(store): Extension<EmailStoreExt>,
    Extension(project): Extension<ProjectContext>,
    Path(name): Path<String>,
    Json(request): Json<SetEmailProfileRequest>,
) -> Response {
    let Some(store) = store else {
        return no_email_store_response();
    };
    let security = match &request.security {
        Some(s) => match s.parse::<boatramp_core::email_config::SmtpSecurity>() {
            Ok(s) => Some(s),
            Err(e) => return (StatusCode::BAD_REQUEST, format!("{e}\n")).into_response(),
        },
        None => None,
    };
    let patch = boatramp_core::email_config::EmailProfilePatch {
        host: request.host,
        port: request.port,
        security,
        username: request.username,
        password: request.password,
        from: request.from,
        durable: request.durable,
        clear_auth: request.clear_auth,
    };
    match store.patch(project.as_ref(), &name, &patch).await {
        Ok(info) => (StatusCode::CREATED, Json(info)).into_response(),
        Err(err) => email_error_response(err),
    }
}

/// `GET /api/projects/{proj}/email/profiles` — list every profile's **redacted**
/// config (sorted), never the password. `501` with no envelope.
pub(super) async fn list_email_profiles(
    Extension(store): Extension<EmailStoreExt>,
    Extension(project): Extension<ProjectContext>,
) -> Response {
    let Some(store) = store else {
        return no_email_store_response();
    };
    match store.list(project.as_ref()).await {
        Ok(infos) => Json(infos).into_response(),
        Err(err) => email_error_response(err),
    }
}

/// `GET /api/projects/{proj}/email/profiles/{name}` — one profile's **redacted**
/// config; `404` if absent. `501` with no envelope.
pub(super) async fn show_email_profile(
    Extension(store): Extension<EmailStoreExt>,
    Extension(project): Extension<ProjectContext>,
    Path(name): Path<String>,
) -> Response {
    let Some(store) = store else {
        return no_email_store_response();
    };
    match store.get_info(project.as_ref(), &name).await {
        Ok(Some(info)) => Json(info).into_response(),
        Ok(None) => (StatusCode::NOT_FOUND, "no matching email profile\n").into_response(),
        Err(err) => email_error_response(err),
    }
}

/// `DELETE /api/projects/{proj}/email/profiles/{name}` — remove a profile; `204` if
/// it existed, `404` if not. `501` with no envelope; `400` on an invalid name.
pub(super) async fn delete_email_profile(
    Extension(store): Extension<EmailStoreExt>,
    Extension(project): Extension<ProjectContext>,
    Path(name): Path<String>,
) -> Response {
    let Some(store) = store else {
        return no_email_store_response();
    };
    match store.delete(project.as_ref(), &name).await {
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
        Ok(false) => (StatusCode::NOT_FOUND, "no matching email profile\n").into_response(),
        Err(err) => email_error_response(err),
    }
}