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}