Skip to main content

thunder/server/
listener.rs

1//! The accept loop, per-connection shape and hot path (SPEC-004 §1/§1b).
2//!
3//! Hot path transplanted from the Synap listener (§7 baseline analysis,
4//! T-027): `set_nodelay(true)` on accept (SRV-008), a dedicated writer task
5//! owning a `BufWriter` over the write half behind an mpsc channel
6//! (SRV-002), and the drain-then-flush pattern — write one response, drain
7//! every already-queued response via `try_recv`, then flush once, so a
8//! pipelined burst coalesces into one syscall (SRV-006, +23% committed
9//! in-family evidence). Exactly one serialization per response: the frame
10//! is encoded once, written, and its length is the out-bytes metric;
11//! request in-bytes come from the decoder's frame size (SRV-007).
12
13use std::io;
14use std::net::SocketAddr;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use crate::wire::config::{Config, ErrorConvention, Handshake, HelloStyle, PushPolicy};
19use crate::wire::{encode_frame, read_request_with_limit, Request, Response, Value, PUSH_ID};
20use tokio::io::{AsyncWriteExt, BufReader, BufWriter};
21use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
22use tokio::net::{TcpListener, TcpStream};
23use tokio::sync::{mpsc, watch, Semaphore};
24
25use crate::server::dispatch::{AuthError, Credentials, Dispatch};
26use crate::server::errors::{format_bracket_code, format_err, NOAUTH, WRONGPASS};
27use crate::server::metrics::{Metrics, MetricsSnapshot};
28use crate::server::session::{PushSender, Session, WriteJob};
29
30/// Wire protocol version advertised in HELLO replies (WIRE-004: v1,
31/// frozen — adding commands or profiles never bumps it).
32const PROTO_VERSION: i64 = 1;
33
34/// Pre-auth allowlist under `Handshake::AuthCommand` (SRV-011, PRO-001).
35const PRE_AUTH_COMMANDS: &[&str] = &["PING", "HELLO", "AUTH", "QUIT"];
36
37/// Depth of the per-connection writer queue (the family's proven value).
38const WRITER_QUEUE_DEPTH: usize = 64;
39
40/// Server identity used by Thunder-built HELLO replies (SRV-014).
41#[derive(Debug, Clone)]
42pub struct ServerInfo {
43    /// `server` field of the metadata-shape HELLO reply (`HelloStyle::ArgLess`).
44    pub name: String,
45    /// `version` field of the metadata-shape HELLO reply.
46    pub version: String,
47}
48
49/// Listener configuration. Family posture keeps binds loopback/private by
50/// default (SRV-040 guidance).
51#[derive(Debug, Clone)]
52pub struct ListenerConfig {
53    /// Address to bind. Port `0` picks an ephemeral port — read it back
54    /// via [`ListenerHandle::local_addr`].
55    pub addr: SocketAddr,
56    /// Per-read idle timeout (slow-loris resistance, SRV-009). Zero
57    /// disables, matching each product's current posture.
58    pub idle_timeout: Duration,
59    /// Commands slower than this bump `slow_commands_total` (SRV-030).
60    /// Zero disables the counter.
61    pub slow_threshold: Duration,
62    /// Whether this **deployment** enforces credentials (SRV-011).
63    ///
64    /// This is policy, not protocol: the profile fixes the handshake
65    /// *shape* (does the client lead with `HELLO`? does it authenticate via
66    /// `AUTH`?), while this flag decides whether the server actually refuses
67    /// un-credentialed sessions. Both family products that authenticate on
68    /// the RPC path expose exactly this toggle — Nexus's `auth_required` and
69    /// Synap's `require_auth` — and an open Synap deployment is the reason
70    /// it must live here rather than in the profile.
71    ///
72    /// Ignored under [`Handshake::None`], which has no gate at all. Defaults
73    /// to `true`: a deployment opens up only by saying so.
74    pub auth_required: bool,
75}
76
77impl ListenerConfig {
78    /// Config for `addr` with the defaults: no idle timeout, 1000 ms slow
79    /// threshold, credentials enforced.
80    pub fn new(addr: SocketAddr) -> Self {
81        Self {
82            addr,
83            idle_timeout: Duration::ZERO,
84            slow_threshold: Duration::from_millis(1000),
85            auth_required: true,
86        }
87    }
88
89    /// Serve un-credentialed sessions — the `auth_required = false` /
90    /// `require_auth = false` posture (e.g. an open Synap deployment).
91    ///
92    /// The handshake shape is unchanged: a client may still send `AUTH`, and
93    /// it still succeeds or fails on its own merits; nothing is *required*.
94    pub fn open(mut self) -> Self {
95        self.auth_required = false;
96        self
97    }
98}
99
100impl Default for ListenerConfig {
101    /// Loopback on an ephemeral port with the standard defaults.
102    fn default() -> Self {
103        Self::new(SocketAddr::from(([127, 0, 0, 1], 0)))
104    }
105}
106
107/// Everything a connection task needs, shared once per listener.
108struct ConnShared<D> {
109    dispatch: Arc<D>,
110    profile: Config,
111    info: ServerInfo,
112    idle_timeout: Duration,
113    slow_threshold: Duration,
114    auth_required: bool,
115    metrics: Arc<Metrics>,
116}
117
118/// Handle to a running listener (SRV-001).
119///
120/// [`stop`](Self::stop) performs the graceful shutdown: the accept loop
121/// ends, every connection finishes its in-flight requests, drains its
122/// writer and closes; `stop` resolves when the last connection is gone.
123/// Dropping the handle signals the same shutdown without waiting.
124#[derive(Debug)]
125pub struct ListenerHandle {
126    local_addr: SocketAddr,
127    shutdown: watch::Sender<bool>,
128    metrics: Arc<Metrics>,
129    done: Option<mpsc::Receiver<()>>,
130}
131
132impl ListenerHandle {
133    /// The bound address (resolves port `0` binds).
134    pub fn local_addr(&self) -> SocketAddr {
135        self.local_addr
136    }
137
138    /// Point-in-time metrics (SRV-030).
139    pub fn snapshot(&self) -> MetricsSnapshot {
140        self.metrics.snapshot()
141    }
142
143    /// Graceful shutdown (SRV-001): stop accepting, let every connection
144    /// drain its in-flight responses, and resolve once all of them closed.
145    pub async fn stop(mut self) {
146        let _ = self.shutdown.send(true);
147        if let Some(mut done) = self.done.take() {
148            // `recv` yields `None` once the accept loop and every
149            // connection task dropped their guard senders.
150            let _ = done.recv().await;
151        }
152    }
153}
154
155impl Drop for ListenerHandle {
156    fn drop(&mut self) {
157        // Fire-and-forget shutdown; `stop()` is the waiting variant.
158        let _ = self.shutdown.send(true);
159    }
160}
161
162/// Bind `config.addr` and run the accept loop: one task per connection,
163/// graceful shutdown through the returned handle (SRV-001).
164pub async fn spawn_listener<D: Dispatch>(
165    dispatch: Arc<D>,
166    profile: Config,
167    info: ServerInfo,
168    config: ListenerConfig,
169) -> io::Result<ListenerHandle> {
170    let listener = TcpListener::bind(config.addr).await?;
171    let local_addr = listener.local_addr()?;
172    let metrics = Arc::new(Metrics::default());
173    let (shutdown_tx, shutdown_rx) = watch::channel(false);
174    let (done_tx, done_rx) = mpsc::channel::<()>(1);
175
176    let shared = Arc::new(ConnShared {
177        dispatch,
178        profile,
179        info,
180        idle_timeout: config.idle_timeout,
181        slow_threshold: config.slow_threshold,
182        auth_required: config.auth_required,
183        metrics: Arc::clone(&metrics),
184    });
185
186    tokio::spawn(accept_loop(listener, shared, shutdown_rx, done_tx));
187
188    Ok(ListenerHandle {
189        local_addr,
190        shutdown: shutdown_tx,
191        metrics,
192        done: Some(done_rx),
193    })
194}
195
196/// Accept until shutdown; each connection runs in its own task (SRV-001).
197/// Accept errors are transient — they never end the loop (SRV-004 spirit:
198/// nothing a single socket does may kill the listener).
199async fn accept_loop<D: Dispatch>(
200    listener: TcpListener,
201    shared: Arc<ConnShared<D>>,
202    shutdown: watch::Receiver<bool>,
203    done: mpsc::Sender<()>,
204) {
205    let mut accept_shutdown = shutdown.clone();
206    let mut next_conn_id: u64 = 1;
207    loop {
208        let accepted = tokio::select! {
209            _ = accept_shutdown.wait_for(|stop| *stop) => break,
210            accepted = listener.accept() => accepted,
211        };
212        let Ok((stream, _peer)) = accepted else {
213            continue;
214        };
215        let conn_id = next_conn_id;
216        next_conn_id = next_conn_id.wrapping_add(1);
217        let ctx = Arc::clone(&shared);
218        let conn_shutdown = shutdown.clone();
219        let done_guard = done.clone();
220        ctx.metrics.connection_opened();
221        tokio::spawn(async move {
222            handle_connection(stream, &ctx, conn_id, conn_shutdown).await;
223            ctx.metrics.connection_closed();
224            drop(done_guard);
225        });
226    }
227    // Dropping `listener` stops new connections; dropping `done` lets
228    // `stop()` resolve once every connection guard is gone.
229}
230
231/// One connection: split socket, writer task behind an mpsc channel
232/// (SRV-002), sequential read loop spawning one dispatch task per request
233/// bounded by the profile's `max_in_flight` semaphore (SRV-003).
234async fn handle_connection<D: Dispatch>(
235    stream: TcpStream,
236    ctx: &ConnShared<D>,
237    conn_id: u64,
238    mut shutdown: watch::Receiver<bool>,
239) {
240    // SRV-008: disable Nagle so length-prefixed replies are not held ~40 ms
241    // by the delayed-ACK interaction documented in the Synap listener.
242    let _ = stream.set_nodelay(true);
243    let (read_half, write_half) = stream.into_split();
244    let mut reader = BufReader::new(read_half);
245
246    let (tx, rx) = mpsc::channel::<WriteJob>(WRITER_QUEUE_DEPTH);
247    let write_task = tokio::spawn(writer_task(
248        BufWriter::new(write_half),
249        rx,
250        Arc::clone(&ctx.metrics),
251        ctx.slow_threshold,
252    ));
253
254    // SRV-013 / PRO-031: the typed push channel exists only under
255    // `push = Enabled`; `Reserved` profiles can never emit.
256    let push = match ctx.profile.push {
257        PushPolicy::Enabled => Some(PushSender::new(tx.clone())),
258        PushPolicy::Reserved => None,
259    };
260    // SRV-011: a session starts ungated when the profile has no handshake
261    // at all, or when this deployment does not require credentials
262    // (`auth_required = false` — Nexus's `auth_required`, Synap's
263    // `require_auth`). Shape is the profile's; enforcement is the
264    // deployment's, and conflating them is what left the `synap` profile
265    // unable to authenticate (BN-023).
266    let starts_authenticated =
267        matches!(ctx.profile.handshake, Handshake::None) || !ctx.auth_required;
268    let session = Arc::new(Session::new(conn_id, starts_authenticated, push));
269
270    let permits = ctx.profile.max_in_flight.clamp(1, u32::MAX as usize) as u32;
271    let in_flight = Arc::new(Semaphore::new(permits as usize));
272
273    let mut first_frame = true;
274    loop {
275        let read = tokio::select! {
276            _ = shutdown.wait_for(|stop| *stop) => break,
277            read = read_next(&mut reader, ctx.profile.max_frame_bytes, ctx.idle_timeout) => read,
278        };
279        // SRV-004: EOF, a decode error, an oversized length prefix
280        // (WIRE-020, rejected before any body allocation) or the idle
281        // timeout (SRV-009) ends this read loop — this connection only,
282        // never the listener.
283        let Ok((req, in_bytes)) = read else { break };
284
285        // SRV-013 / WIRE-005: client frames carrying PUSH_ID get a
286        // dedicated refusal; the connection stays usable.
287        if req.id == PUSH_ID {
288            let response = Response::err(req.id, push_refusal_error(&ctx.profile));
289            if !send_inline(&tx, response, in_bytes).await {
290                break;
291            }
292            continue;
293        }
294
295        // SRV-011 / PRO-030: `HelloMandatory` rejects a non-HELLO first
296        // frame with the profile's error convention and closes.
297        if first_frame {
298            first_frame = false;
299            if matches!(ctx.profile.handshake, Handshake::HelloMandatory) && req.command != "HELLO"
300            {
301                let response = Response::err(req.id, hello_required_error(&ctx.profile));
302                let _ = send_inline(&tx, response, in_bytes).await;
303                break;
304            }
305        }
306
307        // Built-ins Thunder owns, handled inline so the auth flag is set
308        // before the next frame's gate check (the donor listeners
309        // serialize AUTH ahead of request tasks for the same reason).
310        match req.command.as_str() {
311            // SRV-014: HELLO replies are constructed by Thunder.
312            "HELLO" if !matches!(ctx.profile.hello_style, HelloStyle::NotUsed) => {
313                let response = handle_hello(ctx, &session, req.id, &req.args).await;
314                if !send_inline(&tx, response, in_bytes).await {
315                    break;
316                }
317                continue;
318            }
319            // SRV-012: Thunder parses, the product validates.
320            "AUTH" if matches!(ctx.profile.handshake, Handshake::AuthCommand) => {
321                let response = handle_auth(ctx, &session, req.id, &req.args).await;
322                if !send_inline(&tx, response, in_bytes).await {
323                    break;
324                }
325                continue;
326            }
327            // SRV-011 allowlist: PING answers pre-auth without product
328            // involvement; post-auth PING belongs to the product dispatch.
329            "PING" if !session.is_authenticated() => {
330                let response = builtin_ping(req.id, &req.args);
331                if !send_inline(&tx, response, in_bytes).await {
332                    break;
333                }
334                continue;
335            }
336            // AuthCommand semantics: acknowledge, then close after the write.
337            "QUIT" if matches!(ctx.profile.handshake, Handshake::AuthCommand) => {
338                let response = Response::ok(req.id, Value::Str("OK".to_owned()));
339                let _ = send_inline(&tx, response, in_bytes).await;
340                break;
341            }
342            _ => {}
343        }
344
345        // SRV-011: pre-auth gate per profile.
346        if !session.is_authenticated() {
347            match ctx.profile.handshake {
348                Handshake::None => {}
349                Handshake::AuthCommand => {
350                    if !PRE_AUTH_COMMANDS.contains(&req.command.as_str()) {
351                        let response = Response::err(req.id, NOAUTH);
352                        if !send_inline(&tx, response, in_bytes).await {
353                            break;
354                        }
355                        continue;
356                    }
357                    // Allowlisted command with no built-in under this
358                    // profile combination — falls through to dispatch.
359                }
360                Handshake::HelloMandatory => {
361                    let response = Response::err(req.id, hello_required_error(&ctx.profile));
362                    if !send_inline(&tx, response, in_bytes).await {
363                        break;
364                    }
365                    continue;
366                }
367            }
368        }
369
370        // SRV-003: one dispatch task per request, bounded by the
371        // semaphore — excess requests wait right here (backpressure on the
372        // read loop), they are never refused.
373        let Ok(permit) = Arc::clone(&in_flight).acquire_owned().await else {
374            break;
375        };
376        let dispatch = Arc::clone(&ctx.dispatch);
377        let session = Arc::clone(&session);
378        let tx = tx.clone();
379        tokio::spawn(async move {
380            let started = Instant::now();
381            let Request { id, command, args } = req;
382            let response = match dispatch.dispatch(&session, &command, args).await {
383                Ok(value) => Response::ok(id, value),
384                // SRV-005/021: the error string travels verbatim and the
385                // connection stays usable.
386                Err(message) => Response::err(id, message),
387            };
388            let _ = tx
389                .send(WriteJob::Response {
390                    response,
391                    in_bytes,
392                    duration: started.elapsed(),
393                })
394                .await;
395            // Released after the send: the drain below can rely on the
396            // queue holding every response once all permits are back.
397            drop(permit);
398        });
399    }
400
401    // Graceful drain (SRV-001/004): every dispatch task enqueues its
402    // response before releasing its permit, so once all permits return the
403    // writer's queue holds every outstanding response. The Shutdown job
404    // then stops the writer even while product-held PushSender clones (and
405    // the session's own) keep the channel open.
406    let _ = in_flight.acquire_many(permits).await;
407    let _ = tx.send(WriteJob::Shutdown).await;
408    drop(tx);
409    let _ = write_task.await;
410}
411
412/// Read one request bounded by the profile's cap (WIRE-020) and the
413/// optional per-read idle timeout (SRV-009; zero disables). The returned
414/// frame size comes from the decoder's length prefix (SRV-007).
415async fn read_next(
416    reader: &mut BufReader<OwnedReadHalf>,
417    max_frame_bytes: usize,
418    idle_timeout: Duration,
419) -> io::Result<(Request, usize)> {
420    if idle_timeout.is_zero() {
421        read_request_with_limit(reader, max_frame_bytes).await
422    } else {
423        match tokio::time::timeout(
424            idle_timeout,
425            read_request_with_limit(reader, max_frame_bytes),
426        )
427        .await
428        {
429            Ok(read) => read,
430            Err(_) => Err(io::Error::new(io::ErrorKind::TimedOut, "idle timeout")),
431        }
432    }
433}
434
435/// Enqueue a read-loop response (built-ins and gate errors carry no
436/// dispatch duration). Returns `false` when the writer is gone and the
437/// connection should close.
438async fn send_inline(tx: &mpsc::Sender<WriteJob>, response: Response, in_bytes: usize) -> bool {
439    tx.send(WriteJob::Response {
440        response,
441        in_bytes,
442        duration: Duration::ZERO,
443    })
444    .await
445    .is_ok()
446}
447
448/// The connection's writer (SRV-002/006): owns the buffered write half.
449/// After writing one job it drains every already-queued job via `try_recv`
450/// before a single `flush()` — the Synap drain-then-flush pattern that
451/// coalesces a pipelined burst into one syscall (SRV-006).
452async fn writer_task(
453    mut writer: BufWriter<OwnedWriteHalf>,
454    mut rx: mpsc::Receiver<WriteJob>,
455    metrics: Arc<Metrics>,
456    slow_threshold: Duration,
457) {
458    'outer: while let Some(job) = rx.recv().await {
459        if !write_job(&mut writer, job, &metrics, slow_threshold).await {
460            break;
461        }
462        while let Ok(job) = rx.try_recv() {
463            if !write_job(&mut writer, job, &metrics, slow_threshold).await {
464                break 'outer;
465            }
466        }
467        if writer.flush().await.is_err() {
468            break;
469        }
470    }
471    // Cover the Shutdown exit paths with frames still buffered.
472    let _ = writer.flush().await;
473}
474
475/// Encode exactly once, write, record after the successful write
476/// (SRV-007/030). Returns `false` when the writer must stop (write error
477/// or shutdown).
478async fn write_job(
479    writer: &mut BufWriter<OwnedWriteHalf>,
480    job: WriteJob,
481    metrics: &Metrics,
482    slow_threshold: Duration,
483) -> bool {
484    let (response, in_bytes, duration) = match job {
485        WriteJob::Shutdown => return false,
486        WriteJob::Push(response) => {
487            let Ok(frame) = encode_frame(&response) else {
488                return true;
489            };
490            if writer.write_all(&frame).await.is_err() {
491                return false;
492            }
493            metrics.record_push(frame.len());
494            return true;
495        }
496        WriteJob::Response {
497            response,
498            in_bytes,
499            duration,
500        } => (response, in_bytes, duration),
501    };
502    let is_error = response.result.is_err();
503    // SRV-007: the one serialization — this buffer is written and its
504    // length is the out-bytes metric. Re-encoding for metrics is banned.
505    let Ok(frame) = encode_frame(&response) else {
506        // Unencodable response: skip the frame, keep the connection (the
507        // donor listeners do the same).
508        return true;
509    };
510    if writer.write_all(&frame).await.is_err() {
511        return false;
512    }
513    // SRV-030: metrics record after the successful socket write.
514    metrics.record_command(in_bytes, frame.len(), duration, is_error, slow_threshold);
515    true
516}
517
518// ── Built-ins (SRV-011/012/014) ──────────────────────────────────────────────
519
520/// Build the HELLO reply from `ServerInfo` + config + the
521/// `authenticate`/`capabilities` hooks — Thunder's job, never application
522/// code (SRV-014). Covers both shapes pinned by the corpus handshake
523/// group: the metadata shape `{server, version, proto, id, authenticated}`
524/// (`HelloStyle::ArgLess`) and the capabilities shape
525/// `{protocol_version, capabilities}` (`HelloStyle::MapPayload`).
526async fn handle_hello<D: Dispatch>(
527    ctx: &ConnShared<D>,
528    session: &Session,
529    req_id: u32,
530    args: &[Value],
531) -> Response {
532    match ctx.profile.hello_style {
533        // Guarded by the caller; kept total for safety.
534        HelloStyle::NotUsed => {
535            Response::err(req_id, format_err("HELLO is not part of this profile"))
536        }
537        // Metadata shape: arg-less request, metadata-only reply —
538        // credentials travel via AUTH.
539        HelloStyle::ArgLess => Response::ok(
540            req_id,
541            Value::Map(vec![
542                (
543                    Value::Str("server".to_owned()),
544                    Value::Str(ctx.info.name.clone()),
545                ),
546                (
547                    Value::Str("version".to_owned()),
548                    Value::Str(ctx.info.version.clone()),
549                ),
550                (Value::Str("proto".to_owned()), Value::Int(PROTO_VERSION)),
551                (
552                    Value::Str("id".to_owned()),
553                    Value::Int(session.connection_id() as i64),
554                ),
555                (
556                    Value::Str("authenticated".to_owned()),
557                    Value::Bool(session.is_authenticated()),
558                ),
559            ]),
560        ),
561        // Capabilities shape: credentials ride in the map (SRV-012).
562        HelloStyle::MapPayload => {
563            let creds = match parse_hello_credentials(args) {
564                Ok(creds) => creds,
565                Err(message) => return Response::err(req_id, message),
566            };
567            match ctx.dispatch.authenticate(creds).await {
568                Ok(principal) => {
569                    let capabilities = ctx.dispatch.capabilities(&principal);
570                    session.set_principal(principal);
571                    Response::ok(
572                        req_id,
573                        Value::Map(vec![
574                            (
575                                Value::Str("protocol_version".to_owned()),
576                                Value::Int(PROTO_VERSION),
577                            ),
578                            (
579                                Value::Str("capabilities".to_owned()),
580                                Value::Array(capabilities.into_iter().map(Value::Str).collect()),
581                            ),
582                        ]),
583                    )
584                }
585                // A failed HELLO leaves the connection open and gated —
586                // the client may retry with better credentials.
587                Err(err) => Response::err(req_id, auth_error_string(&ctx.profile, err)),
588            }
589        }
590    }
591}
592
593/// `AUTH <api_key>` / `AUTH <user> <pass>` under `AuthCommand` (SRV-012):
594/// Thunder parses, the product validates, the session flips (SRV-010).
595async fn handle_auth<D: Dispatch>(
596    ctx: &ConnShared<D>,
597    session: &Session,
598    req_id: u32,
599    args: &[Value],
600) -> Response {
601    let creds = match args {
602        [key] => value_str(key).map(Credentials::ApiKey),
603        [user, pass] => value_str(user)
604            .zip(value_str(pass))
605            .map(|(user, pass)| Credentials::UserPass(user, pass)),
606        _ => None,
607    };
608    let Some(creds) = creds else {
609        return Response::err(req_id, format_err("invalid arguments for 'AUTH'"));
610    };
611    match ctx.dispatch.authenticate(creds).await {
612        Ok(principal) => {
613            session.set_principal(principal);
614            Response::ok(req_id, Value::Str("OK".to_owned()))
615        }
616        Err(err) => Response::err(req_id, auth_error_string(&ctx.profile, err)),
617    }
618}
619
620/// Built-in pre-auth `PING` (SRV-011 allowlist), family-pinned echo shape:
621/// bare `PING` → `"PONG"`, one string/bytes argument echoes back.
622fn builtin_ping(req_id: u32, args: &[Value]) -> Response {
623    match args {
624        [] => Response::ok(req_id, Value::Str("PONG".to_owned())),
625        [Value::Str(payload)] => Response::ok(req_id, Value::Str(payload.clone())),
626        [Value::Bytes(payload)] => Response::ok(req_id, Value::Bytes(payload.clone())),
627        [_] => Response::err(
628            req_id,
629            format_err("PING argument must be a string or bytes"),
630        ),
631        args => Response::err(
632            req_id,
633            format_err(&format!(
634                "wrong number of arguments for 'PING' ({})",
635                args.len()
636            )),
637        ),
638    }
639}
640
641/// Parse the `MapPayload` HELLO argument — a map with `version`,
642/// `token` | `api_key`, `client_name` (PRO-001). Missing credentials
643/// become [`Credentials::None`]: products with auth disabled accept them.
644fn parse_hello_credentials(args: &[Value]) -> Result<Credentials, String> {
645    let map = match args.first() {
646        None => return Ok(Credentials::None),
647        Some(map @ Value::Map(_)) => map,
648        Some(_) => return Err(format_err("HELLO expects a Map argument")),
649    };
650    if let Some(token) = map.map_get("token").and_then(value_str) {
651        Ok(Credentials::Token(token))
652    } else if let Some(key) = map.map_get("api_key").and_then(value_str) {
653        Ok(Credentials::ApiKey(key))
654    } else {
655        Ok(Credentials::None)
656    }
657}
658
659/// Extract a UTF-8 string from a credential argument (`Str`, or `Bytes`
660/// holding UTF-8 — the family's tolerant form).
661fn value_str(value: &Value) -> Option<String> {
662    match value {
663        Value::Str(text) => Some(text.clone()),
664        Value::Bytes(bytes) => String::from_utf8(bytes.clone()).ok(),
665        _ => None,
666    }
667}
668
669// ── Config-convention error strings (SRV-021, PRO-014) ─────────────────────
670
671/// Map an [`AuthError`] to the profile's convention; product-supplied
672/// messages travel verbatim (WIRE-040).
673fn auth_error_string(profile: &Config, err: AuthError) -> String {
674    match err {
675        AuthError::Message(message) => message,
676        AuthError::InvalidCredentials => match profile.error_codes {
677            ErrorConvention::BracketCode | ErrorConvention::Both => {
678                format_bracket_code("unauthorized", "invalid credentials")
679            }
680            _ => WRONGPASS.to_owned(),
681        },
682    }
683}
684
685/// The gate error for `HelloMandatory` profiles (SRV-011).
686fn hello_required_error(profile: &Config) -> String {
687    match profile.error_codes {
688        ErrorConvention::BracketCode | ErrorConvention::Both => {
689            format_bracket_code("unauthorized", "authentication required: send HELLO first")
690        }
691        _ => NOAUTH.to_owned(),
692    }
693}
694
695/// The dedicated PUSH_ID refusal (SRV-013, WIRE-005).
696fn push_refusal_error(profile: &Config) -> String {
697    const MESSAGE: &str = "request id u32::MAX is reserved for server push frames";
698    match profile.error_codes {
699        ErrorConvention::BracketCode | ErrorConvention::Both => {
700            format_bracket_code("reserved_frame_id", MESSAGE)
701        }
702        _ => format_err(MESSAGE),
703    }
704}