mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
mesh-llm: Max review fixups — expires_at, EndpointAddr, same-relay filter
Per Max's pre-PR review of d9a791f:
1. Add MeshLlmOffer.expires_at: u64 (unix seconds). Hard-required by
serde (no default) since consumers depend on it for correctness:
crashed publishers cannot send the NIP-33 delete-by-replace
tombstone, so the TTL is the only thing that reaps stale offers.
New helpers + tests in sprout-core::mesh_llm:
- is_expired(now) returns true when expires_at <= now.
- matches_local_relay(current_relay) compares against the relay's
NIP-11 iroh_relay_url with lightweight canonicalisation
(lower-case scheme/host, trailing-slash collapse, query/fragment
drop). v1 invariant: one relay = one mesh boundary.
- is_publishable now rejects expires_at == 0.
- canonical_relay_url() is a small private helper, deliberately
separate from sprout_auth::nip98_canonical_url (different jobs).
5 new tests (expires_at_required, is_expired_filter,
matches_local_relay_canonicalises, is_publishable_rejects_zero_*,
plus updated JSON fixtures throughout).
2. Fix doc wording: iroh NodeAddr -> EndpointAddr (rc.0 naming).
Soften 'multiple relays bridging into membership scope' language to
reflect that v1 is strictly single-relay and the field is reserved
for future cross-relay only.
3. Desktop publisher (mesh_publish_offer) now computes
expires_at = now + OFFER_TTL_SECS (15 min) and threads it through
build_offer(endpoint_id, iroh_relay_url, expires_at). The frontend
hook is expected to re-invoke publish on heartbeat well before the
deadline (heartbeat plumbing is in the next commit).
4. Frontend useMeshLlmOffers hook:
- probes the relay's NIP-11 iroh_relay_url once on mount and
filters offers whose iroh_relay_url doesn't match (v1 same-relay
invariant). When the relay doesn't advertise mesh-LLM, the filter
correctly empties the list.
- rejects events with schema v != 1 before storing.
- 30s setInterval ticks 'now' so expired offers drop without waiting
for a new event. O(1) timer regardless of how many offers are in
the cache.
- mirrors the canonicaliser logic from sprout-core in TS so JS-side
accept/reject decisions match the Rust check.
Tests after change:
- sprout-core --lib: 174 -> 178 (+4 mesh_llm)
- desktop mesh_llm --lib: 11 unchanged (publisher signature change
required updating build_offer call sites; tests pass exact-same set).
- pnpm typecheck + pnpm check (biome + file-sizes): clean.
- cargo clippy --workspace -- -D warnings: clean.
- cargo fmt --all -- --check: clean.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -61,20 +61,35 @@ pub struct MeshLlmOffer {
|
||||
|
||||
/// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh
|
||||
/// renders it) of the offering node's iroh endpoint. Consumers dial
|
||||
/// this through an iroh `NodeAddr`.
|
||||
/// this through an iroh `EndpointAddr` constructed from
|
||||
/// `(endpoint_id, iroh_relay_url)`.
|
||||
pub endpoint_id: String,
|
||||
|
||||
/// Iroh relay URL through which the offering endpoint is reachable.
|
||||
///
|
||||
/// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from
|
||||
/// the publisher's view of NIP-11 `iroh_relay_url`. If multiple Sprout
|
||||
/// relays are bridged into the same membership scope in the future,
|
||||
/// this lets a consumer reach an offer behind a different host.
|
||||
/// the publisher's view of NIP-11 `iroh_relay_url`. The field is
|
||||
/// preserved for future cross-relay bridging, but **v1 consumers MUST
|
||||
/// ignore offers whose `iroh_relay_url` doesn't match the current
|
||||
/// relay's NIP-11 `iroh_relay_url`** (see [`Self::matches_local_relay`]).
|
||||
/// This keeps "one relay = one mesh boundary" as an enforced invariant
|
||||
/// until cross-relay membership is explicitly designed.
|
||||
pub iroh_relay_url: String,
|
||||
|
||||
/// Unix-seconds timestamp at which this offer becomes stale. Consumers
|
||||
/// MUST ignore offers where `expires_at <= now` (publishers SHOULD
|
||||
/// republish a fresh offer well before this deadline to act as a
|
||||
/// heartbeat). Because crashed publishers cannot send the NIP-33
|
||||
/// delete-by-replace tombstone, this TTL is the only thing that
|
||||
/// removes their offers from the consumer view.
|
||||
pub expires_at: u64,
|
||||
|
||||
/// Resource caps the offering side promises to honour for any single
|
||||
/// consumer at a time. The publisher should re-publish (replacing the
|
||||
/// previous event) whenever these change materially.
|
||||
/// previous event) whenever these change materially. **These are
|
||||
/// claims/UI hints, not authority** — the provider runtime must
|
||||
/// enforce its own caps locally; the consumer cannot rely on the
|
||||
/// publisher to honour them at admission time.
|
||||
pub caps: ResourceCaps,
|
||||
|
||||
/// Models this node is willing to serve. Empty list = "negotiate at
|
||||
@@ -157,12 +172,83 @@ impl MeshLlmOffer {
|
||||
///
|
||||
/// This is a *publisher-side* sanity check; consumers should be
|
||||
/// permissive in what they accept as long as serde-deserialization
|
||||
/// succeeds.
|
||||
/// succeeds (modulo the [`Self::is_expired`] and
|
||||
/// [`Self::matches_local_relay`] filters below).
|
||||
pub fn is_publishable(&self) -> bool {
|
||||
self.v == 1
|
||||
&& Self::is_valid_d_tag(&self.d_tag)
|
||||
&& !self.endpoint_id.is_empty()
|
||||
&& !self.iroh_relay_url.is_empty()
|
||||
&& self.expires_at > 0
|
||||
}
|
||||
|
||||
/// Consumer-side TTL check. Returns `true` when `expires_at <= now`,
|
||||
/// in which case the offer must be ignored (crashed publishers can't
|
||||
/// send a delete-by-replace tombstone; the TTL is the only reaper).
|
||||
///
|
||||
/// `now` is unix-seconds — callers in this crate pass `Timestamp::now()`
|
||||
/// or a test clock to avoid pulling `std::time::SystemTime` into the
|
||||
/// trust path.
|
||||
pub fn is_expired(&self, now: u64) -> bool {
|
||||
self.expires_at <= now
|
||||
}
|
||||
|
||||
/// Consumer-side same-relay filter for v1 discovery. Returns `true`
|
||||
/// when the offer's advertised `iroh_relay_url` matches `current_relay`
|
||||
/// after canonicalisation (lower-case scheme/host, trailing slash on
|
||||
/// path collapsed, query/fragment dropped).
|
||||
///
|
||||
/// **v1 consumers MUST ignore offers where this returns `false`.** The
|
||||
/// invariant is "one relay = one mesh boundary"; cross-relay bridging
|
||||
/// is reserved for a future explicit design.
|
||||
pub fn matches_local_relay(&self, current_relay: &str) -> bool {
|
||||
canonical_relay_url(&self.iroh_relay_url) == canonical_relay_url(current_relay)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight URL canonicaliser used only for the same-relay filter. Not
|
||||
/// to be confused with [`sprout_auth::nip98_canonical_url`], which has a
|
||||
/// different job (computing the `u`-tag value); this one just strips
|
||||
/// query/fragment, lower-cases scheme/host, and collapses one trailing
|
||||
/// slash so users who paste `https://r.example.com/iroh/` see it match
|
||||
/// `https://r.example.com/iroh`.
|
||||
fn canonical_relay_url(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
// Split off query + fragment. Compose the splits in two steps so the
|
||||
// second split operates on the result of the first, not on `trimmed`.
|
||||
let no_query = match trimmed.split_once('?') {
|
||||
Some((h, _)) => h,
|
||||
None => trimmed,
|
||||
};
|
||||
let no_frag = match no_query.split_once('#') {
|
||||
Some((h, _)) => h,
|
||||
None => no_query,
|
||||
};
|
||||
let head = if let Some(stripped) = no_frag.strip_suffix('/') {
|
||||
// Only strip *one* trailing slash — don't collapse repeated
|
||||
// slashes (those are real path components).
|
||||
stripped
|
||||
} else {
|
||||
no_frag
|
||||
};
|
||||
|
||||
// Lower-case the scheme+authority portion; leave the path case-sensitive.
|
||||
if let Some(idx) = head.find("://") {
|
||||
let (scheme, rest) = head.split_at(idx);
|
||||
// rest starts with "://"; authority ends at next '/'.
|
||||
let after_proto = &rest[3..];
|
||||
let (authority, path) = match after_proto.find('/') {
|
||||
Some(p) => after_proto.split_at(p),
|
||||
None => (after_proto, ""),
|
||||
};
|
||||
format!(
|
||||
"{}://{}{}",
|
||||
scheme.to_ascii_lowercase(),
|
||||
authority.to_ascii_lowercase(),
|
||||
path,
|
||||
)
|
||||
} else {
|
||||
head.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +262,7 @@ mod tests {
|
||||
d_tag: "node-1".to_string(),
|
||||
endpoint_id: "1234abcd".to_string(),
|
||||
iroh_relay_url: "https://relay.example.com/iroh".to_string(),
|
||||
expires_at: 2_000_000_000, // far-future fixture
|
||||
caps: ResourceCaps {
|
||||
max_vram_mb: Some(24_000),
|
||||
max_ram_mb: Some(64_000),
|
||||
@@ -205,6 +292,7 @@ mod tests {
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r/",
|
||||
"expires_at": 2000000000,
|
||||
"caps": {}
|
||||
}"#;
|
||||
let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal");
|
||||
@@ -222,6 +310,7 @@ mod tests {
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r",
|
||||
"expires_at": 2000000000,
|
||||
"caps": {},
|
||||
"wat": "lol"
|
||||
}"#;
|
||||
@@ -235,11 +324,60 @@ mod tests {
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r",
|
||||
"expires_at": 2000000000,
|
||||
"caps": { "wat": 7 }
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<MeshLlmOffer>(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_at_required() {
|
||||
// expires_at has no serde default; missing it is a hard error
|
||||
// (consumers depend on TTL for correctness).
|
||||
let s = r#"{
|
||||
"v": 1,
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r",
|
||||
"caps": {}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<MeshLlmOffer>(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expired_filter() {
|
||||
let mut offer = sample();
|
||||
offer.expires_at = 1_000;
|
||||
assert!(offer.is_expired(2_000), "now > expires_at must expire");
|
||||
assert!(offer.is_expired(1_000), "now == expires_at must expire");
|
||||
assert!(!offer.is_expired(999), "now < expires_at must not expire");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_local_relay_canonicalises() {
|
||||
let mut offer = sample();
|
||||
offer.iroh_relay_url = "https://relay.example.com/iroh".to_string();
|
||||
// exact match
|
||||
assert!(offer.matches_local_relay("https://relay.example.com/iroh"));
|
||||
// trailing slash on one side
|
||||
assert!(offer.matches_local_relay("https://relay.example.com/iroh/"));
|
||||
// upper-case host
|
||||
assert!(offer.matches_local_relay("HTTPS://Relay.Example.COM/iroh"));
|
||||
// query/fragment stripped
|
||||
assert!(offer.matches_local_relay("https://relay.example.com/iroh?x=1#y"));
|
||||
// different host -> reject
|
||||
assert!(!offer.matches_local_relay("https://other.example.com/iroh"));
|
||||
// different path -> reject (different mesh boundary)
|
||||
assert!(!offer.matches_local_relay("https://relay.example.com/other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_publishable_rejects_zero_expires_at() {
|
||||
let mut offer = sample();
|
||||
offer.expires_at = 0;
|
||||
assert!(!offer.is_publishable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_freeform_passes_through() {
|
||||
let offer = MeshLlmOffer {
|
||||
|
||||
@@ -119,8 +119,17 @@ pub async fn mesh_publish_offer(
|
||||
let d_tag_tag = Tag::parse(["d", &d_tag]).map_err(|e| format!("d tag: {e}"))?;
|
||||
|
||||
let (content, published_offer) = if prefs.enabled {
|
||||
// expires_at = now + OFFER_TTL_SECS. The frontend hook re-invokes
|
||||
// mesh_publish_offer on a heartbeat well before the deadline; if
|
||||
// the publisher crashes before the next heartbeat, consumers reap
|
||||
// the offer once `now > expires_at` (see MeshLlmOffer::is_expired).
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| format!("system clock: {e}"))?
|
||||
.as_secs();
|
||||
let expires_at = now + mesh_llm::offer::OFFER_TTL_SECS;
|
||||
let offer = prefs
|
||||
.build_offer(&endpoint_id_str, &iroh_relay_url)
|
||||
.build_offer(&endpoint_id_str, &iroh_relay_url, expires_at)
|
||||
.ok_or_else(|| {
|
||||
"build_offer returned None despite enabled=true (logic bug)".to_string()
|
||||
})?;
|
||||
|
||||
@@ -75,10 +75,15 @@ impl ComputeSharingPrefs {
|
||||
/// Builds the kind:31990 offer envelope to publish. Returns `None` if
|
||||
/// sharing is disabled; the publisher should then *delete* any prior
|
||||
/// offer rather than calling this.
|
||||
///
|
||||
/// `expires_at` is the unix-seconds deadline after which consumers will
|
||||
/// drop this offer. Callers pass `now + OFFER_TTL_SECS` so an
|
||||
/// unannounced publisher crash naturally reaps the stale offer.
|
||||
pub fn build_offer(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
iroh_relay_url: &str,
|
||||
expires_at: u64,
|
||||
) -> Option<MeshLlmOffer> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
@@ -88,6 +93,7 @@ impl ComputeSharingPrefs {
|
||||
d_tag: self.d_tag.clone(),
|
||||
endpoint_id: endpoint_id.to_string(),
|
||||
iroh_relay_url: iroh_relay_url.to_string(),
|
||||
expires_at,
|
||||
caps: self.caps.clone(),
|
||||
models: self.models.clone(),
|
||||
extra: None,
|
||||
@@ -95,6 +101,11 @@ impl ComputeSharingPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default offer TTL: 15 minutes. Publishers republish on each prefs
|
||||
/// change *and* should heartbeat at ~`OFFER_TTL_SECS/3` so a one-missed
|
||||
/// heartbeat still leaves the offer visible.
|
||||
pub const OFFER_TTL_SECS: u64 = 15 * 60;
|
||||
|
||||
fn prefs_path(app: &AppHandle) -> Result<PathBuf, OfferPrefsError> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
@@ -149,7 +160,7 @@ mod tests {
|
||||
let prefs = ComputeSharingPrefs::default();
|
||||
assert!(
|
||||
prefs
|
||||
.build_offer("endpoint", "https://relay/iroh")
|
||||
.build_offer("endpoint", "https://relay/iroh", 2_000_000_000)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
@@ -161,10 +172,15 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let offer = prefs
|
||||
.build_offer("endpoint-id-hex", "https://relay.example.com/iroh")
|
||||
.build_offer(
|
||||
"endpoint-id-hex",
|
||||
"https://relay.example.com/iroh",
|
||||
2_000_000_000,
|
||||
)
|
||||
.expect("offer");
|
||||
assert_eq!(offer.endpoint_id, "endpoint-id-hex");
|
||||
assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh");
|
||||
assert_eq!(offer.expires_at, 2_000_000_000);
|
||||
assert!(offer.is_publishable());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
@@ -12,6 +13,8 @@ export interface MeshLlmOffer {
|
||||
d_tag: string;
|
||||
endpoint_id: string;
|
||||
iroh_relay_url: string;
|
||||
/** Unix-seconds deadline; consumers ignore offers where `expires_at <= now`. */
|
||||
expires_at: number;
|
||||
caps: {
|
||||
max_vram_mb?: number | null;
|
||||
max_ram_mb?: number | null;
|
||||
@@ -44,14 +47,50 @@ function extractDTag(event: RelayEvent): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalise a relay URL the same way `sprout_core::mesh_llm`'s same-relay
|
||||
* filter does, so the JS side can't accept an offer the Rust schema check
|
||||
* would reject. (See `MeshLlmOffer::matches_local_relay` in core.)
|
||||
*/
|
||||
function canonicalRelayUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const noQuery = trimmed.split("?")[0] ?? trimmed;
|
||||
const noFrag = noQuery.split("#")[0] ?? noQuery;
|
||||
const noTrail = noFrag.endsWith("/") ? noFrag.slice(0, -1) : noFrag;
|
||||
const protoIdx = noTrail.indexOf("://");
|
||||
if (protoIdx === -1) return noTrail;
|
||||
const scheme = noTrail.slice(0, protoIdx).toLowerCase();
|
||||
const rest = noTrail.slice(protoIdx + 3);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash === -1) {
|
||||
return `${scheme}://${rest.toLowerCase()}`;
|
||||
}
|
||||
const authority = rest.slice(0, slash).toLowerCase();
|
||||
const path = rest.slice(slash);
|
||||
return `${scheme}://${authority}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How often (in ms) the hook recomputes the rendered list so freshly
|
||||
* expired offers drop without waiting for a new event to arrive. Tied to a
|
||||
* setInterval rather than per-offer setTimeouts so the cost is O(1) no
|
||||
* matter how many offers are in the cache.
|
||||
*/
|
||||
const EXPIRY_TICK_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Subscribe to live mesh-LLM offers from the connected relay.
|
||||
*
|
||||
* Returns the de-duplicated set of *currently-active* offers (keyed by
|
||||
* `(pubkey, d_tag)` per NIP-33). An event with empty `content` is treated
|
||||
* as 'offer withdrawn' and removes the corresponding entry — this is the
|
||||
* NIP-33 delete-by-replace idiom the Rust publisher emits when the user
|
||||
* toggles compute-sharing off.
|
||||
* `(pubkey, d_tag)` per NIP-33), filtered to:
|
||||
* - the current relay's iroh-relay URL (v1 invariant: one relay = one mesh
|
||||
* boundary). Offers advertising a different `iroh_relay_url` are dropped.
|
||||
* - non-expired offers (`expires_at > now`). Crashed publishers can't send
|
||||
* the NIP-33 delete-by-replace tombstone; the TTL is the reaper.
|
||||
*
|
||||
* An event with empty `content` is treated as 'offer withdrawn' and removes
|
||||
* the corresponding entry — this is the NIP-33 delete-by-replace idiom the
|
||||
* Rust publisher emits when the user toggles compute-sharing off.
|
||||
*/
|
||||
export function useMeshLlmOffers(): {
|
||||
offers: ResolvedOffer[];
|
||||
@@ -59,6 +98,39 @@ export function useMeshLlmOffers(): {
|
||||
} {
|
||||
const [offers, setOffers] = useState<Map<string, ResolvedOffer>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [localRelay, setLocalRelay] = useState<string | null>(null);
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
|
||||
// Discover the current relay's iroh_relay_url once so we can filter
|
||||
// offers against it. If the relay doesn't advertise one, the same-relay
|
||||
// filter rejects everything and the panel correctly shows the empty
|
||||
// state.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const ws = await invoke<string>("get_relay_ws_url");
|
||||
const irohUrl = await invoke<string | null>("mesh_relay_iroh_url", {
|
||||
relayWsUrl: ws,
|
||||
});
|
||||
if (!cancelled) setLocalRelay(irohUrl ?? null);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic re-tick so expired offers fall out of the rendered list even
|
||||
// when no new events arrive.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setNowSec(Math.floor(Date.now() / 1000));
|
||||
}, EXPIRY_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -89,6 +161,9 @@ export function useMeshLlmOffers(): {
|
||||
// poison the list.
|
||||
return;
|
||||
}
|
||||
// Reject obviously-bad schema versions before storing.
|
||||
if (parsed.v !== 1) return;
|
||||
|
||||
setOffers((prev) => {
|
||||
const existing = prev.get(key);
|
||||
if (existing && existing.createdAt >= event.created_at) {
|
||||
@@ -125,9 +200,18 @@ export function useMeshLlmOffers(): {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sort newest first for the UI.
|
||||
const list = Array.from(offers.values()).sort(
|
||||
(a, b) => b.createdAt - a.createdAt,
|
||||
);
|
||||
// Filter on every render so newly-expired offers drop without a refresh
|
||||
// event, and so the same-relay filter applies as soon as the NIP-11
|
||||
// probe completes.
|
||||
const localCanonical =
|
||||
localRelay != null ? canonicalRelayUrl(localRelay) : null;
|
||||
const list = Array.from(offers.values())
|
||||
.filter((entry) => entry.offer.expires_at > nowSec)
|
||||
.filter((entry) => {
|
||||
if (localCanonical == null) return false;
|
||||
return canonicalRelayUrl(entry.offer.iroh_relay_url) === localCanonical;
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
return { offers: list, error };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user