Skip to main content

zlayer_types/api/
cluster.rs

1//! Cluster join / membership wire DTOs.
2//!
3//! Lifted from `zlayer-api::handlers::cluster` so the CLI, the manager UI,
4//! and any other client can describe these requests/responses without
5//! depending on `zlayer-api`. The handler itself stays in `zlayer-api`.
6
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10use crate::api::nodes::GpuInfoSummary;
11use crate::spec::{ArchKind, OsKind};
12
13/// Request body for `POST /api/v1/cluster/join`.
14#[derive(Debug, Clone, Deserialize, ToSchema)]
15pub struct ClusterJoinRequest {
16    /// Base64-encoded join token (contains `auth_secret` for validation)
17    pub token: String,
18    /// Joining node's advertise address (IP)
19    pub advertise_addr: String,
20    /// Joining node's overlay port (`WireGuard`)
21    pub overlay_port: u16,
22    /// Joining node's Raft RPC port
23    pub raft_port: u16,
24    /// Joining node's API server port
25    #[serde(default = "default_api_port")]
26    pub api_port: u16,
27    /// Joining node's `WireGuard` public key
28    pub wg_public_key: String,
29    /// Node mode: "full" or "replicate"
30    #[serde(default = "default_mode")]
31    pub mode: String,
32    /// Services to replicate (only if mode == "replicate")
33    pub services: Option<Vec<String>>,
34    /// Total CPU cores on the joining node
35    #[serde(default)]
36    pub cpu_total: f64,
37    /// Total memory in bytes
38    #[serde(default)]
39    pub memory_total: u64,
40    /// Total disk in bytes
41    #[serde(default)]
42    pub disk_total: u64,
43    /// Detected GPUs
44    #[serde(default)]
45    pub gpus: Vec<GpuInfoSummary>,
46    /// Operating system of the joining agent. `None` = legacy client that did
47    /// not report platform info.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub os: Option<OsKind>,
50    /// CPU architecture of the joining agent. Same legacy semantics as `os`.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub arch: Option<ArchKind>,
53    /// Free-form labels advertised by the joining agent, used for
54    /// `NodeSelector` placement matching. Empty on legacy clients.
55    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
56    pub labels: std::collections::HashMap<String, String>,
57    /// Whether the joining node holds the standing HTTP/HTTPS ingress on
58    /// `0.0.0.0:80` / `0.0.0.0:443`. Defaults to `true` so a joiner whose
59    /// client predates this field is treated as ingress-capable, matching the
60    /// `NodeConfig.ingress` default. The leader records it in `NodeInfo.ingress`
61    /// so service domains can resolve to an ingress-capable node.
62    #[serde(default = "default_ingress")]
63    pub ingress: bool,
64    /// Joiner's 32-byte X25519 pubkey for sealed-box DEK wrapping.
65    /// Present on Phase-1+ joiners; absent on legacy clients (in which
66    /// case the leader treats the node as not eligible to host
67    /// replicated-secret ciphertext until it re-joins with a pubkey).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub secrets_pubkey: Option<[u8; 32]>,
70}
71
72#[must_use]
73pub fn default_mode() -> String {
74    "full".to_string()
75}
76
77/// Serde default for [`ClusterJoinRequest::ingress`]: `true`. A joiner whose
78/// client predates the field is treated as ingress-capable, matching the
79/// `NodeConfig.ingress` default.
80#[must_use]
81pub fn default_ingress() -> bool {
82    true
83}
84
85#[must_use]
86pub fn default_api_port() -> u16 {
87    3669
88}
89
90/// Response body for `POST /api/v1/cluster/join`.
91#[derive(Debug, Clone, Serialize, ToSchema)]
92pub struct ClusterJoinResponse {
93    /// Assigned node UUID
94    pub node_id: String,
95    /// Assigned Raft node ID (monotonic u64)
96    pub raft_node_id: u64,
97    /// Assigned overlay IP for the new node
98    pub overlay_ip: String,
99    /// Per-node slice CIDR assigned by the leader (e.g. "10.200.42.0/28").
100    /// Empty string if the leader is not slice-aware yet.
101    #[serde(default)]
102    pub slice_cidr: String,
103    /// Existing peers in the cluster
104    pub peers: Vec<ClusterPeer>,
105    /// Role assigned to this node: "voter" or "learner"
106    pub role: String,
107    /// Node JWT minted by the leader for this joiner — `roles: ["node"]`,
108    /// `node_id` set. Used to authenticate inter-node calls separately
109    /// from any user identity. `None` on legacy responses.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub node_jwt: Option<String>,
112    /// Sealed-box-wrapped copy of the cluster DEK addressed to the
113    /// joiner's `secrets_pubkey`. The joiner unwraps with its node X25519
114    /// private key and holds the DEK in zeroized memory. `None` on legacy
115    /// responses or when the joiner did not provide a `secrets_pubkey`.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub wrapped_dek: Option<Vec<u8>>,
118    /// Cluster DEK generation that `wrapped_dek` was sealed under. Lets
119    /// the joiner detect rotation drift if it re-joins after a revocation
120    /// rotated the cluster DEK. `None` when `wrapped_dek` is `None`.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub dek_generation: Option<u64>,
123    /// Cluster-wide HMAC join secret. Returned to authenticated joiners
124    /// so they can derive the same internal RPC bearer as the leader.
125    /// `None` on legacy responses from older leaders.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub join_secret: Option<String>,
128    /// Cluster-wide JWT signing secret (the literal HMAC key the leader's
129    /// API uses to sign user session cookies). Propagated to authenticated
130    /// joiners so a session cookie minted on any node validates cluster-wide.
131    ///
132    /// Carried in cleartext here because the whole `ClusterJoinResponse`
133    /// already travels over the trusted leader↔joiner channel; the
134    /// `admin_identity` material below is the only piece that additionally
135    /// rides the sealed-box envelope. `None` on legacy responses or when the
136    /// leader has no cluster JWT secret configured.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub cluster_jwt_secret: Option<String>,
139    /// Sealed-box-wrapped admin identity export, addressed to the joiner's
140    /// `secrets_pubkey`. Inside (after the joiner unseals with its node
141    /// X25519 private key) is a JSON [`AdminIdentityExport`]: the admin user
142    /// row(s) plus their pre-hashed argon2 credential record, so the joiner
143    /// can import them WITHOUT re-hashing. `None` on legacy responses, when
144    /// the joiner did not provide a `secrets_pubkey`, or when the leader has
145    /// no admin users to propagate.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub admin_identity: Option<Vec<u8>>,
148    /// Server-side advisory warnings to surface to the operator/CLI.
149    ///
150    /// Examples: "your token format is deprecated and will be removed in
151    /// release X.Y", "consider rotating the signing key, last rotated N
152    /// days ago". Present-but-empty means "no warnings"; serialized as
153    /// `null` (skip-if-none) when there are none.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub warnings: Option<Vec<String>>,
156}
157
158/// A single admin user plus their pre-hashed credential, exported by the
159/// leader during a cluster join so the joiner can adopt the cluster's admin
160/// identity without re-hashing any password.
161///
162/// This struct is serialized to JSON, sealed-box-encrypted to the joiner's
163/// `secrets_pubkey`, and carried in [`ClusterJoinResponse::admin_identity`].
164/// It deliberately lives in `zlayer-types` (not `zlayer-api`) so the CLI/daemon
165/// joiner side can deserialize it without depending on `zlayer-api`.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct AdminUserExport {
168    /// Stable user id (`UUIDv4` string). Preserved verbatim on import so the
169    /// imported row matches the leader's.
170    pub id: String,
171    /// Primary login identifier (lower-cased email).
172    pub email: String,
173    /// Human-readable display name.
174    pub display_name: String,
175    /// Role string ("admin" / "user").
176    pub role: String,
177    /// Pre-computed argon2id PHC-string hash of the user's password. The
178    /// joiner stores this AS-IS — it never sees or re-hashes the password.
179    pub credential_hash: String,
180    /// Credential roles array (e.g. `["admin"]`).
181    pub credential_roles: Vec<String>,
182}
183
184/// The full admin-identity payload sealed into
185/// [`ClusterJoinResponse::admin_identity`]: every admin user the leader knows
186/// about, each with their pre-hashed credential record.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct AdminIdentityExport {
189    /// Admin user records to import on the joiner.
190    pub users: Vec<AdminUserExport>,
191}
192
193/// Summary of an existing cluster peer returned in join response.
194#[derive(Debug, Clone, Serialize, ToSchema)]
195pub struct ClusterPeer {
196    /// UUID
197    pub node_id: String,
198    /// Raft node ID
199    pub raft_node_id: u64,
200    /// Advertise address
201    pub advertise_addr: String,
202    /// Overlay port
203    pub overlay_port: u16,
204    /// Raft port
205    pub raft_port: u16,
206    /// `WireGuard` public key
207    pub wg_public_key: String,
208    /// Overlay IP
209    pub overlay_ip: String,
210}
211
212/// Claims carried inside a signed cluster join token.
213///
214/// **Field declaration order is the canonical signing order.** Do NOT
215/// reorder these fields without bumping the envelope's `v` and writing
216/// a migration — Wave 3.2's `mint_signed_cluster_join_token` signs
217/// `serde_json::to_vec(&claims)` directly, which depends on the
218/// declaration order being stable.
219///
220/// Timestamps are RFC3339 strings (not Unix epoch) so a token printed
221/// to a wiki or chat log is human-readable. `chrono::DateTime<Utc>` would
222/// also work; we chose `String` to keep the wire format trivially
223/// inspectable with `base64 -d | jq .`.
224#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
225pub struct ClusterJoinClaims {
226    /// Public API endpoint of the issuing leader (e.g. `https://leader.prod:3669`).
227    pub api_endpoint: String,
228    /// Raft endpoint of the issuing leader (e.g. `10.0.0.1:3670`).
229    pub raft_endpoint: String,
230    /// `WireGuard` public key of the issuing leader (base64 standard, no-pad).
231    pub leader_wg_pubkey: String,
232    /// Overlay CIDR the cluster owns (e.g. `10.42.0.0/16`).
233    pub overlay_cidr: String,
234    /// Expiration as RFC3339, e.g. `2026-05-15T17:55:00Z`.
235    pub exp: String,
236    /// Issued-at as RFC3339.
237    pub iat: String,
238    /// Issuing leader node identity. In Wave 3 this is the raw node UUID;
239    /// Wave 9 will switch this to a `spiffe://<cluster_domain>/<node_id>` URI
240    /// (token format version bump). Verifiers in Wave 3 treat `iss` as opaque
241    /// metadata — no parsing required.
242    pub iss: String,
243}
244
245/// Envelope around `ClusterJoinClaims` carrying the Ed25519 signature.
246///
247/// On the wire, this struct is serialized as JSON and then base64
248/// url-safe-no-pad encoded. The Wave 3.2 mint function produces that
249/// outer base64; the parser reverses it.
250#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
251pub struct SignedClusterJoinToken {
252    /// Format version. `1` in Wave 3. Wave 9 will introduce `v=2` (adds
253    /// a `ca_chain` field for federated trust); parsers MUST reject any
254    /// version they don't understand.
255    pub v: u32,
256    /// Key identifier (first 8 hex chars of SHA-256 over the verifying
257    /// key bytes). Lets joining nodes pick the correct pubkey during
258    /// rotation (Wave 5).
259    pub kid: String,
260    /// The payload that's actually signed.
261    pub claims: ClusterJoinClaims,
262    /// Ed25519 signature over `serde_json::to_vec(&claims)`, encoded as
263    /// URL-safe no-pad base64.
264    pub sig: String,
265    /// Optional CA chain binding the `kid` to a foreign cluster. Set
266    /// to `Some(...)` only on v=2 tokens minted for cross-cluster
267    /// federation; same-cluster v=2 tokens may omit it. v=1 tokens
268    /// MUST have this field absent (`skip_serializing_if` guarantees the
269    /// JSON shape stays compatible with v=1 parsers).
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub ca_chain: Option<CaCert>,
272}
273
274/// Current envelope version Wave-3 mints. Re-export so mint and verify
275/// stay in lockstep without a stringly-typed constant elsewhere.
276pub const SIGNED_TOKEN_V_WAVE3: u32 = 1;
277
278/// Wave 9 envelope version: extends Wave 3 with an optional `ca_chain`
279/// so a foreign-issued token can carry the CA-signed binding that
280/// proves its `kid` was issued by the cluster identified in
281/// `ca_chain.cluster_domain`. v=1 tokens still parse — `ca_chain` is
282/// just absent in their JSON.
283pub const SIGNED_TOKEN_V_WAVE9: u32 = 2;
284
285/// "CA certificate" minted by the cluster CA at every rotation of the
286/// active signing key.
287///
288/// Provides the binding: `active_kid` was issued by the cluster whose
289/// `ca_public_key_b64` is published in this cluster's `TrustBundle`.
290/// The signature `sig_by_ca` is the CA's Ed25519 signature over
291/// `serde_json::to_vec(&CaCertCore { active_kid, active_pubkey_b64,
292/// issued_at, expires_at, cluster_domain })` (i.e. the same struct
293/// with the `sig_by_ca` field stripped).
294///
295/// Field declaration order is canonical for signing. Do NOT reorder
296/// without bumping `CA_CERT_FORMAT_VERSION` and adding a migration.
297#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
298pub struct CaCert {
299    /// Format version for the CA cert body. `1` today.
300    pub v: u32,
301    /// `kid` of the active signing key this cert is binding.
302    pub active_kid: String,
303    /// URL-safe no-pad base64 of the active signing key's verifying key.
304    pub active_pubkey_b64: String,
305    /// RFC3339 timestamp when this cert was issued.
306    pub issued_at: String,
307    /// RFC3339 timestamp when this cert expires. Should match the
308    /// active key's own grace expiry so the cert and key share a
309    /// retirement clock.
310    pub expires_at: String,
311    /// Cluster identity this cert binds to. Defaults to the cluster's
312    /// UUID; operators may override to a DNS-style name like
313    /// `prod.zlayer.example`.
314    pub cluster_domain: String,
315    /// Ed25519 signature of the CA over `serde_json::to_vec(&self
316    /// with sig_by_ca cleared)`. URL-safe no-pad base64.
317    pub sig_by_ca: String,
318}
319
320/// Current `CaCert::v` value the issuer emits.
321pub const CA_CERT_FORMAT_VERSION: u32 = 1;
322
323/// Response body for `GET /api/v1/cluster/signing-pubkey`.
324///
325/// Returns the cluster's currently-active Ed25519 verifying key in URL-safe
326/// no-pad base64, along with a short identifier (`kid`) derived from
327/// `SHA-256(verifying_key)[..4]` (first 8 hex chars). Joining nodes use
328/// `kid` to disambiguate during key rotation (Wave 5).
329///
330/// This endpoint is intentionally unauthenticated: the data is a public key.
331#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
332pub struct SigningPubkeyResponse {
333    /// URL-safe no-pad base64 of the 32-byte Ed25519 verifying key.
334    pub public_key_b64: String,
335    /// Short greppable key id: first 8 hex chars of SHA-256(verifying_key).
336    pub kid: String,
337}
338
339/// Per-key entry returned by `GET /api/v1/cluster/signing-pubkeys`.
340#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
341pub struct SigningPubkeyEntry {
342    /// Short key id (8 hex chars).
343    pub kid: String,
344    /// URL-safe no-pad base64 of the Ed25519 verifying key (32 bytes → 43 chars).
345    pub public_key_b64: String,
346    /// `"active"` or `"grace"`. Active = newly-issued tokens use this key.
347    /// Grace = previously active; still verifies in-flight tokens until
348    /// `valid_until`.
349    pub status: String,
350    /// RFC3339 timestamp this key stops being accepted. Only present for
351    /// `status = "grace"`.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub valid_until: Option<String>,
354    /// RFC3339 timestamp this key was created.
355    pub created_at: String,
356}
357
358/// Response body for `GET /api/v1/cluster/signing-pubkeys`.
359///
360/// Returns every currently-trusted Ed25519 verifying key. The first entry
361/// is the active key (the one new tokens are minted under); subsequent
362/// entries are grace-period keys that still accept verification but not
363/// minting. Joining nodes use this when fetching keys for a token whose
364/// `kid` is a previous-active (rotated-out) key.
365///
366/// Unauthenticated by design (matches `signing-pubkey` singular).
367#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
368pub struct SigningPubkeysResponse {
369    pub keys: Vec<SigningPubkeyEntry>,
370}
371
372/// Request body for `POST /api/v1/cluster/rotate-signing-key`.
373///
374/// Triggers a rotation of the cluster's Ed25519 signing keystore: a fresh
375/// keypair is generated, set as the new active key, and the previously
376/// active key is moved into the grace map. Grace-period keys continue to
377/// verify in-flight join tokens until their `valid_until` timestamp.
378#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
379pub struct RotateSigningKeyRequest {
380    /// How long the previous-active key should remain valid for verifying
381    /// in-flight tokens after rotation. Humantime syntax (`24h`, `7d`).
382    /// Defaults to `7d` if omitted.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub grace: Option<String>,
385}
386
387/// Response body for `POST /api/v1/cluster/rotate-signing-key`.
388#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
389pub struct RotateSigningKeyResponse {
390    /// New active key id (8 hex chars).
391    pub kid: String,
392    /// URL-safe no-pad base64 of the new active verifying key.
393    pub public_key_b64: String,
394    /// Previous active kid, now in grace.
395    pub previous_kid: String,
396    /// RFC3339 timestamp when the previous key's grace expires.
397    pub previous_grace_until: String,
398}
399
400/// Public trust bundle for a cluster, distributable out-of-band so
401/// other clusters can import it and accept this cluster's tokens.
402///
403/// Contains the long-lived cluster CA pubkey (not the per-rotation
404/// signing key). Federation works by:
405///   1. Cluster A exports its bundle (`GET /api/v1/cluster/trust-bundle`).
406///   2. Operator transports the bundle to cluster B (out-of-band).
407///   3. Cluster B imports it via the admin endpoint, replicated through
408///      Raft so every node converges.
409///   4. Tokens minted by A's per-rotation key, carrying A's `ca_chain`,
410///      now validate against A's CA pubkey in B's trusted-bundles map.
411#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
412pub struct TrustBundle {
413    /// Format version. `1` today.
414    pub v: u32,
415    /// Cluster identity this bundle represents (defaults to cluster UUID;
416    /// may be a DNS-style domain like `prod.zlayer.example`).
417    pub cluster_domain: String,
418    /// URL-safe no-pad base64 of the cluster CA's Ed25519 verifying key.
419    pub ca_public_key_b64: String,
420    /// Short kid of the CA verifying key (8 hex chars).
421    pub ca_kid: String,
422    /// RFC3339 timestamp of when this bundle snapshot was generated.
423    /// Imports may compare timestamps to spot stale bundles.
424    pub generated_at: String,
425}
426
427/// Current `TrustBundle::v` value.
428pub const TRUST_BUNDLE_FORMAT_VERSION: u32 = 1;
429
430/// Request body for `POST /api/v1/cluster/trust-imports`.
431///
432/// The operator supplies a parsed [`TrustBundle`]. The handler proposes
433/// a Raft op so the import is replicated to every node before returning
434/// success.
435#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
436pub struct ImportTrustBundleRequest {
437    /// The bundle to import. Must be well-formed (parseable + non-empty
438    /// `cluster_domain` + valid base64 pubkey of correct length).
439    pub bundle: TrustBundle,
440    /// Optional URL the bundle was fetched from. Recorded server-side
441    /// for audit; not validated.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub source_url: Option<String>,
444}
445
446/// Response body for `POST /api/v1/cluster/trust-imports`.
447#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
448pub struct ImportTrustBundleResponse {
449    /// The `cluster_domain` of the imported bundle (echoed for clarity).
450    pub cluster_domain: String,
451    /// CA kid of the imported bundle.
452    pub ca_kid: String,
453}
454
455/// One entry in the trusted-bundle listing.
456#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
457pub struct TrustedBundleEntry {
458    /// Cluster domain.
459    pub cluster_domain: String,
460    /// CA kid.
461    pub ca_kid: String,
462    /// CA pubkey (URL-safe no-pad base64).
463    pub ca_public_key_b64: String,
464    /// RFC3339 timestamp when this bundle was originally generated by
465    /// the source cluster.
466    pub generated_at: String,
467    /// Optional source URL captured at import time.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub source_url: Option<String>,
470}
471
472/// Response body for `GET /api/v1/cluster/trust-bundles`.
473#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
474pub struct TrustedBundlesResponse {
475    /// All imported bundles, sorted by `cluster_domain` for stability.
476    pub bundles: Vec<TrustedBundleEntry>,
477}
478
479/// Request body for `POST /api/v1/cluster/revoke-token`.
480///
481/// The operator supplies EITHER the raw token (the same b64 envelope
482/// string `zlayer node generate-join-token` printed) OR the lowercase
483/// hex SHA-256 of that string. The server normalises to the hash form
484/// before proposing the Raft op so the actual token never enters
485/// replicated state. A `reason` may be attached for audit.
486#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
487pub struct RevokeTokenRequest {
488    /// Either the raw token envelope (b64 string) or its lowercase hex
489    /// SHA-256. The handler auto-detects which: 64 lowercase hex chars
490    /// is treated as a hash; anything else is hashed before insertion.
491    pub token_or_hash: String,
492    /// Optional human-readable reason recorded in the audit log
493    /// (NOT replicated — local to the leader that processed the request).
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub reason: Option<String>,
496}
497
498/// Response body for `POST /api/v1/cluster/revoke-token`.
499#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
500pub struct RevokeTokenResponse {
501    /// The canonical hash form of the revoked token (lowercase hex SHA-256).
502    pub token_hash: String,
503    /// RFC3339 timestamp when the revocation entry will be pruned. Matches
504    /// the token's own `exp` claim if the server could parse the envelope,
505    /// or `now() + 24h` as a safe fallback if only a hash was supplied.
506    pub expires_at: String,
507}
508
509/// One entry in the cluster-wide token revocation list.
510#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
511pub struct RevocationEntry {
512    /// Lowercase hex SHA-256 of the revoked token b64 envelope.
513    pub token_hash: String,
514    /// RFC3339 timestamp when this entry will be pruned.
515    pub expires_at: String,
516}
517
518/// Response body for `GET /api/v1/cluster/revocations`.
519///
520/// Returns all currently-active (un-expired) revocations replicated
521/// through Raft. Entries auto-prune at apply time; this listing is a
522/// point-in-time view of the local state machine.
523#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
524pub struct RevocationListResponse {
525    /// All currently-revoked tokens. Sorted by `expires_at` ascending so
526    /// the soonest-to-be-pruned entries come first.
527    pub revocations: Vec<RevocationEntry>,
528}
529
530/// The JWT algorithm policy a cluster enforces for join tokens.
531///
532/// Phases of the HS256 → `EdDSA` migration:
533/// - **`Hs256`**: accept HS256-JWT and Ed25519-signed-envelope tokens.
534///   EdDSA-JWT is rejected (fresh tokens have nowhere to come from
535///   in this phase).
536/// - **`Both`**: accept all three modern formats. Operators run their
537///   cluster here for a migration grace window so in-flight HS256
538///   tokens remain valid while clients re-issue under `EdDSA`.
539/// - **`Eddsa`**: accept EdDSA-JWT and Ed25519-signed-envelope.
540///   HS256-JWT is rejected with an actionable error. The symmetric
541///   `{data_dir}/join_secret` may be wiped via `WipeJoinSecret` at
542///   this point — it's no longer load-bearing.
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)]
544#[serde(rename_all = "lowercase")]
545pub enum JwtAlgorithm {
546    /// HS256-only (legacy default for clusters created before Wave 11).
547    Hs256,
548    /// Both algorithms accepted (migration window).
549    #[default]
550    Both,
551    /// EdDSA-only. The cluster has decommissioned its symmetric secret.
552    Eddsa,
553}
554
555impl JwtAlgorithm {
556    /// Return the canonical lowercase identifier.
557    #[must_use]
558    pub fn as_str(self) -> &'static str {
559        match self {
560            Self::Hs256 => "hs256",
561            Self::Both => "both",
562            Self::Eddsa => "eddsa",
563        }
564    }
565}
566
567impl std::fmt::Display for JwtAlgorithm {
568    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569        f.write_str(self.as_str())
570    }
571}
572
573/// Request body for `POST /api/v1/cluster/jwt-algorithm`.
574#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
575pub struct SetJwtAlgorithmRequest {
576    /// New algorithm policy to enforce cluster-wide.
577    pub algorithm: JwtAlgorithm,
578}
579
580/// Response body for `GET /api/v1/cluster/jwt-status`.
581#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
582pub struct JwtStatusResponse {
583    /// Currently-enforced algorithm policy.
584    pub algorithm: JwtAlgorithm,
585    /// RFC3339 timestamp when this node believes `{data_dir}/join_secret`
586    /// was last wiped via `SecretsRaftOp::WipeJoinSecret`. `None` if it
587    /// has never been wiped (the file may still exist on disk for HS256).
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub join_secret_wiped_at: Option<String>,
590}
591
592/// Summary of a worker-tier worker node, returned by `GET /api/v1/cluster/workers`.
593#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
594pub struct WorkerSummary {
595    /// Worker's assigned node id.
596    pub id: u64,
597    /// Worker's API/health address (host:port).
598    pub api_addr: String,
599    /// Labels declared during Register.
600    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
601    pub labels: std::collections::HashMap<String, String>,
602    /// Worker's reported OS.
603    pub os: String,
604    /// Last time the leader observed the worker.
605    pub last_seen_unix_secs: i64,
606    /// Liveness state (`ready` | `unreachable` | `draining`).
607    pub state: String,
608}
609
610/// Snapshot of one gossip-pool peer, returned by
611/// `GET /api/v1/cluster/gossip/peers`.
612#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
613pub struct GossipPeerSummary {
614    /// Worker (or peer) node id.
615    pub node_id: u64,
616    /// `WireGuard` public key (base64-url-no-pad), if known.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub wg_pubkey: Option<String>,
619    /// `WireGuard` UDP endpoint (host:port), if known.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub wg_endpoint: Option<String>,
622    /// Overlay IP assigned to this peer, if known.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub overlay_ip: Option<String>,
625    /// Free-form labels advertised by the peer.
626    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
627    pub labels: std::collections::HashMap<String, String>,
628}
629
630/// Summary of a cluster node for listing.
631#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
632pub struct ClusterNodeSummary {
633    /// UUID or Raft-level ID
634    pub id: String,
635    /// Network address (Raft RPC address)
636    pub address: String,
637    /// Advertise address (public IP)
638    pub advertise_addr: String,
639    /// API endpoint as `advertise_addr:api_port` (e.g., "127.0.0.1:19110").
640    /// Distinct from `address` which holds the Raft RPC endpoint.
641    #[serde(default)]
642    pub api_endpoint: String,
643    /// Current status (e.g. "ready", "draining", "dead")
644    pub status: String,
645    /// Role in the Raft cluster: "leader", "voter", or "learner"
646    pub role: String,
647    /// Join mode: "full" or "replicate"
648    pub mode: String,
649    /// Whether this node is the Raft leader
650    pub is_leader: bool,
651    /// Overlay network IP assigned to this node
652    pub overlay_ip: String,
653    /// Total CPU cores on this node
654    pub cpu_total: f64,
655    /// Current CPU usage (cores)
656    pub cpu_used: f64,
657    /// Total memory in bytes
658    pub memory_total: u64,
659    /// Current memory usage in bytes
660    pub memory_used: u64,
661    /// When the node was registered (Unix timestamp ms)
662    pub registered_at: u64,
663    /// Last heartbeat timestamp (Unix timestamp ms)
664    pub last_heartbeat: u64,
665}