Skip to main content

wire/
relay_server.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//
3// Copyright (C) 2026  wire contributors.
4//
5// This file (and only this file in the wire repository) is licensed under
6// the GNU Affero General Public License v3.0 or later. The protocol crate
7// (signing, agent_card, trust, canonical, cli, mcp) is Apache-2.0; the CLI
8// binary entry point is MIT. See LICENSE.md for the trio explanation.
9//
10// AGPL on the relay specifically discourages forks that operate `wire-relay`
11// as a closed-source SaaS offering — those forks must publish their changes
12// under AGPL too. Self-hosting your own relay for your own org or running
13// the public-good relay we operate is fully permitted.
14//
15//! HTTP mailbox relay — minimal, persistent, bearer-authenticated.
16//!
17//! Design (v0.1):
18//!   - One process serves N slots; each slot is a per-peer FIFO of signed events.
19//!   - Slot allocation returns `(slot_id, slot_token)`. Holder of the token
20//!     can post + read that slot. Tokens never expire in v0.1 (rotate by
21//!     allocating a new slot).
22//!   - Events are stored verbatim — the relay does NOT verify Ed25519 signatures
23//!     itself. Verification happens client-side (`wire tail` + `verify_message_v31`).
24//!     The relay is dumb on purpose: it is a content-addressed mailbox, not a
25//!     trust authority.
26//!   - 256 KiB max body per event.
27//!   - Persistence: each slot's events are appended to
28//!     `<state_dir>/slots/<slot_id>.jsonl` on every `POST /events`. Tokens
29//!     are persisted to `<state_dir>/tokens.json` on allocation.
30//!   - On startup, slots + tokens are reloaded from disk.
31
32use anyhow::{Context, Result};
33use axum::{
34    Json, Router,
35    extract::{Path, Query, State},
36    http::{HeaderMap, StatusCode, header::AUTHORIZATION},
37    response::IntoResponse,
38    routing::{delete, get, post},
39};
40use rand::RngCore;
41use serde::Deserialize;
42use serde_json::{Value, json};
43use std::collections::HashMap;
44use std::path::PathBuf;
45use std::sync::Arc;
46use std::sync::atomic::{AtomicU64, Ordering};
47use std::time::{SystemTime, UNIX_EPOCH};
48use tokio::sync::Mutex;
49use tower_governor::{
50    GovernorLayer, governor::GovernorConfigBuilder, key_extractor::GlobalKeyExtractor,
51};
52
53const MAX_EVENT_BYTES: usize = 256 * 1024;
54/// Total bytes a single slot can hold before further POSTs are rejected (413).
55/// Defends against an abusive bearer-holder filling relay disk (T11). At 64 MB
56/// per slot, an attacker pushing the rate-limit ceiling fills their own slot
57/// in ~25 seconds, then gets 413 forever — disk impact bounded.
58const MAX_SLOT_BYTES: usize = 64 * 1024 * 1024;
59
60/// Per-nick `/v1/handle/intro` rate limit (#247.3): at most this many intros to
61/// a given nick within `INTRO_WINDOW_SECS`, else 429. Pair-intro is a rare,
62/// human-paced action (you dial a peer once), so a tight cap is safe and stops
63/// an unauthenticated flood from filling the victim's slot to MAX_SLOT_BYTES.
64const INTRO_MAX_PER_WINDOW: usize = 5;
65const INTRO_WINDOW_SECS: u64 = 300;
66
67/// Backstop ceilings on the COUNT of in-memory objects (audit H1 / #291).
68/// Generous — they don't bind a healthy relay — but stop an attacker from
69/// allocating millions of slots/handles/pairs/invites to exhaust RAM + disk
70/// (every allocation also rewrites a persistence file). `503` when reached.
71const MAX_SLOTS: usize = 200_000;
72const MAX_HANDLES: usize = 100_000;
73const MAX_PAIR_SLOTS: usize = 50_000;
74const MAX_INVITES: usize = 50_000;
75/// Cap concurrent SSE subscribers per slot (audit). An authenticated slot-token
76/// holder can otherwise open unbounded `GET /v1/events/:slot_id/stream`
77/// connections, each a channel that every `post_event` fans out to (O(n)
78/// broadcast) — memory + latency DoS. Over the cap → 503.
79const MAX_STREAMS_PER_SLOT: usize = 256;
80/// Soft cap on nicks tracked in `intro_times`; once exceeded, fully-aged entries
81/// are swept so the map can't accumulate one stale entry per ever-touched nick
82/// (#247.3 hygiene; growth is already bounded by `MAX_HANDLES`).
83const MAX_INTRO_TRACKING_NICKS: usize = 10_000;
84
85/// True iff a map at `len` entries is at/over the `max` ceiling — i.e. a new
86/// allocation must be refused. Pure → unit-tested (#291 H1).
87fn at_capacity(len: usize, max: usize) -> bool {
88    len >= max
89}
90
91/// Drop subscriber senders whose receiver (the SSE client) has disconnected.
92/// `is_closed()` becomes true once the `UnboundedReceiverStream` is dropped on
93/// client disconnect. `post_event` only prunes lazily on its next broadcast, so
94/// a slot that goes silent would otherwise leak dead senders forever; pruning at
95/// stream admission bounds it. Pure → unit-tested.
96fn retain_live_subscribers(subs: &mut Vec<tokio::sync::mpsc::UnboundedSender<Value>>) {
97    subs.retain(|tx| !tx.is_closed());
98}
99
100/// Wall-clock unix seconds (best-effort; 0 on a pre-epoch clock).
101fn unix_now() -> u64 {
102    SystemTime::now()
103        .duration_since(UNIX_EPOCH)
104        .map(|d| d.as_secs())
105        .unwrap_or(0)
106}
107
108/// Per-nick intro rate gate (#247.3). Prunes `times` to entries within `window`
109/// of `now`, then: if `>= max` remain it's over the limit → return `false`
110/// (reject); otherwise record `now` and return `true` (allow). Pure → unit-tested.
111fn record_intro_within_rate(times: &mut Vec<u64>, now: u64, window: u64, max: usize) -> bool {
112    times.retain(|t| now.saturating_sub(*t) < window);
113    if times.len() >= max {
114        return false;
115    }
116    times.push(now);
117    true
118}
119
120/// Drop nicks from `times_by_nick` whose intro timestamps have all aged past
121/// `window` relative to `now`. Bounds `intro_times` growth (#247.3 hygiene).
122/// Pure → unit-tested.
123fn evict_stale_intro_nicks(times_by_nick: &mut HashMap<String, Vec<u64>>, now: u64, window: u64) {
124    times_by_nick.retain(|_, ts| ts.iter().any(|t| now.saturating_sub(*t) < window));
125}
126
127#[derive(Clone)]
128pub struct Relay {
129    inner: Arc<Mutex<Inner>>,
130    state_dir: PathBuf,
131    counters: Arc<RelayCounters>,
132}
133
134/// Lock-free usage counters served by GET /stats. Counter values are
135/// loaded from `<state_dir>/counters.json` on startup and snapshotted back
136/// to disk every 30s by `spawn_counter_persister`, so deploys + restarts
137/// don't reset them. `boot_unix` is per-process — uptime is process-local.
138struct RelayCounters {
139    boot_unix: u64,
140    handle_claims_total: AtomicU64,
141    handle_first_claims_total: AtomicU64,
142    slot_allocations_total: AtomicU64,
143    pair_opens_total: AtomicU64,
144    events_posted_total: AtomicU64,
145}
146
147#[derive(serde::Serialize, serde::Deserialize, Default)]
148struct CountersSnapshot {
149    handle_claims_total: u64,
150    handle_first_claims_total: u64,
151    slot_allocations_total: u64,
152    pair_opens_total: u64,
153    events_posted_total: u64,
154}
155
156/// One row in `<state_dir>/stats-history.jsonl` — written every 30s by
157/// `spawn_counter_persister` so /stats.html can draw sparklines. Live-state
158/// fields (`*_active`) are point-in-time; *_total fields are the cumulative
159/// counters at that timestamp. Field names mirror the /stats endpoint.
160#[derive(serde::Serialize, serde::Deserialize)]
161struct HistoryEntry {
162    ts: u64,
163    handles_active: usize,
164    slots_active: usize,
165    pair_slots_open: usize,
166    streams_active: usize,
167    handle_claims_total: u64,
168    handle_first_claims_total: u64,
169    slot_allocations_total: u64,
170    pair_opens_total: u64,
171    events_posted_total: u64,
172}
173
174#[derive(Deserialize)]
175pub struct StatsHistoryQuery {
176    /// How many hours of history to return, default 24, max 168 (7 days).
177    pub hours: Option<u64>,
178}
179
180struct Inner {
181    /// slot_id -> ordered list of stored events (parsed JSON Values).
182    slots: HashMap<String, Vec<Value>>,
183    /// slot_id -> bearer token. Token holder may read + write that slot.
184    tokens: HashMap<String, String>,
185    /// slot_id -> total bytes stored. Enforced against MAX_SLOT_BYTES.
186    slot_bytes: HashMap<String, usize>,
187    /// slot_id -> wall-clock unix seconds of the slot owner's last `list_events`
188    /// call. Used by `GET /v1/slot/:slot_id/state` so a remote sender can
189    /// gauge whether the slot's owner is still polling (i.e., still attentive).
190    /// `None` means the slot has never been pulled since the relay restarted.
191    last_pull_at_unix: HashMap<String, u64>,
192    /// slot_id -> active SSE subscribers (R1 push). Each `UnboundedSender`
193    /// belongs to one open `GET /v1/events/:slot_id/stream` connection.
194    /// On every successful `post_event` to a slot we walk the slot's list
195    /// and broadcast the event; closed channels are pruned lazily on send-
196    /// error. Auth: subscribers presented a valid slot_token at stream open.
197    streams: HashMap<String, Vec<tokio::sync::mpsc::UnboundedSender<Value>>>,
198    /// code_hash -> pair_id (lookup so guests find the host).
199    pair_lookup: HashMap<String, String>,
200    /// pair_id -> ephemeral pairing state.
201    pair_slots: HashMap<String, PairSlot>,
202    /// nick -> registered handle directory entry (v0.5).
203    handles: HashMap<String, HandleRecord>,
204    /// slot_id -> latest operator-published auto-responder health record (R3).
205    responder_health: HashMap<String, ResponderHealthRecord>,
206    /// token -> short-URL invite record (v0.5.10 — one-curl onboarding).
207    /// Token is the path segment in `GET /i/{token}`. Record holds the
208    /// underlying `wire://pair?...` URL plus TTL/uses bookkeeping.
209    invites: HashMap<String, InviteRecord>,
210    /// nick -> unix-second timestamps of recent `/v1/handle/intro` deliveries
211    /// (#247.3). Pruned to the rate-limit window on each access; bounds the
212    /// unauthenticated pair-intro flood that could otherwise fill a victim's
213    /// slot to `MAX_SLOT_BYTES`.
214    intro_times: HashMap<String, Vec<u64>>,
215}
216
217/// One entry in the short-URL invite map. Persisted to
218/// `<state_dir>/invites.jsonl` so deploys don't drop active invites.
219#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
220struct InviteRecord {
221    token: String,
222    invite_url: String,
223    expires_unix: u64,
224    /// `None` = unlimited until TTL hits. `Some(n)` = decrement each fetch.
225    uses_remaining: Option<u32>,
226    created_unix: u64,
227}
228
229/// One entry in the relay's handle directory (v0.5 — agentic hotline).
230/// FCFS on nick: first claimant binds the nick to their DID. Same-DID re-claims
231/// are allowed (used for profile updates + slot rotation).
232#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
233struct HandleRecord {
234    pub nick: String,
235    pub did: String,
236    pub card: Value,
237    pub slot_id: String,
238    pub relay_url: Option<String>,
239    pub claimed_at: String,
240    /// v0.5.19 (#9.1): if false, this handle is omitted from the
241    /// `/v1/handles` directory listing — operator opted out of bulk
242    /// discovery. The `.well-known/wire/agent?handle=X` direct lookup
243    /// still resolves so existing peers + out-of-band sharing continue
244    /// to work.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub discoverable: Option<bool>,
247}
248
249impl HandleRecord {
250    fn is_discoverable(&self) -> bool {
251        self.discoverable.unwrap_or(true)
252    }
253}
254
255#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
256pub struct ResponderHealthRecord {
257    pub status: String,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub reason: Option<String>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub last_success_at: Option<String>,
262    pub set_at: String,
263}
264
265#[derive(Clone, Debug)]
266struct PairSlot {
267    /// SPAKE2 message from the host side.
268    host_msg: Option<String>,
269    /// SPAKE2 message from the guest side.
270    guest_msg: Option<String>,
271    /// Sealed bootstrap payload from host (after SAS confirm).
272    host_bootstrap: Option<String>,
273    /// Sealed bootstrap payload from guest.
274    guest_bootstrap: Option<String>,
275    /// Last activity time (monotonic) — used for TTL eviction.
276    last_touched: std::time::Instant,
277}
278
279impl Default for PairSlot {
280    fn default() -> Self {
281        Self {
282            host_msg: None,
283            guest_msg: None,
284            host_bootstrap: None,
285            guest_bootstrap: None,
286            last_touched: std::time::Instant::now(),
287        }
288    }
289}
290
291/// Pair-slot idle TTL. After this many seconds without activity, the slot
292/// is evicted to free memory + bound brute-force surface (PENTEST.md §code-review #3).
293const PAIR_SLOT_TTL_SECS: u64 = 300;
294
295#[derive(Deserialize)]
296pub struct AllocateRequest {
297    /// Optional handle hint — purely informational, server doesn't enforce.
298    #[serde(default)]
299    pub handle: Option<String>,
300}
301
302#[derive(Deserialize)]
303pub struct PostEventRequest {
304    pub event: Value,
305}
306
307#[derive(Deserialize)]
308pub struct ListEventsQuery {
309    /// Resume from after this event_id (exclusive). Omit for full slot read.
310    pub since: Option<String>,
311    /// Max events to return. Default 100, max 1000.
312    pub limit: Option<usize>,
313}
314
315impl Relay {
316    pub async fn new(state_dir: PathBuf) -> Result<Self> {
317        tokio::fs::create_dir_all(state_dir.join("slots")).await?;
318        tokio::fs::create_dir_all(state_dir.join("handles")).await?;
319        tokio::fs::create_dir_all(state_dir.join("responder-health")).await?;
320        let mut inner = Inner {
321            slots: HashMap::new(),
322            tokens: HashMap::new(),
323            slot_bytes: HashMap::new(),
324            last_pull_at_unix: HashMap::new(),
325            streams: HashMap::new(),
326            pair_lookup: HashMap::new(),
327            pair_slots: HashMap::new(),
328            handles: HashMap::new(),
329            responder_health: HashMap::new(),
330            invites: HashMap::new(),
331            intro_times: HashMap::new(),
332        };
333        // Reload tokens
334        let token_path = state_dir.join("tokens.json");
335        if token_path.exists() {
336            let body = tokio::fs::read_to_string(&token_path).await?;
337            inner.tokens = serde_json::from_str(&body).unwrap_or_default();
338        }
339        // Reload slots from JSONL
340        let mut slots_dir = tokio::fs::read_dir(state_dir.join("slots")).await?;
341        while let Some(entry) = slots_dir.next_entry().await? {
342            let path = entry.path();
343            if path.extension().map(|x| x != "jsonl").unwrap_or(true) {
344                continue;
345            }
346            let stem = match path.file_stem().and_then(|s| s.to_str()) {
347                Some(s) => s.to_string(),
348                None => continue,
349            };
350            let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
351            let mut events = Vec::new();
352            for line in body.lines() {
353                if let Ok(v) = serde_json::from_str::<Value>(line) {
354                    events.push(v);
355                }
356            }
357            // Recompute byte usage for the slot from its persisted events.
358            let bytes: usize = events
359                .iter()
360                .map(|e| serde_json::to_vec(e).map(|v| v.len()).unwrap_or(0))
361                .sum();
362            inner.slot_bytes.insert(stem.clone(), bytes);
363            inner.slots.insert(stem, events);
364        }
365        // Reload handle directory (v0.5).
366        let handles_dir = state_dir.join("handles");
367        if handles_dir.exists() {
368            let mut rd = tokio::fs::read_dir(&handles_dir).await?;
369            while let Some(entry) = rd.next_entry().await? {
370                let path = entry.path();
371                if path.extension().and_then(|x| x.to_str()) != Some("json") {
372                    continue;
373                }
374                let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
375                if let Ok(rec) = serde_json::from_str::<HandleRecord>(&body) {
376                    inner.handles.insert(rec.nick.clone(), rec);
377                }
378            }
379        }
380        // Reload responder health records (R3).
381        let responder_health_dir = state_dir.join("responder-health");
382        if responder_health_dir.exists() {
383            let mut rd = tokio::fs::read_dir(&responder_health_dir).await?;
384            while let Some(entry) = rd.next_entry().await? {
385                let path = entry.path();
386                if path.extension().and_then(|x| x.to_str()) != Some("json") {
387                    continue;
388                }
389                let Some(slot_id) = path.file_stem().and_then(|s| s.to_str()) else {
390                    continue;
391                };
392                let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
393                if let Ok(rec) = serde_json::from_str::<ResponderHealthRecord>(&body) {
394                    inner.responder_health.insert(slot_id.to_string(), rec);
395                }
396            }
397        }
398        // Reload short-URL invites. JSONL append-only; later entries with
399        // the same token overwrite earlier (won't happen — tokens are
400        // unique by construction — but coded defensively).
401        let invites_path = state_dir.join("invites.jsonl");
402        if invites_path.exists() {
403            let now_unix = SystemTime::now()
404                .duration_since(UNIX_EPOCH)
405                .map(|d| d.as_secs())
406                .unwrap_or(0);
407            let body = tokio::fs::read_to_string(&invites_path)
408                .await
409                .unwrap_or_default();
410            for line in body.lines() {
411                if let Ok(rec) = serde_json::from_str::<InviteRecord>(line)
412                    && rec.expires_unix > now_unix
413                {
414                    inner.invites.insert(rec.token.clone(), rec);
415                }
416            }
417        }
418        let boot_unix = SystemTime::now()
419            .duration_since(UNIX_EPOCH)
420            .map(|d| d.as_secs())
421            .unwrap_or(0);
422        // Reload counter snapshot. Missing/corrupt file → start at zero.
423        let snap: CountersSnapshot =
424            match tokio::fs::read_to_string(state_dir.join("counters.json")).await {
425                Ok(body) => serde_json::from_str(&body).unwrap_or_default(),
426                Err(_) => CountersSnapshot::default(),
427            };
428        Ok(Self {
429            inner: Arc::new(Mutex::new(inner)),
430            state_dir,
431            counters: Arc::new(RelayCounters {
432                boot_unix,
433                handle_claims_total: AtomicU64::new(snap.handle_claims_total),
434                handle_first_claims_total: AtomicU64::new(snap.handle_first_claims_total),
435                slot_allocations_total: AtomicU64::new(snap.slot_allocations_total),
436                pair_opens_total: AtomicU64::new(snap.pair_opens_total),
437                events_posted_total: AtomicU64::new(snap.events_posted_total),
438            }),
439        })
440    }
441
442    pub fn router(self) -> Router {
443        self.router_with_mode(ServerMode::default())
444    }
445
446    pub fn router_with_mode(self, mode: ServerMode) -> Router {
447        // Rate limit applied to write endpoints that create new state (slots,
448        // pair-sessions, bootstraps). 10 req/sec sustained, 50 req burst.
449        // v0.1 uses the GLOBAL key extractor (single bucket for all callers) —
450        // per-IP needs ConnectInfo middleware which axum 0.7 wires differently.
451        // Per-IP keying is a v0.2 hardening; for now Cloudflare WAF + this
452        // global cap shoulder DDoS protection in series.
453        let governor_conf = std::sync::Arc::new(
454            GovernorConfigBuilder::default()
455                .per_second(10)
456                .burst_size(50)
457                .key_extractor(GlobalKeyExtractor)
458                .finish()
459                .expect("valid governor config"),
460        );
461        let governor_layer = GovernorLayer {
462            config: governor_conf,
463        };
464
465        // Hot writes group — rate limited.
466        let hot_writes = Router::new()
467            .route("/v1/slot/allocate", post(allocate_slot))
468            .route("/v1/pair", post(pair_open))
469            .route("/v1/pair/:pair_id/bootstrap", post(pair_bootstrap))
470            .route("/v1/pair/abandon", post(pair_abandon))
471            .layer(governor_layer);
472
473        // Core data-plane routes: events, pair, slots, healthz. Always
474        // present, in both federation and local-only modes.
475        let mut router = Router::new()
476            .route("/healthz", get(healthz))
477            .route("/v1/events/:slot_id", post(post_event).get(list_events))
478            .route("/v1/slot/:slot_id/state", get(slot_state))
479            .route(
480                "/v1/slot/:slot_id/responder-health",
481                post(responder_health_set),
482            )
483            .route("/v1/events/:slot_id/stream", get(stream_events))
484            .route("/v1/pair/:pair_id", get(pair_get))
485            .route("/v1/handle/intro/:nick", post(handle_intro));
486
487        // Discovery + landing surfaces: phonebook, well-known agent cards,
488        // landing page, stats, invite landing. In `--local-only` mode we
489        // skip all of these — the relay becomes invisible from the outside
490        // (and from other agents on the same box that might enumerate it).
491        // This is the v0.5.17 within-machine privacy guarantee.
492        if !mode.local_only {
493            router = router
494                .route("/", get(landing_index))
495                .route("/favicon.svg", get(landing_favicon))
496                .route("/og.png", get(landing_og))
497                .route("/install", get(landing_install_sh))
498                .route("/install.sh", get(landing_install_sh))
499                .route("/openshell-policy.sh", get(landing_openshell_policy_sh))
500                .route("/stats", get(stats_root))
501                .route("/stats.json", get(stats_json))
502                .route("/stats.html", get(landing_stats_html))
503                .route("/stats.history", get(stats_history))
504                .route("/phonebook", get(landing_phonebook_html))
505                .route("/phonebook.html", get(landing_phonebook_html))
506                .route("/v1/handle/claim", post(handle_claim))
507                .route("/v1/handle/claim/:nick", delete(handle_unclaim))
508                .route("/v1/handles", get(handles_directory))
509                .route("/v1/invite/register", post(invite_register))
510                .route("/i/:token", get(invite_script))
511                .route("/.well-known/wire/agent", get(well_known_agent))
512                .route(
513                    "/.well-known/agent-card.json",
514                    get(well_known_agent_card_a2a),
515                );
516        } else {
517            // Local-only mode still needs handle_claim for in-process
518            // session bootstrap (`wire session new` allocates a local
519            // slot AND claims a handle on the local relay so peers can
520            // resolve it). The claim is gated to loopback-only callers
521            // by the bind, not by the route.
522            router = router
523                .route("/v1/handle/claim", post(handle_claim))
524                .route("/v1/handle/claim/:nick", delete(handle_unclaim));
525        }
526
527        router.merge(hot_writes).with_state(self)
528    }
529
530    /// Evict pair-slots that have been idle past `PAIR_SLOT_TTL_SECS`.
531    /// Called inline on every pair-slot mutation; a background sweeper task
532    /// (see `Self::start_sweeper`) covers the long-idle case.
533    async fn evict_expired_pair_slots(&self) {
534        let now = std::time::Instant::now();
535        let ttl = std::time::Duration::from_secs(PAIR_SLOT_TTL_SECS);
536        let mut inner = self.inner.lock().await;
537        let mut to_remove = Vec::new();
538        for (id, slot) in inner.pair_slots.iter() {
539            if now.duration_since(slot.last_touched) > ttl {
540                to_remove.push(id.clone());
541            }
542        }
543        for id in to_remove {
544            inner.pair_slots.remove(&id);
545            inner.pair_lookup.retain(|_, v| v != &id);
546        }
547    }
548
549    /// Spawn a background tokio task that runs `evict_expired_pair_slots` every
550    /// 60 seconds. Call once after `Relay::new`; the handle is leaked deliberately
551    /// — process exit reaps it. Safe to skip in tests where you'd rather test
552    /// eviction inline.
553    /// Proactively drop fully-aged nicks from `intro_times` on the background
554    /// tick, so the map rarely reaches the on-demand sweep threshold in
555    /// `handle_intro` — that keeps the 10k-entry `retain` scan off the hot,
556    /// globally-locked intro path (where it would block every other handler).
557    /// The on-demand sweep stays as a backstop, so the bound is unchanged.
558    async fn sweep_intro_times(&self) {
559        let now = unix_now();
560        let mut inner = self.inner.lock().await;
561        evict_stale_intro_nicks(&mut inner.intro_times, now, INTRO_WINDOW_SECS);
562    }
563
564    pub fn spawn_pair_sweeper(&self) {
565        let me = self.clone();
566        tokio::spawn(async move {
567            let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
568            loop {
569                tick.tick().await;
570                me.evict_expired_pair_slots().await;
571                me.sweep_intro_times().await;
572            }
573        });
574    }
575
576    /// Snapshot the in-process counters to `<state_dir>/counters.json`. Called
577    /// every 30s by `spawn_counter_persister` and once during graceful
578    /// shutdown so a deploy doesn't reset the running totals.
579    pub async fn persist_counters(&self) -> Result<()> {
580        let snap = CountersSnapshot {
581            handle_claims_total: self.counters.handle_claims_total.load(Ordering::Relaxed),
582            handle_first_claims_total: self
583                .counters
584                .handle_first_claims_total
585                .load(Ordering::Relaxed),
586            slot_allocations_total: self.counters.slot_allocations_total.load(Ordering::Relaxed),
587            pair_opens_total: self.counters.pair_opens_total.load(Ordering::Relaxed),
588            events_posted_total: self.counters.events_posted_total.load(Ordering::Relaxed),
589        };
590        let body = serde_json::to_vec_pretty(&snap)?;
591        let path = self.state_dir.join("counters.json");
592        tokio::fs::write(path, body).await?;
593        Ok(())
594    }
595
596    /// Append one row to `<state_dir>/stats-history.jsonl` mirroring the
597    /// /stats endpoint at this instant. Used by /stats.html for sparklines.
598    /// File grows ~250 B per call → ~720 KB/day. A future prune wave can
599    /// roll old entries off once the history exceeds 90 days.
600    pub async fn append_history(&self) -> Result<()> {
601        use tokio::io::AsyncWriteExt;
602        let now = SystemTime::now()
603            .duration_since(UNIX_EPOCH)
604            .map(|d| d.as_secs())
605            .unwrap_or(0);
606        let (handles_active, slots_active, pair_slots_open, streams_active) = {
607            let inner = self.inner.lock().await;
608            (
609                inner.handles.len(),
610                inner.slots.len(),
611                inner.pair_slots.len(),
612                inner.streams.values().map(Vec::len).sum::<usize>(),
613            )
614        };
615        let entry = HistoryEntry {
616            ts: now,
617            handles_active,
618            slots_active,
619            pair_slots_open,
620            streams_active,
621            handle_claims_total: self.counters.handle_claims_total.load(Ordering::Relaxed),
622            handle_first_claims_total: self
623                .counters
624                .handle_first_claims_total
625                .load(Ordering::Relaxed),
626            slot_allocations_total: self.counters.slot_allocations_total.load(Ordering::Relaxed),
627            pair_opens_total: self.counters.pair_opens_total.load(Ordering::Relaxed),
628            events_posted_total: self.counters.events_posted_total.load(Ordering::Relaxed),
629        };
630        let line = serde_json::to_vec(&entry)?;
631        let path = self.state_dir.join("stats-history.jsonl");
632        let mut f = tokio::fs::OpenOptions::new()
633            .create(true)
634            .append(true)
635            .open(&path)
636            .await?;
637        f.write_all(&line).await?;
638        f.write_all(b"\n").await?;
639        f.flush().await?;
640        Ok(())
641    }
642
643    /// Spawn a background tokio task that calls `persist_counters` every 30s
644    /// + appends a history row on the same tick. Loss bound: counters can
645    ///   drift back up to 30s on crash, history can drop one row.
646    pub fn spawn_counter_persister(&self) {
647        let me = self.clone();
648        tokio::spawn(async move {
649            let mut tick = tokio::time::interval(std::time::Duration::from_secs(30));
650            // First tick fires immediately; skip it so we don't write the
651            // freshly-loaded snapshot back unchanged.
652            tick.tick().await;
653            loop {
654                tick.tick().await;
655                if let Err(e) = me.persist_counters().await {
656                    eprintln!("counter persist failed: {e}");
657                }
658                if let Err(e) = me.append_history().await {
659                    eprintln!("history append failed: {e}");
660                }
661            }
662        });
663    }
664
665    async fn persist_tokens(&self) -> Result<()> {
666        let body = {
667            let inner = self.inner.lock().await;
668            serde_json::to_string_pretty(&inner.tokens)?
669        };
670        let path = self.state_dir.join("tokens.json");
671        tokio::fs::write(path, body).await?;
672        Ok(())
673    }
674
675    async fn append_event_to_disk(&self, slot_id: &str, event: &Value) -> Result<()> {
676        // Defense in depth: only allow lowercase hex slot_ids of the exact length
677        // we ever produce ourselves (16 random bytes -> 32 hex chars). Blocks
678        // any future code path that might let attacker-controlled slot_ids reach
679        // disk operations. allocate_slot() always meets this; this assert is
680        // belt-and-suspenders against future regressions.
681        if !is_valid_slot_id(slot_id) {
682            return Err(anyhow::anyhow!("invalid slot_id format: {slot_id:?}"));
683        }
684        let path = self
685            .state_dir
686            .join("slots")
687            .join(format!("{slot_id}.jsonl"));
688        let mut line = serde_json::to_vec(event)?;
689        line.push(b'\n');
690        use tokio::io::AsyncWriteExt;
691        let mut f = tokio::fs::OpenOptions::new()
692            .create(true)
693            .append(true)
694            .open(&path)
695            .await
696            .with_context(|| format!("opening {path:?}"))?;
697        f.write_all(&line).await?;
698        f.flush().await?;
699        Ok(())
700    }
701}
702
703async fn healthz() -> impl IntoResponse {
704    (StatusCode::OK, "ok\n")
705}
706
707// Public aggregate-usage snapshot. Counter fields (`*_total`) reset on
708// process restart; state fields (`handles_active`, `slots_active`) survive
709// on the persistent volume. No DIDs / handles / IPs leaked — counts only.
710async fn stats_history(
711    State(relay): State<Relay>,
712    Query(q): Query<StatsHistoryQuery>,
713) -> impl IntoResponse {
714    let hours = q.hours.unwrap_or(24).min(168);
715    let now = SystemTime::now()
716        .duration_since(UNIX_EPOCH)
717        .map(|d| d.as_secs())
718        .unwrap_or(0);
719    let cutoff = now.saturating_sub(hours * 3600);
720    let path = relay.state_dir.join("stats-history.jsonl");
721    let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
722    let entries: Vec<Value> = body
723        .lines()
724        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
725        .filter(|v| {
726            v.get("ts")
727                .and_then(Value::as_u64)
728                .map(|t| t >= cutoff)
729                .unwrap_or(false)
730        })
731        .collect();
732    (
733        StatusCode::OK,
734        Json(json!({
735            "hours": hours,
736            "now_unix": now,
737            "count": entries.len(),
738            "entries": entries,
739        })),
740    )
741}
742
743async fn landing_stats_html() -> impl IntoResponse {
744    static STATS_HTML: &[u8] = include_bytes!("../landing/stats.html");
745    (
746        StatusCode::OK,
747        [
748            (axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
749            (axum::http::header::CACHE_CONTROL, "public, max-age=60"),
750        ],
751        STATS_HTML,
752    )
753}
754
755async fn landing_phonebook_html() -> impl IntoResponse {
756    static PHONEBOOK_HTML: &[u8] = include_bytes!("../landing/phonebook.html");
757    (
758        StatusCode::OK,
759        [
760            (axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
761            (axum::http::header::CACHE_CONTROL, "public, max-age=60"),
762        ],
763        PHONEBOOK_HTML,
764    )
765}
766
767/// `/stats` dispatch: serve the pretty HTML dashboard to browsers (Accept
768/// includes text/html) and JSON to everything else (curl, scripts, scrapers).
769/// Keeps the JSON contract intact while letting humans land on the page at
770/// the short URL.
771async fn stats_root(State(relay): State<Relay>, headers: HeaderMap) -> axum::response::Response {
772    let wants_html = headers
773        .get(axum::http::header::ACCEPT)
774        .and_then(|v| v.to_str().ok())
775        .unwrap_or("")
776        .contains("text/html");
777    if wants_html {
778        landing_stats_html().await.into_response()
779    } else {
780        stats_json(State(relay)).await.into_response()
781    }
782}
783
784async fn stats_json(State(relay): State<Relay>) -> impl IntoResponse {
785    let now = SystemTime::now()
786        .duration_since(UNIX_EPOCH)
787        .map(|d| d.as_secs())
788        .unwrap_or(0);
789    let inner = relay.inner.lock().await;
790    let streams_active: usize = inner.streams.values().map(Vec::len).sum();
791    let body = json!({
792        "version": env!("CARGO_PKG_VERSION"),
793        "uptime_seconds": now.saturating_sub(relay.counters.boot_unix),
794        "handles_active": inner.handles.len(),
795        "slots_active": inner.slots.len(),
796        "pair_slots_open": inner.pair_slots.len(),
797        "streams_active": streams_active,
798        "handle_claims_total": relay.counters.handle_claims_total.load(Ordering::Relaxed),
799        "handle_first_claims_total": relay.counters.handle_first_claims_total.load(Ordering::Relaxed),
800        "slot_allocations_total": relay.counters.slot_allocations_total.load(Ordering::Relaxed),
801        "pair_opens_total": relay.counters.pair_opens_total.load(Ordering::Relaxed),
802        "events_posted_total": relay.counters.events_posted_total.load(Ordering::Relaxed),
803    });
804    (StatusCode::OK, Json(body))
805}
806
807// Static landing site baked into the binary so apex (wireup.net) can flip
808// straight to Fly without a separate static-host. ~37 KB total — negligible
809// against the release binary size, and keeps the relay self-contained.
810async fn landing_index() -> impl IntoResponse {
811    static INDEX_HTML: &[u8] = include_bytes!("../landing/index.html");
812    (
813        StatusCode::OK,
814        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
815        INDEX_HTML,
816    )
817}
818
819async fn landing_favicon() -> impl IntoResponse {
820    static FAVICON_SVG: &[u8] = include_bytes!("../landing/favicon.svg");
821    (
822        StatusCode::OK,
823        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
824        FAVICON_SVG,
825    )
826}
827
828async fn landing_og() -> impl IntoResponse {
829    static OG_PNG: &[u8] = include_bytes!("../landing/og.png");
830    (
831        StatusCode::OK,
832        [
833            (axum::http::header::CONTENT_TYPE, "image/png"),
834            (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
835        ],
836        OG_PNG,
837    )
838}
839
840async fn landing_install_sh() -> impl IntoResponse {
841    static INSTALL_SH: &[u8] = include_bytes!("../landing/install.sh");
842    (
843        StatusCode::OK,
844        [
845            (
846                axum::http::header::CONTENT_TYPE,
847                "text/x-shellscript; charset=utf-8",
848            ),
849            (axum::http::header::CACHE_CONTROL, "public, max-age=300"),
850        ],
851        INSTALL_SH,
852    )
853}
854
855async fn landing_openshell_policy_sh() -> impl IntoResponse {
856    static POLICY_SH: &[u8] = include_bytes!("../landing/openshell-policy.sh");
857    (
858        StatusCode::OK,
859        [
860            (
861                axum::http::header::CONTENT_TYPE,
862                "text/x-shellscript; charset=utf-8",
863            ),
864            (axum::http::header::CACHE_CONTROL, "public, max-age=300"),
865        ],
866        POLICY_SH,
867    )
868}
869
870async fn allocate_slot(
871    State(relay): State<Relay>,
872    Json(_req): Json<AllocateRequest>,
873) -> impl IntoResponse {
874    let slot_id = random_hex(16);
875    let slot_token = random_hex(32);
876    {
877        let mut inner = relay.inner.lock().await;
878        if at_capacity(inner.slots.len(), MAX_SLOTS) {
879            return (
880                StatusCode::SERVICE_UNAVAILABLE,
881                Json(json!({"error": "relay slot capacity reached"})),
882            )
883                .into_response();
884        }
885        inner.slots.insert(slot_id.clone(), Vec::new());
886        inner.tokens.insert(slot_id.clone(), slot_token.clone());
887    }
888    if let Err(e) = relay.persist_tokens().await {
889        return (
890            StatusCode::INTERNAL_SERVER_ERROR,
891            Json(json!({"error": format!("persist failed: {e}")})),
892        )
893            .into_response();
894    }
895    relay
896        .counters
897        .slot_allocations_total
898        .fetch_add(1, Ordering::Relaxed);
899    (
900        StatusCode::CREATED,
901        Json(json!({"slot_id": slot_id, "slot_token": slot_token})),
902    )
903        .into_response()
904}
905
906async fn post_event(
907    State(relay): State<Relay>,
908    Path(slot_id): Path<String>,
909    headers: HeaderMap,
910    Json(req): Json<PostEventRequest>,
911) -> impl IntoResponse {
912    if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
913        return resp;
914    }
915    // Body size cap (rough; serialize and check).
916    let body_bytes = match serde_json::to_vec(&req.event) {
917        Ok(b) => b,
918        Err(e) => {
919            return (
920                StatusCode::BAD_REQUEST,
921                Json(json!({"error": format!("event not serializable: {e}")})),
922            )
923                .into_response();
924        }
925    };
926    if body_bytes.len() > MAX_EVENT_BYTES {
927        return (
928            StatusCode::PAYLOAD_TOO_LARGE,
929            Json(json!({"error": "event exceeds 256 KiB", "max_bytes": MAX_EVENT_BYTES})),
930        )
931            .into_response();
932    }
933    // Per-slot quota: cap accumulated bytes per slot at MAX_SLOT_BYTES.
934    {
935        let inner = relay.inner.lock().await;
936        let used = inner.slot_bytes.get(&slot_id).copied().unwrap_or(0);
937        if used + body_bytes.len() > MAX_SLOT_BYTES {
938            return (
939                StatusCode::PAYLOAD_TOO_LARGE,
940                Json(json!({
941                    "error": "slot quota exceeded",
942                    "slot_bytes_used": used,
943                    "slot_bytes_max": MAX_SLOT_BYTES,
944                    "remediation": "operator should `wire rotate-slot` to drain old slot",
945                })),
946            )
947                .into_response();
948        }
949    }
950    let event_id = req
951        .event
952        .get("event_id")
953        .and_then(Value::as_str)
954        .map(str::to_string);
955
956    // Dedupe by event_id if present.
957    let dup = {
958        let inner = relay.inner.lock().await;
959        let slot = inner.slots.get(&slot_id);
960        if let (Some(eid), Some(slot)) = (&event_id, slot) {
961            slot.iter()
962                .any(|e| e.get("event_id").and_then(Value::as_str) == Some(eid))
963        } else {
964            false
965        }
966    };
967    if dup {
968        return (
969            StatusCode::OK,
970            Json(json!({"event_id": event_id, "status": "duplicate"})),
971        )
972            .into_response();
973    }
974
975    {
976        let mut inner = relay.inner.lock().await;
977        let event_size = body_bytes.len();
978        let slot = inner.slots.entry(slot_id.clone()).or_default();
979        slot.push(req.event.clone());
980        *inner.slot_bytes.entry(slot_id.clone()).or_insert(0) += event_size;
981    }
982    if let Err(e) = relay.append_event_to_disk(&slot_id, &req.event).await {
983        return (
984            StatusCode::INTERNAL_SERVER_ERROR,
985            Json(json!({"error": format!("persist failed: {e}")})),
986        )
987            .into_response();
988    }
989    relay
990        .counters
991        .events_posted_total
992        .fetch_add(1, Ordering::Relaxed);
993    // R1 push: broadcast the new event to every active SSE subscriber on
994    // this slot. Dead channels are pruned in-place. The broadcast happens
995    // AFTER the disk persist so subscribers and disk readers see the same
996    // events; on persist failure we already returned 500 above.
997    {
998        let mut inner = relay.inner.lock().await;
999        if let Some(subs) = inner.streams.get_mut(&slot_id) {
1000            subs.retain(|tx| tx.send(req.event.clone()).is_ok());
1001            if subs.is_empty() {
1002                // Don't leave an empty per-slot Vec behind — `streams` would
1003                // otherwise accumulate one key per ever-streamed slot.
1004                inner.streams.remove(&slot_id);
1005            }
1006        }
1007    }
1008    (
1009        StatusCode::CREATED,
1010        Json(json!({"event_id": event_id, "status": "stored"})),
1011    )
1012        .into_response()
1013}
1014
1015/// R1 — server-sent-events push stream for a slot. Auth'd by slot_token
1016/// (same as `list_events`). The connection registers an `UnboundedSender`
1017/// on the slot's subscriber list; every subsequent `post_event` to the slot
1018/// fans out to all subscribers as `data: <event-json>\n\n` lines. The
1019/// connection stays open until the client disconnects.
1020///
1021/// A 30-second keepalive ping is emitted automatically so reverse proxies
1022/// (Cloudflare tunnel, nginx) don't time out the upstream.
1023///
1024/// Note: the subscriber sees events posted AFTER it subscribed. To catch
1025/// up on history first, the client should call `GET /v1/events/:slot_id`
1026/// with `since=` before opening the stream.
1027async fn stream_events(
1028    State(relay): State<Relay>,
1029    Path(slot_id): Path<String>,
1030    headers: HeaderMap,
1031) -> axum::response::Response {
1032    use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
1033    use futures::stream::StreamExt;
1034
1035    if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
1036        return resp;
1037    }
1038
1039    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
1040    {
1041        let mut inner = relay.inner.lock().await;
1042        // Prune already-disconnected subscribers before counting toward the cap
1043        // (post_event only prunes lazily on a broadcast → a silent slot leaks
1044        // dead senders forever and over-counts against MAX_STREAMS_PER_SLOT).
1045        if let Some(subs) = inner.streams.get_mut(&slot_id) {
1046            retain_live_subscribers(subs);
1047            if subs.is_empty() {
1048                inner.streams.remove(&slot_id);
1049            }
1050        }
1051        // Audit: cap subscribers per slot so an authed token-holder can't open
1052        // unbounded streams (each fans out on every post_event → memory + O(n)
1053        // latency DoS). Over the ceiling → 503.
1054        let current = inner.streams.get(&slot_id).map_or(0, Vec::len);
1055        if at_capacity(current, MAX_STREAMS_PER_SLOT) {
1056            return (
1057                StatusCode::SERVICE_UNAVAILABLE,
1058                Json(json!({
1059                    "error": format!(
1060                        "phyllis: too many open lines on that slot — max {MAX_STREAMS_PER_SLOT} subscribers"
1061                    )
1062                })),
1063            )
1064                .into_response();
1065        }
1066        inner.streams.entry(slot_id.clone()).or_default().push(tx);
1067    }
1068
1069    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx).map(|ev| {
1070        SseEvent::default()
1071            .json_data(&ev)
1072            .map_err(|e| std::io::Error::other(e.to_string()))
1073    });
1074
1075    Sse::new(stream)
1076        .keep_alive(
1077            KeepAlive::new()
1078                .interval(std::time::Duration::from_secs(30))
1079                .text("phyllis: still on the line"),
1080        )
1081        .into_response()
1082}
1083
1084// ---------- pair-slot handlers ----------
1085
1086#[derive(Deserialize)]
1087pub struct PairOpenRequest {
1088    pub code_hash: String,
1089    /// SPAKE2 message (base64).
1090    pub msg: String,
1091    pub role: String, // "host" or "guest"
1092}
1093
1094#[derive(Deserialize)]
1095pub struct PairBootstrapRequest {
1096    pub role: String,
1097    pub sealed: String,
1098}
1099
1100#[derive(Deserialize)]
1101pub struct PairAbandonRequest {
1102    /// SHA-256 hex digest of the code phrase. Same value the caller posts to
1103    /// /v1/pair as `code_hash` — knowing the code IS the auth here.
1104    pub code_hash: String,
1105}
1106
1107/// Forget the pair-slot associated with this code_hash. Either side can call;
1108/// no auth beyond knowledge of the code (which is the shared secret of this
1109/// handshake anyway). Idempotent: returns 204 whether or not the slot exists.
1110/// Used by clients to recover after a crash mid-handshake, so the host doesn't
1111/// stay locked out until the 5-minute TTL.
1112async fn pair_abandon(
1113    State(relay): State<Relay>,
1114    Json(req): Json<PairAbandonRequest>,
1115) -> impl IntoResponse {
1116    let mut inner = relay.inner.lock().await;
1117    if let Some(pair_id) = inner.pair_lookup.remove(&req.code_hash) {
1118        inner.pair_slots.remove(&pair_id);
1119    }
1120    StatusCode::NO_CONTENT.into_response()
1121}
1122
1123async fn pair_open(
1124    State(relay): State<Relay>,
1125    Json(req): Json<PairOpenRequest>,
1126) -> impl IntoResponse {
1127    if req.role != "host" && req.role != "guest" {
1128        return (
1129            StatusCode::BAD_REQUEST,
1130            Json(json!({"error": "role must be 'host' or 'guest'"})),
1131        )
1132            .into_response();
1133    }
1134    relay.evict_expired_pair_slots().await;
1135    let mut inner = relay.inner.lock().await;
1136    let pair_id = match inner.pair_lookup.get(&req.code_hash).cloned() {
1137        Some(id) => id,
1138        None => {
1139            if at_capacity(inner.pair_slots.len(), MAX_PAIR_SLOTS) {
1140                return (
1141                    StatusCode::SERVICE_UNAVAILABLE,
1142                    Json(json!({"error": "relay pair-slot capacity reached"})),
1143                )
1144                    .into_response();
1145            }
1146            let new_id = random_hex(16);
1147            inner
1148                .pair_lookup
1149                .insert(req.code_hash.clone(), new_id.clone());
1150            inner.pair_slots.insert(new_id.clone(), PairSlot::default());
1151            relay
1152                .counters
1153                .pair_opens_total
1154                .fetch_add(1, Ordering::Relaxed);
1155            new_id
1156        }
1157    };
1158    let slot = inner.pair_slots.entry(pair_id.clone()).or_default();
1159    slot.last_touched = std::time::Instant::now();
1160    if req.role == "host" {
1161        if slot.host_msg.is_some() {
1162            return (
1163                StatusCode::CONFLICT,
1164                Json(json!({"error": "host already registered for this code"})),
1165            )
1166                .into_response();
1167        }
1168        slot.host_msg = Some(req.msg);
1169    } else {
1170        if slot.guest_msg.is_some() {
1171            return (
1172                StatusCode::CONFLICT,
1173                Json(json!({"error": "guest already registered for this code"})),
1174            )
1175                .into_response();
1176        }
1177        slot.guest_msg = Some(req.msg);
1178    }
1179    (StatusCode::CREATED, Json(json!({"pair_id": pair_id}))).into_response()
1180}
1181
1182#[derive(Deserialize)]
1183pub struct PairGetQuery {
1184    /// "host" or "guest" — caller's role; we return the OTHER side's data.
1185    pub as_role: String,
1186}
1187
1188async fn pair_get(
1189    State(relay): State<Relay>,
1190    Path(pair_id): Path<String>,
1191    Query(q): Query<PairGetQuery>,
1192) -> impl IntoResponse {
1193    relay.evict_expired_pair_slots().await;
1194    let mut inner = relay.inner.lock().await;
1195    let slot = match inner.pair_slots.get_mut(&pair_id) {
1196        Some(s) => {
1197            s.last_touched = std::time::Instant::now();
1198            s.clone()
1199        }
1200        None => {
1201            return (
1202                StatusCode::NOT_FOUND,
1203                Json(json!({"error": "unknown pair_id"})),
1204            )
1205                .into_response();
1206        }
1207    };
1208    let (peer_msg, peer_bootstrap) = match q.as_role.as_str() {
1209        "host" => (slot.guest_msg, slot.guest_bootstrap),
1210        "guest" => (slot.host_msg, slot.host_bootstrap),
1211        _ => {
1212            return (
1213                StatusCode::BAD_REQUEST,
1214                Json(json!({"error": "as_role must be 'host' or 'guest'"})),
1215            )
1216                .into_response();
1217        }
1218    };
1219    (
1220        StatusCode::OK,
1221        Json(json!({"peer_msg": peer_msg, "peer_bootstrap": peer_bootstrap})),
1222    )
1223        .into_response()
1224}
1225
1226async fn pair_bootstrap(
1227    State(relay): State<Relay>,
1228    Path(pair_id): Path<String>,
1229    Json(req): Json<PairBootstrapRequest>,
1230) -> impl IntoResponse {
1231    relay.evict_expired_pair_slots().await;
1232    let mut inner = relay.inner.lock().await;
1233    let slot = match inner.pair_slots.get_mut(&pair_id) {
1234        Some(s) => s,
1235        None => {
1236            return (
1237                StatusCode::NOT_FOUND,
1238                Json(json!({"error": "unknown pair_id"})),
1239            )
1240                .into_response();
1241        }
1242    };
1243    slot.last_touched = std::time::Instant::now();
1244    match req.role.as_str() {
1245        "host" => slot.host_bootstrap = Some(req.sealed),
1246        "guest" => slot.guest_bootstrap = Some(req.sealed),
1247        _ => {
1248            return (
1249                StatusCode::BAD_REQUEST,
1250                Json(json!({"error": "role must be 'host' or 'guest'"})),
1251            )
1252                .into_response();
1253        }
1254    }
1255    (StatusCode::CREATED, Json(json!({"ok": true}))).into_response()
1256}
1257
1258// ---------- handle directory (v0.5) ----------
1259
1260#[derive(Deserialize)]
1261pub struct HandleClaimRequest {
1262    /// Nick the claimant wants (case-folded). Domain part is implicit: the
1263    /// domain the relay's `.well-known` is served from.
1264    pub nick: String,
1265    /// Slot id the claimant owns on this relay (proves they allocated here).
1266    pub slot_id: String,
1267    /// Optional public-facing relay URL the relay should advertise back in
1268    /// `.well-known/wire/agent` responses. If omitted, callers will need to
1269    /// know the relay URL out-of-band.
1270    pub relay_url: Option<String>,
1271    /// Claimant's full signed agent-card (includes DID + verify_keys +
1272    /// optional profile).
1273    pub card: Value,
1274    /// v0.5.19 (#9.1): set false to opt out of `/v1/handles` bulk listing.
1275    /// Direct `.well-known/wire/agent` lookup by handle still works.
1276    /// Omitted on first claim defaults to discoverable=true.
1277    #[serde(default, skip_serializing_if = "Option::is_none")]
1278    pub discoverable: Option<bool>,
1279}
1280
1281/// `POST /v1/handle/claim` — claim or update a `nick@<relay-domain>` handle.
1282///
1283/// FCFS on nick. Same-DID re-claims allowed (used for profile updates +
1284/// slot rotation). Different-DID claims on a taken nick return 409.
1285/// Caller must (a) own the `slot_id` they reference (verified by token
1286/// being present), and (b) submit a card with a valid self-signature.
1287async fn handle_claim(
1288    State(relay): State<Relay>,
1289    headers: HeaderMap,
1290    Json(req): Json<HandleClaimRequest>,
1291) -> impl IntoResponse {
1292    // Bearer auth: claimant must hold the slot_token for the slot they
1293    // reference. Prevents nick-squatting from an unauthenticated POSTer.
1294    if let Err(resp) = check_token(&relay, &headers, &req.slot_id).await {
1295        return resp;
1296    }
1297    // Validate nick (same rules as the client-side parser).
1298    if !crate::pair_profile::is_valid_nick(&req.nick) {
1299        return (
1300            StatusCode::BAD_REQUEST,
1301            Json(json!({
1302                "error": "phyllis: that handle won't fit in the books — nicks need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list",
1303                "nick": req.nick,
1304            })),
1305        )
1306            .into_response();
1307    }
1308    // Audit L3: the claimant-supplied `relay_url` is echoed verbatim into the
1309    // advertised A2A `endpoint` (well_known_agent_card_a2a). Reject anything
1310    // that isn't a clean `http(s)://host[:port]` — no userinfo (the
1311    // `handle@relay` bug), no `javascript:`/other schemes — so a poisoned
1312    // endpoint can't be planted in the directory for A2A consumers to follow.
1313    if let Some(url) = req.relay_url.as_deref()
1314        && !url.is_empty()
1315        && !is_valid_public_relay_url(url)
1316    {
1317        return (
1318            StatusCode::BAD_REQUEST,
1319            Json(json!({
1320                "error": "relay_url must be http(s)://host[:port] with no userinfo",
1321                "relay_url": url,
1322            })),
1323        )
1324            .into_response();
1325    }
1326    // Verify the card signature using the public verify_agent_card helper.
1327    if let Err(e) = crate::agent_card::verify_agent_card(&req.card) {
1328        return (
1329            StatusCode::BAD_REQUEST,
1330            Json(json!({"error": format!("card signature invalid: {e}")})),
1331        )
1332            .into_response();
1333    }
1334    let did = match req.card.get("did").and_then(Value::as_str) {
1335        Some(d) => d.to_string(),
1336        None => {
1337            return (
1338                StatusCode::BAD_REQUEST,
1339                Json(json!({"error": "card missing 'did' field"})),
1340            )
1341                .into_response();
1342        }
1343    };
1344
1345    // ONE-NAME rule, enforced server-side. The claimed nick MUST equal
1346    // the card's DID-derived persona. The client coerces this before
1347    // POSTing, but that's courtesy — a raw HTTP claim could otherwise
1348    // map an arbitrary nick (e.g. a well-known handle) onto a foreign
1349    // DID, so `wire dial <nick>@relay` would resolve to the impostor.
1350    // `verify_agent_card` above already proved the DID commits to the
1351    // card's key, so this binds nick → key transitively.
1352    let canonical_nick = crate::agent_card::display_handle_from_did(&did);
1353    if req.nick != canonical_nick {
1354        return (
1355            StatusCode::BAD_REQUEST,
1356            Json(json!({
1357                "error": "phyllis: that nick doesn't match your DID — wire publishes one name, and it's the one your key spells out",
1358                "nick": req.nick,
1359                "expected": canonical_nick,
1360            })),
1361        )
1362            .into_response();
1363    }
1364
1365    // FCFS check. Also snapshot the existing record (clone) so the
1366    // re-claim path below can preserve fields the client didn't include
1367    // in the request (notably `discoverable` from v0.5.19, so an old
1368    // client doing a profile-update re-claim doesn't accidentally
1369    // re-publish a hidden handle).
1370    let prior_record: Option<HandleRecord>;
1371    let first_claim = {
1372        let inner = relay.inner.lock().await;
1373        match inner.handles.get(&req.nick) {
1374            Some(existing) if existing.did != did => {
1375                return (
1376                    StatusCode::CONFLICT,
1377                    Json(json!({
1378                        "error": "phyllis: this line's already taken by a different identity (persona collision). Your handle is fixed to your key, so claim on another relay (`wire up @<other-relay>`) or mint a fresh identity (`wire nuke` then `wire up`).",
1379                        "nick": req.nick,
1380                        "claimed_by": existing.did,
1381                    })),
1382                )
1383                    .into_response();
1384            }
1385            Some(prev) => {
1386                prior_record = Some(prev.clone());
1387                false
1388            }
1389            None => {
1390                prior_record = None;
1391                true
1392            }
1393        }
1394    };
1395
1396    // #291 H1: cap the directory size — but only on a NEW nick. A same-DID
1397    // re-claim (profile update / re-publish) must always succeed even at the
1398    // ceiling, since it doesn't grow the map.
1399    if first_claim {
1400        let inner = relay.inner.lock().await;
1401        if at_capacity(inner.handles.len(), MAX_HANDLES) {
1402            return (
1403                StatusCode::SERVICE_UNAVAILABLE,
1404                Json(json!({"error": "relay handle-directory capacity reached"})),
1405            )
1406                .into_response();
1407        }
1408    }
1409
1410    // v0.5.19 (#9.5): round claimed_at to whole seconds. Nanosecond
1411    // precision served no client purpose (display only) and acted as a
1412    // cross-tab fingerprint correlating one operator's multiple handles
1413    // claimed in the same session. Truncate before formatting.
1414    let now = time::OffsetDateTime::now_utc()
1415        .replace_nanosecond(0)
1416        .unwrap_or_else(|_| time::OffsetDateTime::now_utc())
1417        .format(&time::format_description::well_known::Rfc3339)
1418        .unwrap_or_default();
1419    // v0.5.19 (#9.1): preserve `discoverable` across re-claims. If the
1420    // request doesn't set it explicitly, keep whatever the existing
1421    // record had so a profile-update re-claim doesn't accidentally
1422    // re-publish a hidden handle. New first-time claims default to
1423    // discoverable=true explicitly.
1424    let discoverable = match (req.discoverable, &prior_record) {
1425        (Some(d), _) => Some(d),
1426        (None, Some(prev)) => prev.discoverable,
1427        (None, None) => Some(true),
1428    };
1429    let record = HandleRecord {
1430        nick: req.nick.clone(),
1431        did: did.clone(),
1432        card: req.card.clone(),
1433        slot_id: req.slot_id.clone(),
1434        relay_url: req.relay_url.clone(),
1435        claimed_at: now,
1436        discoverable,
1437    };
1438
1439    // Persist to disk first (durable), then update in-memory.
1440    let path = relay
1441        .state_dir
1442        .join("handles")
1443        .join(format!("{}.json", req.nick));
1444    let body = match serde_json::to_vec_pretty(&record) {
1445        Ok(b) => b,
1446        Err(e) => {
1447            return (
1448                StatusCode::INTERNAL_SERVER_ERROR,
1449                Json(json!({"error": format!("serialize failed: {e}")})),
1450            )
1451                .into_response();
1452        }
1453    };
1454    if let Err(e) = tokio::fs::write(&path, &body).await {
1455        return (
1456            StatusCode::INTERNAL_SERVER_ERROR,
1457            Json(json!({"error": format!("persist failed: {e}")})),
1458        )
1459            .into_response();
1460    }
1461    {
1462        let mut inner = relay.inner.lock().await;
1463        inner.handles.insert(req.nick.clone(), record);
1464    }
1465    relay
1466        .counters
1467        .handle_claims_total
1468        .fetch_add(1, Ordering::Relaxed);
1469    if first_claim {
1470        relay
1471            .counters
1472            .handle_first_claims_total
1473            .fetch_add(1, Ordering::Relaxed);
1474    }
1475    (
1476        StatusCode::CREATED,
1477        Json(json!({
1478            "nick": req.nick,
1479            "did": did,
1480            "status": if first_claim { "claimed" } else { "re-claimed" },
1481        })),
1482    )
1483        .into_response()
1484}
1485
1486/// `DELETE /v1/handle/claim/:nick` — release a claimed handle (#247.1). Without
1487/// this, a claim is FCFS-permanent (no expiry, no unclaim) and an abandoned
1488/// handle squats the directory forever. Owner-gated: the caller must present the
1489/// bearer `slot_token` of the handle's slot, so only the holder can release it.
1490/// Removes the in-memory entry and its on-disk file. Idempotent-ish: an
1491/// unknown nick is 404.
1492async fn handle_unclaim(
1493    State(relay): State<Relay>,
1494    Path(nick): Path<String>,
1495    headers: HeaderMap,
1496) -> impl IntoResponse {
1497    // Validate before any filesystem use (the nick is interpolated into a path).
1498    if !crate::pair_profile::is_valid_nick(&nick) {
1499        return (
1500            StatusCode::BAD_REQUEST,
1501            Json(json!({"error": "invalid nick"})),
1502        )
1503            .into_response();
1504    }
1505    // Resolve the handle's slot so we can owner-gate the unclaim.
1506    let slot_id = {
1507        let inner = relay.inner.lock().await;
1508        match inner.handles.get(&nick) {
1509            Some(rec) => rec.slot_id.clone(),
1510            None => {
1511                return (
1512                    StatusCode::NOT_FOUND,
1513                    Json(json!({"error": format!("{nick:?} isn't claimed")})),
1514                )
1515                    .into_response();
1516            }
1517        }
1518    };
1519    // Owner-only: must hold the slot_token for the handle's slot.
1520    if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
1521        return resp;
1522    }
1523    // Remove on-disk file (best-effort — absence is fine) then in-memory entry.
1524    let path = relay.state_dir.join("handles").join(format!("{nick}.json"));
1525    let _ = tokio::fs::remove_file(&path).await;
1526    {
1527        let mut inner = relay.inner.lock().await;
1528        inner.handles.remove(&nick);
1529    }
1530    (
1531        StatusCode::OK,
1532        Json(json!({"nick": nick, "status": "unclaimed"})),
1533    )
1534        .into_response()
1535}
1536
1537#[derive(Deserialize)]
1538pub struct WellKnownAgentQuery {
1539    pub handle: String,
1540}
1541
1542#[derive(Deserialize)]
1543pub struct HandlesDirectoryQuery {
1544    pub cursor: Option<String>,
1545    pub limit: Option<usize>,
1546    pub vibe: Option<String>,
1547}
1548
1549// ─── short-URL invites (v0.5.10) ──────────────────────────────────────────
1550// One-curl onboarding: the invitor registers their `wire://pair?...` URL
1551// here, gets back a 6-hex token. Anyone who does
1552//   curl -fsSL https://wireup.net/i/<token> | sh
1553// gets wire installed (if needed) + the invite accepted, in one shot.
1554//
1555// Possession of the short URL = pair authorization (same shape as the
1556// underlying wire:// invite — it's just a redirector).
1557
1558#[derive(Deserialize)]
1559pub struct InviteRegisterRequest {
1560    /// The wire://pair?... URL produced by `wire invite`. Required.
1561    pub invite_url: String,
1562    /// Lifetime in seconds. Default 86400 (24h). Capped at 7 days.
1563    #[serde(default)]
1564    pub ttl_seconds: Option<u64>,
1565    /// If `Some(n)`, the short URL can be fetched N times before 410s.
1566    /// `None` = unlimited until TTL hits.
1567    #[serde(default)]
1568    pub uses: Option<u32>,
1569}
1570
1571impl Relay {
1572    /// Append one InviteRecord to `<state_dir>/invites.jsonl`.
1573    async fn persist_invite(&self, rec: &InviteRecord) -> Result<()> {
1574        use tokio::io::AsyncWriteExt;
1575        let mut line = serde_json::to_vec(rec)?;
1576        line.push(b'\n');
1577        let path = self.state_dir.join("invites.jsonl");
1578        let mut f = tokio::fs::OpenOptions::new()
1579            .create(true)
1580            .append(true)
1581            .open(&path)
1582            .await?;
1583        f.write_all(&line).await?;
1584        f.flush().await?;
1585        Ok(())
1586    }
1587}
1588
1589async fn invite_register(
1590    State(relay): State<Relay>,
1591    Json(req): Json<InviteRegisterRequest>,
1592) -> impl IntoResponse {
1593    if req.invite_url.is_empty() {
1594        return (
1595            StatusCode::BAD_REQUEST,
1596            Json(json!({"error": "invite_url required"})),
1597        )
1598            .into_response();
1599    }
1600    // Length cap on the embedded URL to keep persisted records bounded.
1601    if req.invite_url.len() > 8_192 {
1602        return (
1603            StatusCode::PAYLOAD_TOO_LARGE,
1604            Json(json!({"error": "invite_url > 8 KiB"})),
1605        )
1606            .into_response();
1607    }
1608    let ttl = req.ttl_seconds.unwrap_or(86_400).clamp(60, 7 * 86_400);
1609    let now = SystemTime::now()
1610        .duration_since(UNIX_EPOCH)
1611        .map(|d| d.as_secs())
1612        .unwrap_or(0);
1613    // 16-hex token → 2^64 space. Brute-force infeasible.
1614    let token = random_hex(8);
1615    let rec = InviteRecord {
1616        token: token.clone(),
1617        invite_url: req.invite_url,
1618        expires_unix: now + ttl,
1619        uses_remaining: req.uses,
1620        created_unix: now,
1621    };
1622    {
1623        let mut inner = relay.inner.lock().await;
1624        if at_capacity(inner.invites.len(), MAX_INVITES) {
1625            return (
1626                StatusCode::SERVICE_UNAVAILABLE,
1627                Json(json!({"error": "relay invite capacity reached"})),
1628            )
1629                .into_response();
1630        }
1631        if inner.invites.contains_key(&token) {
1632            return (
1633                StatusCode::CONFLICT,
1634                Json(json!({"error": "token collision, retry"})),
1635            )
1636                .into_response();
1637        }
1638        inner.invites.insert(token.clone(), rec.clone());
1639    }
1640    if let Err(e) = relay.persist_invite(&rec).await {
1641        return (
1642            StatusCode::INTERNAL_SERVER_ERROR,
1643            Json(json!({"error": format!("persist failed: {e}")})),
1644        )
1645            .into_response();
1646    }
1647    (
1648        StatusCode::CREATED,
1649        Json(json!({
1650            "token": token,
1651            "path": format!("/i/{token}"),
1652            "expires_unix": rec.expires_unix,
1653            "uses_remaining": rec.uses_remaining,
1654        })),
1655    )
1656        .into_response()
1657}
1658
1659#[derive(Deserialize)]
1660pub struct InviteScriptQuery {
1661    /// `format=url` returns the raw `wire://pair?...` URL as text/plain
1662    /// (used by `wire accept https://wireup.net/i/<token>` to resolve a
1663    /// short URL programmatically). Default: shell-script template.
1664    /// Note: ?format=url does NOT decrement `uses_remaining` — it's a
1665    /// resolution lookup, not an acceptance. The actual accept happens
1666    /// when the wire:// URL is consumed by `pair_invite::accept_invite`.
1667    pub format: Option<String>,
1668}
1669
1670async fn invite_script(
1671    State(relay): State<Relay>,
1672    Path(token): Path<String>,
1673    Query(q): Query<InviteScriptQuery>,
1674) -> impl IntoResponse {
1675    // Token shape: 6 lowercase hex. Reject anything else immediately so a
1676    // path-traversal try never reaches the map lookup.
1677    if token.len() != 6 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
1678        return (StatusCode::NOT_FOUND, "not found\n").into_response();
1679    }
1680    let want_raw_url = q.format.as_deref() == Some("url");
1681    let now = SystemTime::now()
1682        .duration_since(UNIX_EPOCH)
1683        .map(|d| d.as_secs())
1684        .unwrap_or(0);
1685    let invite_url = {
1686        let mut inner = relay.inner.lock().await;
1687        let Some(rec) = inner.invites.get_mut(&token) else {
1688            return (StatusCode::NOT_FOUND, "not found\n").into_response();
1689        };
1690        if rec.expires_unix <= now {
1691            return (StatusCode::GONE, "this invite has expired\n").into_response();
1692        }
1693        if let Some(n) = rec.uses_remaining {
1694            if n == 0 {
1695                return (StatusCode::GONE, "this invite has been used up\n").into_response();
1696            }
1697            // Only decrement on script-template fetch (the one that's
1698            // actually doing the pair). The raw-URL resolution path is a
1699            // lookup, not an accept.
1700            if !want_raw_url {
1701                rec.uses_remaining = Some(n - 1);
1702            }
1703        }
1704        rec.invite_url.clone()
1705    };
1706    if want_raw_url {
1707        return (
1708            StatusCode::OK,
1709            [
1710                (
1711                    axum::http::header::CONTENT_TYPE,
1712                    "text/plain; charset=utf-8",
1713                ),
1714                (
1715                    axum::http::header::CACHE_CONTROL,
1716                    "private, no-store, max-age=0",
1717                ),
1718            ],
1719            invite_url,
1720        )
1721            .into_response();
1722    }
1723    let escaped = invite_url.replace('\'', "'\\''");
1724    let script = format!(
1725        "#!/bin/sh\n\
1726         # wire — one-curl onboarding (install + pair in one shot)\n\
1727         # source: https://github.com/SlanchaAi/wire\n\
1728         set -eu\n\
1729         INVITE='{escaped}'\n\
1730         echo \"\u{2192} checking for wire CLI...\"\n\
1731         if ! command -v wire >/dev/null 2>&1; then\n  \
1732           echo \"\u{2192} wire not installed; installing first...\"\n  \
1733           curl -fsSL https://wireup.net/install.sh | sh\n  \
1734           case \":$PATH:\" in\n    \
1735             *:\"$HOME/.local/bin\":*) ;;\n    \
1736             *) export PATH=\"$HOME/.local/bin:$PATH\" ;;\n  \
1737           esac\n  \
1738           if ! command -v wire >/dev/null 2>&1; then\n    \
1739             echo \"\"\n    \
1740             echo \"wire was installed to ~/.local/bin but it's not on \\$PATH yet.\"\n    \
1741             echo \"Open a new shell, then run:\"\n    \
1742             echo \"  wire accept '$INVITE'\"\n    \
1743             exit 0\n  \
1744           fi\n\
1745         fi\n\
1746         echo \"\u{2192} accepting invite...\"\n\
1747         wire accept \"$INVITE\"\n"
1748    );
1749    (
1750        StatusCode::OK,
1751        [
1752            (
1753                axum::http::header::CONTENT_TYPE,
1754                "text/x-shellscript; charset=utf-8",
1755            ),
1756            (
1757                axum::http::header::CACHE_CONTROL,
1758                "private, no-store, max-age=0",
1759            ),
1760        ],
1761        script,
1762    )
1763        .into_response()
1764}
1765
1766async fn handles_directory(
1767    State(relay): State<Relay>,
1768    Query(q): Query<HandlesDirectoryQuery>,
1769) -> impl IntoResponse {
1770    let limit = q.limit.unwrap_or(100).clamp(1, 500);
1771    let vibe_filter = q.vibe.as_ref().map(|v| v.to_ascii_lowercase());
1772    let inner = relay.inner.lock().await;
1773    let mut records: Vec<HandleRecord> = inner.handles.values().cloned().collect();
1774    drop(inner);
1775    records.sort_by(|a, b| a.nick.cmp(&b.nick));
1776
1777    let cursor = q.cursor.as_deref();
1778    let mut eligible = Vec::new();
1779    for rec in records {
1780        if cursor.is_some_and(|c| rec.nick.as_str() <= c) {
1781            continue;
1782        }
1783        // Hygiene: hide test-shaped nicks from the public directory. Records
1784        // remain claimed (FCFS protection persists), they just don't surface
1785        // in the phone book. `demo-` is reserved for asciinema-cast handles,
1786        // `test-` for integration runs.
1787        if rec.nick.starts_with("demo-") || rec.nick.starts_with("test-") {
1788            continue;
1789        }
1790        // v0.5.19 (#9.1): operator opt-out — hidden handles skip the
1791        // bulk directory but still resolve via `.well-known/wire/agent?
1792        // handle=X` for out-of-band sharing.
1793        if !rec.is_discoverable() {
1794            continue;
1795        }
1796        let profile = rec.card.get("profile").cloned().unwrap_or(Value::Null);
1797        if profile
1798            .get("listed")
1799            .and_then(Value::as_bool)
1800            .is_some_and(|listed| !listed)
1801        {
1802            continue;
1803        }
1804        if let Some(want) = &vibe_filter {
1805            let matched = profile
1806                .get("vibe")
1807                .and_then(Value::as_array)
1808                .map(|arr| {
1809                    arr.iter().any(|v| {
1810                        v.as_str()
1811                            .map(|s| s.eq_ignore_ascii_case(want))
1812                            .unwrap_or(false)
1813                    })
1814                })
1815                .unwrap_or(false);
1816            if !matched {
1817                continue;
1818            }
1819        }
1820        eligible.push((rec, profile));
1821    }
1822
1823    let has_more = eligible.len() > limit;
1824    let page = eligible.into_iter().take(limit).collect::<Vec<_>>();
1825    let next_cursor = if has_more {
1826        page.last().map(|(rec, _)| rec.nick.clone())
1827    } else {
1828        None
1829    };
1830    let handles: Vec<Value> = page
1831        .into_iter()
1832        .map(|(rec, profile)| {
1833            // v0.12.1: fall back to the DID-derived persona emoji when the
1834            // card carries no explicit profile emoji, so every phonebook
1835            // line shows a face next to the name. The persona is a
1836            // deterministic function of the DID, so the relay can compute it
1837            // without the claimant having set anything.
1838            let emoji = profile
1839                .get("emoji")
1840                .and_then(Value::as_str)
1841                .filter(|s| !s.is_empty())
1842                .map(str::to_string)
1843                .unwrap_or_else(|| crate::character::Character::from_did(&rec.did).emoji);
1844            json!({
1845                "nick": rec.nick,
1846                "did": rec.did,
1847                "profile": {
1848                    "emoji": emoji,
1849                    "motto": profile.get("motto").cloned().unwrap_or(Value::Null),
1850                    "vibe": profile.get("vibe").cloned().unwrap_or(Value::Null),
1851                    "pronouns": profile.get("pronouns").cloned().unwrap_or(Value::Null),
1852                    "now": profile.get("now").cloned().unwrap_or(Value::Null),
1853                },
1854                "claimed_at": rec.claimed_at,
1855            })
1856        })
1857        .collect();
1858    (
1859        StatusCode::OK,
1860        Json(json!({
1861            "handles": handles,
1862            "next_cursor": next_cursor,
1863        })),
1864    )
1865        .into_response()
1866}
1867
1868/// `POST /v1/handle/intro/:nick` — drop a signed pair-introduction event
1869/// into a known nick's slot WITHOUT needing that slot's bearer token.
1870///
1871/// Why this exists: `.well-known/wire/agent` returns a nick's `slot_id` for
1872/// reachability, but NEVER its `slot_token` (that would leak read+write
1873/// authority to any handle-resolver). To zero-paste-pair, we need a way for
1874/// a stranger to deliver their signed agent-card to the nick's owner. This
1875/// endpoint provides exactly that, and ONLY that: the event must be `kind=1100`
1876/// (pair_drop / agent_card), self-signed, and the carrying agent-card embedded
1877/// in the body must verify-OK on its own.
1878///
1879/// SECURITY (audit / #247 finding 3): this route is UNAUTHENTICATED by design
1880/// (a stranger must be able to drop a pair-intro) and is NOT under the governor
1881/// layer — it sits on the main router, not in `hot_writes`. The only backpressure
1882/// is the per-slot 64MB quota, which is shared with the owner's own inbound, so a
1883/// flood of intros to a known nick can fill the victim's slot and 413 their
1884/// legitimate traffic. A per-nick / per-source intro rate-limit (and a separate
1885/// intro-byte budget so a flood can't evict the owner) is tracked in #247.3 —
1886/// it is NOT in place today. (Earlier comment here wrongly claimed governor
1887/// coverage.)
1888async fn handle_intro(
1889    State(relay): State<Relay>,
1890    Path(nick): Path<String>,
1891    Json(req): Json<PostEventRequest>,
1892) -> impl IntoResponse {
1893    // Look up the nick. Must already be claimed.
1894    let slot_id = {
1895        let mut inner = relay.inner.lock().await;
1896        let slot_id = match inner.handles.get(&nick) {
1897            Some(rec) => rec.slot_id.clone(),
1898            None => {
1899                return (
1900                    StatusCode::NOT_FOUND,
1901                    Json(json!({"error": format!("phyllis: that number's been disconnected — {nick:?} isn't claimed on this switchboard")})),
1902                )
1903                    .into_response();
1904            }
1905        };
1906        // #247.3: per-nick intro rate-limit. This endpoint is unauthenticated
1907        // by design (a stranger drops a pair-intro), so without a per-nick cap
1908        // an attacker can flood a known nick's slot to MAX_SLOT_BYTES and DoS
1909        // them. The global governor doesn't help (it also throttles everyone,
1910        // and 10/s × 256KiB still fills 64MB in ~25s). Cap intros per nick per
1911        // window; over the limit → 429.
1912        let now = unix_now();
1913        // Audit hygiene: before tracking this nick, opportunistically sweep
1914        // fully-aged nicks once the map grows past a soft cap, so it can't
1915        // retain one stale entry per ever-touched nick.
1916        if at_capacity(inner.intro_times.len(), MAX_INTRO_TRACKING_NICKS) {
1917            evict_stale_intro_nicks(&mut inner.intro_times, now, INTRO_WINDOW_SECS);
1918        }
1919        let times = inner.intro_times.entry(nick.clone()).or_default();
1920        if !record_intro_within_rate(times, now, INTRO_WINDOW_SECS, INTRO_MAX_PER_WINDOW) {
1921            return (
1922                StatusCode::TOO_MANY_REQUESTS,
1923                Json(json!({
1924                    "error": format!(
1925                        "too many pair-intros to {nick:?} — rate limited ({INTRO_MAX_PER_WINDOW} per {INTRO_WINDOW_SECS}s). Try again shortly."
1926                    ),
1927                })),
1928            )
1929                .into_response();
1930        }
1931        slot_id
1932    };
1933
1934    // Only allow kind=1100 pair_drop / agent_card here. Anything else routes
1935    // to the standard /v1/events/:slot_id with bearer auth.
1936    let kind = req.event.get("kind").and_then(Value::as_u64).unwrap_or(0);
1937    let type_str = req.event.get("type").and_then(Value::as_str).unwrap_or("");
1938    if !intro_event_allowed(kind, type_str) {
1939        return (
1940            StatusCode::BAD_REQUEST,
1941            Json(json!({
1942                "error": "intro endpoint only accepts kind=1100 pair_drop / agent_card events",
1943                "got_kind": kind,
1944                "got_type": type_str,
1945            })),
1946        )
1947            .into_response();
1948    }
1949
1950    // Body must embed a signed agent-card (so the receiver can pin from it).
1951    let embedded_card = match req.event.get("body").and_then(|b| b.get("card")) {
1952        Some(c) => c.clone(),
1953        None => {
1954            return (
1955                StatusCode::BAD_REQUEST,
1956                Json(json!({"error": "intro event body must embed 'card' field"})),
1957            )
1958                .into_response();
1959        }
1960    };
1961    if let Err(e) = crate::agent_card::verify_agent_card(&embedded_card) {
1962        return (
1963            StatusCode::BAD_REQUEST,
1964            Json(json!({"error": format!("embedded card signature invalid: {e}")})),
1965        )
1966            .into_response();
1967    }
1968
1969    // Size + quota checks (same as post_event).
1970    let body_bytes = match serde_json::to_vec(&req.event) {
1971        Ok(b) => b,
1972        Err(e) => {
1973            return (
1974                StatusCode::BAD_REQUEST,
1975                Json(json!({"error": format!("event not serializable: {e}")})),
1976            )
1977                .into_response();
1978        }
1979    };
1980    if body_bytes.len() > MAX_EVENT_BYTES {
1981        return (
1982            StatusCode::PAYLOAD_TOO_LARGE,
1983            Json(json!({"error": "intro event exceeds 256 KiB", "max_bytes": MAX_EVENT_BYTES})),
1984        )
1985            .into_response();
1986    }
1987    {
1988        let inner = relay.inner.lock().await;
1989        let used = inner.slot_bytes.get(&slot_id).copied().unwrap_or(0);
1990        if used + body_bytes.len() > MAX_SLOT_BYTES {
1991            return (
1992                StatusCode::PAYLOAD_TOO_LARGE,
1993                Json(json!({
1994                    "error": "target slot quota exceeded",
1995                    "slot_bytes_used": used,
1996                    "slot_bytes_max": MAX_SLOT_BYTES,
1997                })),
1998            )
1999                .into_response();
2000        }
2001    }
2002
2003    let event_id = req
2004        .event
2005        .get("event_id")
2006        .and_then(Value::as_str)
2007        .map(str::to_string);
2008
2009    // Dedupe by event_id if present.
2010    let dup = {
2011        let inner = relay.inner.lock().await;
2012        let slot = inner.slots.get(&slot_id);
2013        if let (Some(eid), Some(slot)) = (&event_id, slot) {
2014            slot.iter()
2015                .any(|e| e.get("event_id").and_then(Value::as_str) == Some(eid))
2016        } else {
2017            false
2018        }
2019    };
2020    if dup {
2021        return (
2022            StatusCode::OK,
2023            Json(json!({"event_id": event_id, "status": "duplicate"})),
2024        )
2025            .into_response();
2026    }
2027
2028    {
2029        let mut inner = relay.inner.lock().await;
2030        let event_size = body_bytes.len();
2031        let slot = inner.slots.entry(slot_id.clone()).or_default();
2032        slot.push(req.event.clone());
2033        *inner.slot_bytes.entry(slot_id.clone()).or_insert(0) += event_size;
2034    }
2035    if let Err(e) = relay.append_event_to_disk(&slot_id, &req.event).await {
2036        return (
2037            StatusCode::INTERNAL_SERVER_ERROR,
2038            Json(json!({"error": format!("persist failed: {e}")})),
2039        )
2040            .into_response();
2041    }
2042    (
2043        StatusCode::CREATED,
2044        Json(json!({"event_id": event_id, "status": "dropped", "to_nick": nick})),
2045    )
2046        .into_response()
2047}
2048
2049/// `GET /.well-known/wire/agent?handle=<nick>` — WebFinger-style resolver
2050/// for `nick@<this-relay-domain>` handles. Returns the signed agent-card +
2051/// slot coords if claimed; 404 if not.
2052///
2053/// The `handle` query parameter may be just `<nick>` or `<nick>@<domain>`.
2054/// Domain part is ignored (the relay only serves nicks it has on file).
2055/// `GET /.well-known/agent-card.json?handle=<nick>` — A2A v1.0-compatible
2056/// AgentCard serving wire's handle directory. Same data as `well_known_agent`
2057/// but in the schema A2A clients (MSFT/AWS/Salesforce/SAP/ServiceNow tooling,
2058/// agent-card-go, agent-card-python, A2A .NET SDK) already speak.
2059///
2060/// Wire-specific fields (DID, slot_id, profile blob, raw signed card) live
2061/// under the standard A2A `extensions` array using the wire extension URI.
2062/// A2A-only clients can pair to wire agents knowing only A2A vocabulary;
2063/// wire-native clients get the full richer card by following the extension.
2064async fn well_known_agent_card_a2a(
2065    State(relay): State<Relay>,
2066    Query(q): Query<WellKnownAgentQuery>,
2067) -> impl IntoResponse {
2068    let nick = q.handle.split('@').next().unwrap_or("").to_string();
2069    if nick.is_empty() {
2070        return (
2071            StatusCode::BAD_REQUEST,
2072            Json(json!({"error": "handle missing nick"})),
2073        )
2074            .into_response();
2075    }
2076    let inner = relay.inner.lock().await;
2077    let rec = match inner.handles.get(&nick) {
2078        Some(r) => r.clone(),
2079        None => {
2080            return (
2081                StatusCode::NOT_FOUND,
2082                Json(json!({"error": format!("phyllis: that number's been disconnected — {nick:?} isn't claimed on this switchboard")})),
2083            )
2084                .into_response();
2085        }
2086    };
2087    drop(inner);
2088
2089    let profile = rec.card.get("profile").cloned().unwrap_or(Value::Null);
2090    let description = profile
2091        .get("motto")
2092        .and_then(Value::as_str)
2093        .unwrap_or("")
2094        .to_string();
2095    let display_name = profile
2096        .get("display_name")
2097        .and_then(Value::as_str)
2098        .unwrap_or(&rec.nick)
2099        .to_string();
2100    let relay_url = rec.relay_url.clone().unwrap_or_default();
2101    // Intro endpoint = where any A2A or wire client posts a signed pair-drop.
2102    let endpoint = if !relay_url.is_empty() {
2103        format!(
2104            "{}/v1/handle/intro/{}",
2105            relay_url.trim_end_matches('/'),
2106            rec.nick
2107        )
2108    } else {
2109        format!("/v1/handle/intro/{}", rec.nick)
2110    };
2111    let card_sig = rec.card.get("signature").cloned().unwrap_or(Value::Null);
2112
2113    // Build A2A v1.0 AgentCard shape with wire extension. Fields named to
2114    // match the A2A spec exactly so downstream tooling (agent-card-go etc.)
2115    // parses without custom code.
2116    let a2a_card = json!({
2117        "id": rec.did,
2118        "name": display_name,
2119        "description": description,
2120        "version": "wire/0.5",
2121        "endpoint": endpoint,
2122        "provider": {
2123            "name": "wire",
2124            "url": "https://github.com/SlanchaAi/wire"
2125        },
2126        "capabilities": {
2127            "streaming": false,
2128            "pushNotifications": false,
2129            "extendedAgentCard": true
2130        },
2131        "securitySchemes": {
2132            "ed25519-event-sig": {
2133                "type": "signature",
2134                "alg": "EdDSA",
2135                "description": "Wire-style signed events (kind=1100 pair_drop for intro; verify against embedded card pubkey)."
2136            }
2137        },
2138        "security": [{"ed25519-event-sig": []}],
2139        "skills": [],
2140        "extensions": [{
2141            // A2A extension URIs are opaque namespace identifiers, not
2142            // forwardable URLs. Changing this string is a coordinated
2143            // federation-spec bump because peers match it exactly.
2144            "uri": "https://slancha.ai/wire/ext/v0.5",
2145            "description": "Wire-native fields: full signed agent-card, profile blob, DID, slot_id, mailbox relay coords.",
2146            "required": false,
2147            "params": {
2148                "did": rec.did,
2149                "handle": rec.nick,
2150                "slot_id": rec.slot_id,
2151                "relay_url": rec.relay_url,
2152                "card": rec.card,
2153                "profile": profile,
2154                "claimed_at": rec.claimed_at,
2155            }
2156        }],
2157        "signature": card_sig,
2158    });
2159    (StatusCode::OK, Json(a2a_card)).into_response()
2160}
2161
2162async fn well_known_agent(
2163    State(relay): State<Relay>,
2164    Query(q): Query<WellKnownAgentQuery>,
2165) -> impl IntoResponse {
2166    let nick = q.handle.split('@').next().unwrap_or("").to_string();
2167    if nick.is_empty() {
2168        return (
2169            StatusCode::BAD_REQUEST,
2170            Json(json!({"error": "handle missing nick"})),
2171        )
2172            .into_response();
2173    }
2174    let inner = relay.inner.lock().await;
2175    match inner.handles.get(&nick) {
2176        Some(rec) => (
2177            StatusCode::OK,
2178            Json(json!({
2179                "nick": rec.nick,
2180                "did": rec.did,
2181                "card": rec.card,
2182                "slot_id": rec.slot_id,
2183                "relay_url": rec.relay_url,
2184                "claimed_at": rec.claimed_at,
2185            })),
2186        )
2187            .into_response(),
2188        None => (
2189            StatusCode::NOT_FOUND,
2190            Json(json!({"error": format!("phyllis: that number's been disconnected — {nick:?} isn't claimed on this switchboard")})),
2191        )
2192            .into_response(),
2193    }
2194}
2195
2196async fn list_events(
2197    State(relay): State<Relay>,
2198    Path(slot_id): Path<String>,
2199    Query(q): Query<ListEventsQuery>,
2200    headers: HeaderMap,
2201) -> impl IntoResponse {
2202    if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
2203        return resp;
2204    }
2205    let limit = q.limit.unwrap_or(100).min(1000);
2206    // R4: record this pull as proof that the slot owner is still polling.
2207    // Anyone holding the slot_token (i.e., a paired peer) can later read
2208    // last_pull_at_unix via /v1/slot/:slot_id/state to gauge attentiveness.
2209    let now_unix = std::time::SystemTime::now()
2210        .duration_since(std::time::UNIX_EPOCH)
2211        .map(|d| d.as_secs())
2212        .unwrap_or(0);
2213    let mut inner = relay.inner.lock().await;
2214    inner.last_pull_at_unix.insert(slot_id.clone(), now_unix);
2215    // Borrow the slot and clone ONLY the requested window. Pre-fix this
2216    // `.cloned()`'d the WHOLE slot Vec (bounded only by MAX_SLOT_BYTES = 64 MiB)
2217    // under the global mutex on every pull — a multi-MB memcpy serialized under
2218    // the lock that post_event and every other handler contend on, which was the
2219    // #342 saturation hot path. The `since` position scan stays under the lock
2220    // but only walks refs (no allocation); the sole clone is the <=limit slice.
2221    let slice: Vec<Value> = match inner.slots.get(&slot_id) {
2222        Some(events) => {
2223            let start = match q.since {
2224                Some(ref eid) => events
2225                    .iter()
2226                    .position(|e| e.get("event_id").and_then(Value::as_str) == Some(eid.as_str()))
2227                    .map(|i| i + 1)
2228                    .unwrap_or(0),
2229                None => 0,
2230            };
2231            let end = (start + limit).min(events.len());
2232            events[start..end].to_vec()
2233        }
2234        None => Vec::new(),
2235    };
2236    drop(inner); // release before serializing the response
2237    (StatusCode::OK, Json(slice)).into_response()
2238}
2239
2240/// R4 — slot-attentiveness probe. Authenticated by slot_token (so only
2241/// paired peers can ask). Returns `last_pull_at_unix` (the slot owner's most
2242/// recent `list_events` call, in unix seconds) and `event_count` (total
2243/// stored). A remote sender uses this before `wire send <peer>` to warn the
2244/// operator if the peer hasn't polled recently.
2245async fn slot_state(
2246    State(relay): State<Relay>,
2247    Path(slot_id): Path<String>,
2248    headers: HeaderMap,
2249) -> impl IntoResponse {
2250    if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
2251        return resp;
2252    }
2253    let inner = relay.inner.lock().await;
2254    let event_count = inner.slots.get(&slot_id).map(|v| v.len()).unwrap_or(0);
2255    let last_pull_at_unix = inner.last_pull_at_unix.get(&slot_id).copied();
2256    let responder_health = inner.responder_health.get(&slot_id).cloned();
2257    (
2258        StatusCode::OK,
2259        Json(json!({
2260            "slot_id": slot_id,
2261            "event_count": event_count,
2262            "last_pull_at_unix": last_pull_at_unix,
2263            "responder_health": responder_health,
2264        })),
2265    )
2266        .into_response()
2267}
2268
2269async fn responder_health_set(
2270    State(relay): State<Relay>,
2271    Path(slot_id): Path<String>,
2272    headers: HeaderMap,
2273    Json(record): Json<ResponderHealthRecord>,
2274) -> impl IntoResponse {
2275    // Defense-in-depth (audit L1): `slot_id` is interpolated into a filesystem
2276    // path below, so validate its shape BEFORE use even though `check_token`
2277    // (which only matches `random_hex(16)` keys) would already reject a
2278    // traversal id. Mirrors the guard in `append_event_to_disk`.
2279    if !is_valid_slot_id(&slot_id) {
2280        return (
2281            StatusCode::BAD_REQUEST,
2282            Json(json!({"error": "invalid slot_id"})),
2283        )
2284            .into_response();
2285    }
2286    if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
2287        return resp;
2288    }
2289    let path = relay
2290        .state_dir
2291        .join("responder-health")
2292        .join(format!("{slot_id}.json"));
2293    let body = match serde_json::to_vec_pretty(&record) {
2294        Ok(b) => b,
2295        Err(e) => {
2296            return (
2297                StatusCode::INTERNAL_SERVER_ERROR,
2298                Json(json!({"error": format!("serialize failed: {e}")})),
2299            )
2300                .into_response();
2301        }
2302    };
2303    if let Err(e) = tokio::fs::write(&path, body).await {
2304        return (
2305            StatusCode::INTERNAL_SERVER_ERROR,
2306            Json(json!({"error": format!("persist failed: {e}")})),
2307        )
2308            .into_response();
2309    }
2310    {
2311        let mut inner = relay.inner.lock().await;
2312        inner
2313            .responder_health
2314            .insert(slot_id.clone(), record.clone());
2315    }
2316    (StatusCode::OK, Json(record)).into_response()
2317}
2318
2319async fn check_token(
2320    relay: &Relay,
2321    headers: &HeaderMap,
2322    slot_id: &str,
2323) -> std::result::Result<(), axum::response::Response> {
2324    let auth = headers
2325        .get(AUTHORIZATION)
2326        .and_then(|h| h.to_str().ok())
2327        .and_then(|s| s.strip_prefix("Bearer "))
2328        .map(str::to_string);
2329    let presented = match auth {
2330        Some(t) => t,
2331        None => {
2332            return Err((
2333                StatusCode::UNAUTHORIZED,
2334                Json(json!({"error": "missing Bearer token"})),
2335            )
2336                .into_response());
2337        }
2338    };
2339    let inner = relay.inner.lock().await;
2340    let expected = match inner.tokens.get(slot_id) {
2341        Some(t) => t.clone(),
2342        None => {
2343            return Err((
2344                StatusCode::NOT_FOUND,
2345                Json(json!({"error": "unknown slot"})),
2346            )
2347                .into_response());
2348        }
2349    };
2350    drop(inner);
2351    if !constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
2352        return Err((StatusCode::FORBIDDEN, Json(json!({"error": "bad token"}))).into_response());
2353    }
2354    Ok(())
2355}
2356
2357fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2358    if a.len() != b.len() {
2359        return false;
2360    }
2361    let mut acc = 0u8;
2362    for (x, y) in a.iter().zip(b.iter()) {
2363        acc |= x ^ y;
2364    }
2365    acc == 0
2366}
2367
2368fn is_valid_slot_id(s: &str) -> bool {
2369    s.len() == 32
2370        && s.bytes()
2371            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
2372}
2373
2374/// The unauthenticated `/v1/handle/intro` endpoint accepts an event ONLY when it
2375/// is a `kind=1100` pair-intro of type `pair_drop` or `agent_card`; everything
2376/// else must route through the bearer-authed `/v1/events/:slot_id`. Extracted as
2377/// a pure predicate so the accept/reject matrix is locked by a unit test — #334
2378/// fixed a boolean bug here (`&&`-chain accepted a kind=1100 event of ANY type,
2379/// and a wrong-kind event whose type happened to match), and this guards the
2380/// security property against regressing.
2381fn intro_event_allowed(kind: u64, type_str: &str) -> bool {
2382    kind == 1100 && (type_str == "pair_drop" || type_str == "agent_card")
2383}
2384
2385/// A claimant-advertised `relay_url` is acceptable for the public directory iff
2386/// it is `http://` or `https://`, has a non-empty host authority, and carries NO
2387/// userinfo (`user@host` — the `handle@relay` bug) and no embedded
2388/// whitespace/control. Audit L3 — the value is echoed into the A2A `endpoint`,
2389/// so a non-URL / alternate-scheme value must not be planted there. Pure +
2390/// unit-tested. Intentionally strict: scheme + authority shape only, not a full
2391/// RFC-3986 parse.
2392fn is_valid_public_relay_url(u: &str) -> bool {
2393    let rest = match u
2394        .strip_prefix("https://")
2395        .or_else(|| u.strip_prefix("http://"))
2396    {
2397        Some(r) => r,
2398        None => return false,
2399    };
2400    // Authority is everything up to the first '/'.
2401    let authority = rest.split('/').next().unwrap_or("");
2402    if authority.is_empty() {
2403        return false;
2404    }
2405    // No userinfo (`user[:pass]@host`).
2406    if authority.contains('@') {
2407        return false;
2408    }
2409    // No whitespace / control chars anywhere in the URL.
2410    if u.chars().any(|c| c.is_whitespace() || c.is_control()) {
2411        return false;
2412    }
2413    // Host (strip optional :port) must be non-empty.
2414    let host = authority.split(':').next().unwrap_or("");
2415    !host.is_empty()
2416}
2417
2418fn random_hex(n_bytes: usize) -> String {
2419    let mut buf = vec![0u8; n_bytes];
2420    rand::thread_rng().fill_bytes(&mut buf);
2421    hex::encode(buf)
2422}
2423
2424/// Run the relay until SIGINT/SIGTERM.
2425pub async fn serve(bind: &str, state_dir: PathBuf) -> Result<()> {
2426    serve_with_mode(bind, state_dir, ServerMode::default()).await
2427}
2428
2429/// v0.5.17: server-mode-aware entry point. Same as `serve` but with the
2430/// `--local-only` toggle exposed so the binary can refuse to publish
2431/// phonebook + well-known surfaces on within-machine relays.
2432pub async fn serve_with_mode(bind: &str, state_dir: PathBuf, mode: ServerMode) -> Result<()> {
2433    let relay = Relay::new(state_dir).await?;
2434    relay.spawn_pair_sweeper();
2435    relay.spawn_counter_persister();
2436    let app = relay.clone().router_with_mode(mode);
2437    let listener = tokio::net::TcpListener::bind(bind)
2438        .await
2439        .with_context(|| format!("binding {bind}"))?;
2440    if mode.local_only {
2441        eprintln!(
2442            "wire relay-server (LOCAL-ONLY) listening on {bind} — phonebook + well-known endpoints disabled"
2443        );
2444    } else {
2445        eprintln!("wire relay-server listening on {bind}");
2446    }
2447    let shutdown_relay = relay.clone();
2448    axum::serve(listener, app)
2449        .with_graceful_shutdown(async move {
2450            let _ = tokio::signal::ctrl_c().await;
2451            eprintln!("\nshutting down — final counter snapshot");
2452            if let Err(e) = shutdown_relay.persist_counters().await {
2453                eprintln!("final counter persist failed: {e}");
2454            }
2455        })
2456        .await?;
2457    Ok(())
2458}
2459
2460/// v0.7.0-alpha.16: Unix Domain Socket entry point. Mirrors `serve_with_mode`
2461/// but binds to a UDS path instead of a TCP host:port. Implicitly forces
2462/// `mode.local_only = true` because UDS has no concept of "publish to a
2463/// public phonebook." Chmods the socket 0600 so only the owner uid can
2464/// connect — the SO_PEERCRED-equivalent trust anchor for sister sessions.
2465///
2466/// Removes any stale socket file at `path` first (otherwise bind fails
2467/// with EADDRINUSE). Removes the socket on graceful shutdown.
2468///
2469/// Implementation note: axum 0.7's `serve` is TcpListener-only, so we
2470/// run the manual hyper accept loop (per-connection
2471/// `hyper::server::conn::http1::Builder`). When axum 0.8 ships with
2472/// generic `Listener` support, this can collapse to one line.
2473#[cfg(unix)]
2474pub async fn serve_uds(socket_path: PathBuf, state_dir: PathBuf) -> Result<()> {
2475    use hyper::server::conn::http1;
2476    use hyper_util::rt::TokioIo;
2477    use tower_service::Service;
2478
2479    // Best-effort cleanup of stale socket file.
2480    if socket_path.exists() {
2481        std::fs::remove_file(&socket_path)
2482            .with_context(|| format!("removing stale socket at {socket_path:?}"))?;
2483    }
2484    if let Some(parent) = socket_path.parent() {
2485        std::fs::create_dir_all(parent)
2486            .with_context(|| format!("creating socket parent {parent:?}"))?;
2487    }
2488    let relay = Relay::new(state_dir).await?;
2489    relay.spawn_pair_sweeper();
2490    relay.spawn_counter_persister();
2491    let app: axum::Router = relay
2492        .clone()
2493        .router_with_mode(ServerMode { local_only: true });
2494    let listener = tokio::net::UnixListener::bind(&socket_path)
2495        .with_context(|| format!("binding UDS at {socket_path:?}"))?;
2496
2497    // 0600: owner-rw only. Trust anchor is the kernel-attested peer uid
2498    // (SO_PEERCRED equivalent); chmod is defense-in-depth.
2499    use std::os::unix::fs::PermissionsExt;
2500    if let Err(e) = std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600)) {
2501        eprintln!(
2502            "wire relay-server (UDS): chmod 0600 on {socket_path:?} failed: {e} — \
2503             socket may be accessible to other uids. Investigate."
2504        );
2505    }
2506    eprintln!(
2507        "wire relay-server (UDS) listening on unix://{} — same-host, owner-uid only",
2508        socket_path.display()
2509    );
2510
2511    // Manual accept loop + per-conn hyper serve (axum 0.7's serve is
2512    // TcpListener-only). On SIGINT, persist counters + remove socket.
2513    let shutdown_relay = relay.clone();
2514    let socket_path_for_cleanup = socket_path.clone();
2515    let mut make_service = app.into_make_service();
2516
2517    let serve_loop = async {
2518        loop {
2519            let (stream, _peer_addr) = match listener.accept().await {
2520                Ok(p) => p,
2521                Err(e) => {
2522                    eprintln!("wire relay-server (UDS): accept failed: {e}");
2523                    continue;
2524                }
2525            };
2526            let tower_service = match make_service.call(&stream).await {
2527                Ok(s) => s,
2528                Err(infallible) => match infallible {},
2529            };
2530            let io = TokioIo::new(stream);
2531            let hyper_service =
2532                hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
2533                    let mut svc = tower_service.clone();
2534                    async move { Service::call(&mut svc, req).await }
2535                });
2536            tokio::task::spawn(async move {
2537                if let Err(e) = http1::Builder::new()
2538                    .serve_connection(io, hyper_service)
2539                    .await
2540                {
2541                    // Connection-level error; not fatal to the listener.
2542                    if !e.is_incomplete_message() {
2543                        eprintln!("wire relay-server (UDS): conn error: {e}");
2544                    }
2545                }
2546            });
2547        }
2548    };
2549
2550    let shutdown = async {
2551        let _ = tokio::signal::ctrl_c().await;
2552        eprintln!("\nshutting down — final counter snapshot");
2553        if let Err(e) = shutdown_relay.persist_counters().await {
2554            eprintln!("final counter persist failed: {e}");
2555        }
2556        let _ = std::fs::remove_file(&socket_path_for_cleanup);
2557    };
2558
2559    tokio::select! {
2560        _ = serve_loop => {},
2561        _ = shutdown => {},
2562    };
2563    Ok(())
2564}
2565
2566#[cfg(not(unix))]
2567pub async fn serve_uds(_socket_path: PathBuf, _state_dir: PathBuf) -> Result<()> {
2568    Err(anyhow::anyhow!(
2569        "UDS transport is Unix-only; Windows falls back to loopback HTTP. \
2570         Use `wire relay-server --bind 127.0.0.1:8771 --local-only` on Windows."
2571    ))
2572}
2573
2574/// v0.5.17: relay-server mode toggles. Default = full federation
2575/// (current behavior). `local_only` strips phonebook + well-known
2576/// surfaces so the relay is invisible from off-box and from any
2577/// directory-scraping agent on the same box.
2578#[derive(Debug, Clone, Copy, Default)]
2579pub struct ServerMode {
2580    /// When true, skip phonebook listing + `.well-known/wire/agent` +
2581    /// `.well-known/agent-card.json` + landing/stats pages. Pair this
2582    /// with a loopback bind (`--local-only` enforces this at the CLI
2583    /// layer) for genuinely within-machine traffic.
2584    pub local_only: bool,
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589    use super::*;
2590
2591    #[test]
2592    fn constant_time_eq_basic() {
2593        assert!(constant_time_eq(b"abc", b"abc"));
2594        assert!(!constant_time_eq(b"abc", b"abd"));
2595        assert!(!constant_time_eq(b"abc", b"abcd")); // length mismatch
2596    }
2597
2598    #[test]
2599    fn at_capacity_is_inclusive_at_the_ceiling() {
2600        // #291 H1: a new allocation is refused once the map reaches `max`.
2601        assert!(!at_capacity(0, 5));
2602        assert!(!at_capacity(4, 5));
2603        assert!(at_capacity(5, 5), "at the ceiling, refuse");
2604        assert!(at_capacity(6, 5));
2605    }
2606
2607    #[test]
2608    fn intro_rate_gate_allows_up_to_max_then_429s_then_recovers() {
2609        // #247.3: 5 intros in the window pass, the 6th is rejected; after the
2610        // window elapses the old ones prune and intros flow again.
2611        let mut times: Vec<u64> = Vec::new();
2612        let t0 = 1_000_000u64;
2613        // 5 allowed at the same instant.
2614        for i in 0..5 {
2615            assert!(
2616                record_intro_within_rate(&mut times, t0, 300, 5),
2617                "intro {i} within limit must pass"
2618            );
2619        }
2620        // 6th in-window → rejected, and NOT recorded (still 5).
2621        assert!(!record_intro_within_rate(&mut times, t0 + 10, 300, 5));
2622        assert_eq!(times.len(), 5, "rejected intro must not be recorded");
2623        // Still rejected just inside the window.
2624        assert!(!record_intro_within_rate(&mut times, t0 + 299, 300, 5));
2625        // Past the window → all 5 prune → allowed again.
2626        assert!(record_intro_within_rate(&mut times, t0 + 301, 300, 5));
2627        assert_eq!(
2628            times.len(),
2629            1,
2630            "stale entries pruned, only the new one remains"
2631        );
2632    }
2633
2634    #[test]
2635    fn valid_public_relay_url_accepts_clean_https_and_ports() {
2636        assert!(is_valid_public_relay_url("https://wireup.net"));
2637        assert!(is_valid_public_relay_url("https://wireup.net/"));
2638        assert!(is_valid_public_relay_url("http://127.0.0.1:8771"));
2639        assert!(is_valid_public_relay_url(
2640            "https://relay.example.com:443/path"
2641        ));
2642    }
2643
2644    #[test]
2645    fn valid_public_relay_url_rejects_userinfo_and_bad_schemes() {
2646        // Audit L3 / the `handle@relay` userinfo bug.
2647        assert!(!is_valid_public_relay_url(
2648            "https://raven-kettle@wireup.net"
2649        ));
2650        assert!(!is_valid_public_relay_url("https://user:pass@host"));
2651        // Non-http schemes.
2652        assert!(!is_valid_public_relay_url("javascript:alert(1)"));
2653        assert!(!is_valid_public_relay_url("file:///etc/passwd"));
2654        assert!(!is_valid_public_relay_url("wireup.net")); // no scheme
2655        // Empty host / authority.
2656        assert!(!is_valid_public_relay_url("https://"));
2657        assert!(!is_valid_public_relay_url("https:///path"));
2658        // Whitespace / control.
2659        assert!(!is_valid_public_relay_url("https://wireup.net\n"));
2660        assert!(!is_valid_public_relay_url("https://wire up.net"));
2661    }
2662
2663    #[test]
2664    fn random_hex_length() {
2665        let s = random_hex(16);
2666        assert_eq!(s.len(), 32); // 16 bytes -> 32 hex chars
2667        assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
2668    }
2669
2670    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2671    async fn sweep_intro_times_drops_aged_nicks_off_the_hot_path() {
2672        let dir = std::env::temp_dir().join(format!("wire-introsweep-{}", random_hex(8)));
2673        let _ = std::fs::remove_dir_all(&dir);
2674        let relay = Relay::new(dir.clone()).await.unwrap();
2675        let now = unix_now();
2676        {
2677            let mut inner = relay.inner.lock().await;
2678            inner.intro_times.insert("fresh".into(), vec![now]); // within window
2679            inner
2680                .intro_times
2681                .insert("stale".into(), vec![now - INTRO_WINDOW_SECS - 1]); // aged out
2682            assert_eq!(inner.intro_times.len(), 2);
2683        }
2684        relay.sweep_intro_times().await;
2685        {
2686            let inner = relay.inner.lock().await;
2687            assert!(inner.intro_times.contains_key("fresh"), "fresh nick kept");
2688            assert!(!inner.intro_times.contains_key("stale"), "aged nick swept");
2689        }
2690        let _ = std::fs::remove_dir_all(&dir);
2691    }
2692
2693    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2694    async fn pair_slot_evicts_when_idle_past_ttl() {
2695        let dir = std::env::temp_dir().join(format!("wire-evict-{}", random_hex(8)));
2696        let _ = std::fs::remove_dir_all(&dir);
2697        let relay = Relay::new(dir.clone()).await.unwrap();
2698
2699        // Seed a pair-slot manually with a past last_touched.
2700        {
2701            let mut inner = relay.inner.lock().await;
2702            inner
2703                .pair_lookup
2704                .insert("hash-A".to_string(), "id-A".to_string());
2705            inner.pair_slots.insert(
2706                "id-A".to_string(),
2707                PairSlot {
2708                    last_touched: std::time::Instant::now()
2709                        - std::time::Duration::from_secs(PAIR_SLOT_TTL_SECS + 60),
2710                    ..PairSlot::default()
2711                },
2712            );
2713
2714            // And a fresh one — should survive.
2715            inner
2716                .pair_lookup
2717                .insert("hash-B".to_string(), "id-B".to_string());
2718            inner
2719                .pair_slots
2720                .insert("id-B".to_string(), PairSlot::default());
2721
2722            assert_eq!(inner.pair_slots.len(), 2);
2723            assert_eq!(inner.pair_lookup.len(), 2);
2724        }
2725
2726        relay.evict_expired_pair_slots().await;
2727
2728        let inner = relay.inner.lock().await;
2729        assert_eq!(
2730            inner.pair_slots.len(),
2731            1,
2732            "expired slot should have been evicted"
2733        );
2734        assert!(inner.pair_slots.contains_key("id-B"));
2735        assert_eq!(inner.pair_lookup.len(), 1);
2736        assert!(inner.pair_lookup.contains_key("hash-B"));
2737        let _ = std::fs::remove_dir_all(&dir);
2738    }
2739
2740    #[test]
2741    fn slot_id_validator_accepts_only_lowercase_32hex() {
2742        assert!(is_valid_slot_id("0123456789abcdef0123456789abcdef"));
2743        assert!(is_valid_slot_id(&random_hex(16)));
2744        // wrong length
2745        assert!(!is_valid_slot_id("abc"));
2746        assert!(!is_valid_slot_id("0123456789abcdef0123456789abcde")); // 31
2747        assert!(!is_valid_slot_id("0123456789abcdef0123456789abcdef0")); // 33
2748        // uppercase
2749        assert!(!is_valid_slot_id("0123456789ABCDEF0123456789abcdef"));
2750        // path traversal attempts
2751        assert!(!is_valid_slot_id("../etc/passwd0123456789abcdef0000"));
2752        assert!(!is_valid_slot_id("..%2Fetc%2Fpasswd00000000000000000"));
2753        assert!(!is_valid_slot_id("/absolute/path/that/looks/like/key"));
2754        // null bytes
2755        assert!(!is_valid_slot_id(
2756            "0123456789abcdef\0\x31\x32\x33456789abcdef"
2757        ));
2758    }
2759
2760    #[test]
2761    fn intro_filter_accepts_only_kind_1100_pair_drop_or_agent_card() {
2762        // The two legitimate intros: kind=1100 with pair_drop or agent_card.
2763        assert!(intro_event_allowed(1100, "pair_drop"));
2764        assert!(intro_event_allowed(1100, "agent_card"));
2765
2766        // #334 HIGH regression guards — the pre-fix `&&`-chain wrongly accepted
2767        // BOTH of these:
2768        // (a) a kind=1100 event of ANY other type must be rejected,
2769        assert!(!intro_event_allowed(1100, "decision"));
2770        assert!(!intro_event_allowed(1100, ""));
2771        assert!(!intro_event_allowed(1100, "trust_revoke_key"));
2772        // (b) a wrong-kind event whose type happens to match must be rejected.
2773        assert!(!intro_event_allowed(1, "pair_drop"));
2774        assert!(!intro_event_allowed(1101, "agent_card"));
2775        assert!(!intro_event_allowed(0, "pair_drop"));
2776
2777        // Neither matching → rejected (the only case the old logic got right).
2778        assert!(!intro_event_allowed(1, "decision"));
2779    }
2780
2781    #[test]
2782    fn stream_subscriber_ceiling_is_inclusive() {
2783        // Audit: a new SSE subscriber is refused once a slot reaches the cap.
2784        assert!(!at_capacity(0, MAX_STREAMS_PER_SLOT));
2785        assert!(!at_capacity(MAX_STREAMS_PER_SLOT - 1, MAX_STREAMS_PER_SLOT));
2786        assert!(at_capacity(MAX_STREAMS_PER_SLOT, MAX_STREAMS_PER_SLOT));
2787        assert!(at_capacity(MAX_STREAMS_PER_SLOT + 1, MAX_STREAMS_PER_SLOT));
2788    }
2789
2790    #[test]
2791    fn retain_live_subscribers_drops_disconnected() {
2792        // A disconnected SSE client = a dropped receiver → sender is_closed().
2793        let (live_tx, _live_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
2794        let (dead_tx, dead_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
2795        drop(dead_rx); // client disconnected
2796        let mut subs = vec![live_tx, dead_tx];
2797        retain_live_subscribers(&mut subs);
2798        assert_eq!(subs.len(), 1, "the disconnected subscriber is pruned");
2799        assert!(!subs[0].is_closed(), "the survivor is the live sender");
2800    }
2801
2802    #[test]
2803    fn evict_stale_intro_nicks_drops_only_fully_aged_entries() {
2804        let now = 10_000u64;
2805        let window = INTRO_WINDOW_SECS;
2806        let mut m: HashMap<String, Vec<u64>> = HashMap::new();
2807        m.insert("fresh".into(), vec![now - 1]); // within window → keep
2808        m.insert("stale".into(), vec![now - window - 1]); // all aged → drop
2809        m.insert("mixed".into(), vec![now - window - 5, now - 2]); // one fresh → keep
2810        m.insert("empty".into(), vec![]); // no timestamps → drop
2811        evict_stale_intro_nicks(&mut m, now, window);
2812        assert!(m.contains_key("fresh"));
2813        assert!(m.contains_key("mixed"));
2814        assert!(!m.contains_key("stale"));
2815        assert!(!m.contains_key("empty"));
2816    }
2817}