commit 0ad748f85c2dc18d75d1cfeb1d582d8d0451a948
parent dc617ddb71230a8fae98d648c9be7547f96edc5b
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 20:30:22 +0000
nip46: bind protocol transport and signer responses
- complete kind-24133 messages through one local protocol signer
- relay completed NIP-46 events through transport-only publication
- bind remote sign_event results to the exact author and canonical ID
- migrate strict service Profiles and low-level transport fixtures
- guard the retired generic event-authoring surface
Diffstat:
13 files changed, 267 insertions(+), 85 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1880,6 +1880,7 @@ dependencies = [
"mediatype",
"serde",
"sha2",
+ "unicode-general-category",
"url",
]
@@ -1904,6 +1905,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "unicode-general-category",
"url",
]
@@ -1912,6 +1914,7 @@ name = "radroots_event_codec"
version = "1.0.0-alpha.1"
dependencies = [
"nostr",
+ "radroots_blossom",
"radroots_core",
"radroots_event",
"serde",
@@ -3113,6 +3116,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/README b/README
@@ -3,6 +3,12 @@
This is the README for `radrootsd` which is a long-running daemon for
authenticated publishing and optional NIP-46 control on Rad Roots networks.
+NIP-46 kind-24133 request and response events are locally completed through a
+dedicated protocol helper and then relayed as already-signed transport
+messages. Remote `sign_event` results are accepted only when the author and
+canonical event ID match the exact unsigned request and the complete NIP-01
+event verifies.
+
## Copyright
Except as otherwise noted, all files in the `radrootsd` distribution are
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -18,10 +18,9 @@ use crate::transport::nostr::listener::spawn_nip46_listener;
#[cfg(not(test))]
use clap::Parser;
use radroots_event::profile::RadrootsAuthoredProfile;
-use radroots_event_codec::profile::authored::authored_profile_to_wire_parts;
use radroots_nostr::prelude::{
RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind,
- radroots_nostr_build_application_handler_event, radroots_nostr_build_event,
+ radroots_nostr_build_application_handler_event, radroots_nostr_build_profile_event,
};
use std::path::PathBuf;
@@ -297,13 +296,10 @@ fn build_service_presence_events(
profile: &RadrootsAuthoredProfile,
handler_spec: &RadrootsNostrApplicationHandlerSpec,
) -> Result<(nostr::Event, nostr::Event)> {
- let profile_wire = authored_profile_to_wire_parts(profile)
- .context("encode strict authored service Profile")?;
- let profile_event =
- radroots_nostr_build_event(profile_wire.kind, profile_wire.content, profile_wire.tags)
- .context("build service Profile event")?
- .sign_with_keys(identity.keys())
- .context("sign service Profile event")?;
+ let profile_event = radroots_nostr_build_profile_event(profile)
+ .context("build service Profile event")?
+ .sign_with_keys(identity.keys())
+ .context("sign service Profile event")?;
let handler_event = radroots_nostr_build_application_handler_event(handler_spec)
.context("build NIP-89 application handler event")?
.sign_with_keys(identity.keys())
diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs
@@ -3259,8 +3259,9 @@ mod tests {
NostrRelayUrlPolicy, TransportPublishConfig, TransportPublishNostrConfig,
};
use nostr::JsonUtil;
+ use nostr::{EventBuilder, Kind, Tag};
use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{RadrootsNostrTimestamp, radroots_nostr_build_event};
+ use radroots_nostr::prelude::RadrootsNostrTimestamp;
use radroots_transport::{
RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
RadrootsTransportTarget,
@@ -3383,15 +3384,13 @@ mod tests {
}
fn signed_event(identity: &RadrootsIdentity, content: &str) -> String {
- let event = radroots_nostr_build_event(
- 30_402,
- content,
- vec![vec!["d".to_owned(), "listing-1".to_owned()]],
- )
- .expect("event builder")
- .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
- .sign_with_keys(identity.keys())
- .expect("signed event");
+ // Transport tests require an already-signed wire fixture; they do not
+ // exercise a Radroots product-authoring boundary.
+ let event = EventBuilder::new(Kind::Custom(30_402), content)
+ .tag(Tag::identifier("listing-1"))
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
+ .sign_with_keys(identity.keys())
+ .expect("signed event");
event.as_json()
}
diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs
@@ -15,13 +15,14 @@ use crate::transport::jsonrpc::nip46::connection::{
};
use crate::transport::jsonrpc::params::DEFAULT_TIMEOUT_SECS;
use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
+use crate::transport::nostr::protocol::sign_nip46_message;
use nostr::JsonUtil;
use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest};
use radroots_nostr::prelude::{
- RadrootsNostrClient, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKeys,
- RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrRelayPoolNotification,
- RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, RadrootsNostrTimestamp,
- radroots_nostr_filter_tag, radroots_nostr_parse_pubkey,
+ RadrootsNostrClient, RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind,
+ RadrootsNostrPublicKey, RadrootsNostrRelayPoolNotification, RadrootsNostrSecretKey,
+ RadrootsNostrSubscriptionId, RadrootsNostrTimestamp, radroots_nostr_filter_tag,
+ radroots_nostr_parse_pubkey,
};
#[derive(Debug, Deserialize)]
@@ -287,11 +288,10 @@ async fn send_connect_request(
remote_signer_pubkey: &RadrootsNostrPublicKey,
message: NostrConnectMessage,
) -> Result<(), RpcError> {
- let event =
- RadrootsNostrEventBuilder::nostr_connect(client_keys, *remote_signer_pubkey, message)
- .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?;
+ let event = sign_nip46_message(client_keys, *remote_signer_pubkey, message)
+ .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?;
client
- .send_event_builder(event)
+ .send_event(&event)
.await
.map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?;
Ok(())
diff --git a/src/transport/jsonrpc/methods/nip46/session_authorize.rs b/src/transport/jsonrpc/methods/nip46/session_authorize.rs
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use nostr::nips::nip46::NostrConnectMessage;
use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError};
-use radroots_nostr::prelude::RadrootsNostrEventBuilder;
+use crate::transport::nostr::protocol::sign_nip46_message;
#[derive(Debug, Deserialize)]
struct Nip46SessionAuthorizeParams {
@@ -42,13 +42,11 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res
)
.await;
let message = NostrConnectMessage::response(pending.request_id, response);
- let response_event = RadrootsNostrEventBuilder::nostr_connect(
- &ctx.state.keys,
- pending.client_pubkey,
- message,
- )
- .map_err(|err| RpcError::Other(format!("nip46 response build failed: {err}")))?;
- let _ = ctx.state.client.send_event_builder(response_event).await;
+ let response_event =
+ sign_nip46_message(&ctx.state.keys, pending.client_pubkey, message).map_err(
+ |err| RpcError::Other(format!("nip46 response build failed: {err}")),
+ )?;
+ let _ = ctx.state.client.send_event(&response_event).await;
replayed = true;
}
Ok::<Nip46SessionAuthorizeResponse, RpcError>(Nip46SessionAuthorizeResponse {
diff --git a/src/transport/jsonrpc/methods/transport_publish.rs b/src/transport/jsonrpc/methods/transport_publish.rs
@@ -141,25 +141,22 @@ mod tests {
use crate::transport::jsonrpc::{MethodRegistry, RpcContext};
use jsonrpsee::server::RpcModule;
use nostr::JsonUtil;
+ use nostr::{EventBuilder, Kind, Tag};
use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{
- RadrootsNostrMetadata, RadrootsNostrTimestamp, radroots_nostr_build_event,
- };
+ use radroots_nostr::prelude::{RadrootsNostrMetadata, RadrootsNostrTimestamp};
use radroots_transport_nostr::RadrootsMockRelayPublishAdapter;
use radroots_transport_publish_protocol::{
NostrPublishTargetSourcePolicy, TransportPublishTargetPolicyName,
};
fn signed_event(identity: &RadrootsIdentity) -> String {
- let event = radroots_nostr_build_event(
- 30_402,
- "{}",
- vec![vec!["d".to_owned(), "listing-1".to_owned()]],
- )
- .expect("event builder")
- .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
- .sign_with_keys(identity.keys())
- .expect("signed event");
+ // This method accepts an already-signed wire event; construct the test
+ // fixture at that explicit low-level interoperability boundary.
+ let event = EventBuilder::new(Kind::Custom(30_402), "{}")
+ .tag(Tag::identifier("listing-1"))
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
+ .sign_with_keys(identity.keys())
+ .expect("signed event");
event.as_json()
}
diff --git a/src/transport/jsonrpc/nip46/client.rs b/src/transport/jsonrpc/nip46/client.rs
@@ -4,6 +4,7 @@ use std::time::Duration;
use crate::core::nip46::session::Nip46Session;
use crate::transport::jsonrpc::{RpcError, params::DEFAULT_TIMEOUT_SECS};
+use crate::transport::nostr::protocol::sign_nip46_message;
use nostr::JsonUtil;
use nostr::UnsignedEvent;
use nostr::nips::{
@@ -11,18 +12,22 @@ use nostr::nips::{
nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult},
};
use radroots_nostr::prelude::{
- RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind,
- RadrootsNostrRelayPoolNotification, RadrootsNostrSubscriptionId, RadrootsNostrTimestamp,
- radroots_nostr_filter_tag,
+ RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrRelayPoolNotification,
+ RadrootsNostrSubscriptionId, RadrootsNostrTimestamp, radroots_nostr_filter_tag,
};
use tokio::sync::broadcast;
use tokio::time::sleep;
pub async fn sign_event(
session: &Nip46Session,
- unsigned: UnsignedEvent,
+ mut unsigned: UnsignedEvent,
label: &str,
) -> Result<nostr::Event, RpcError> {
+ unsigned.verify_id().map_err(|_| {
+ RpcError::InvalidParams(format!("nip46 {label} unsigned event ID mismatch"))
+ })?;
+ let expected_public_key = unsigned.pubkey;
+ let expected_event_id = unsigned.id();
let req = NostrConnectRequest::SignEvent(unsigned);
let response = request(session, req, label).await?;
let response = response
@@ -43,11 +48,7 @@ pub async fn sign_event(
None => return Err(RpcError::Other(format!("nip46 {label} missing response"))),
};
- event
- .verify()
- .map_err(|e| RpcError::Other(format!("nip46 {label} invalid event: {e}")))?;
-
- Ok(event)
+ validate_signed_event_response(expected_public_key, expected_event_id, event, label)
}
pub async fn request(
@@ -70,16 +71,12 @@ pub async fn request(
.subscribe(filter, None)
.await
.map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
- let event = RadrootsNostrEventBuilder::nostr_connect(
- &session.client_keys,
- session.remote_signer_pubkey,
- message,
- )
- .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
+ let event = sign_nip46_message(&session.client_keys, session.remote_signer_pubkey, message)
+ .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?;
if let Err(error) = session
.client
- .send_event_builder(event)
+ .send_event(&event)
.await
.map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))
{
@@ -97,6 +94,28 @@ pub async fn request(
.await
}
+fn validate_signed_event_response(
+ expected_public_key: nostr::PublicKey,
+ expected_event_id: nostr::EventId,
+ event: nostr::Event,
+ label: &str,
+) -> Result<nostr::Event, RpcError> {
+ if event.pubkey != expected_public_key {
+ return Err(RpcError::Other(format!(
+ "nip46 {label} response author mismatch"
+ )));
+ }
+ if event.id != expected_event_id {
+ return Err(RpcError::Other(format!(
+ "nip46 {label} response event ID mismatch"
+ )));
+ }
+ event
+ .verify()
+ .map_err(|_| RpcError::Other(format!("nip46 {label} response event is invalid")))?;
+ Ok(event)
+}
+
fn response_filter(
session: &Nip46Session,
since: RadrootsNostrTimestamp,
@@ -160,3 +179,71 @@ async fn wait_for_response(
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use nostr::{EventBuilder, EventId, Kind, Timestamp};
+ use radroots_nostr::prelude::RadrootsNostrKeys;
+
+ use super::validate_signed_event_response;
+
+ fn signed_fixture() -> (nostr::UnsignedEvent, nostr::Event) {
+ let keys = RadrootsNostrKeys::generate();
+ let unsigned = EventBuilder::new(Kind::Custom(30_001), "checked")
+ .custom_created_at(Timestamp::from_secs(1_784_347_200))
+ .build(keys.public_key());
+ let event = unsigned
+ .clone()
+ .sign_with_keys(&keys)
+ .expect("signed fixture");
+ (unsigned, event)
+ }
+
+ #[test]
+ fn sign_event_response_accepts_only_the_exact_valid_event() {
+ let (mut unsigned, event) = signed_fixture();
+ let expected_public_key = unsigned.pubkey;
+ let expected_event_id = unsigned.id();
+ assert!(
+ validate_signed_event_response(
+ expected_public_key,
+ expected_event_id,
+ event.clone(),
+ "test",
+ )
+ .is_ok()
+ );
+
+ let (_, wrong_author) = signed_fixture();
+ let error = validate_signed_event_response(
+ expected_public_key,
+ expected_event_id,
+ wrong_author,
+ "test",
+ )
+ .expect_err("wrong author");
+ assert!(error.to_string().contains("author mismatch"));
+
+ let mut wrong_id = event.clone();
+ wrong_id.id = EventId::all_zeros();
+ let error = validate_signed_event_response(
+ expected_public_key,
+ expected_event_id,
+ wrong_id,
+ "test",
+ )
+ .expect_err("wrong event ID");
+ assert!(error.to_string().contains("event ID mismatch"));
+
+ let mut wrong_signature = event;
+ wrong_signature.content.push('!');
+ let error = validate_signed_event_response(
+ expected_public_key,
+ expected_event_id,
+ wrong_signature,
+ "test",
+ )
+ .expect_err("invalid event");
+ assert!(error.to_string().contains("event is invalid"));
+ }
+}
diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs
@@ -134,10 +134,9 @@ mod tests {
use crate::transport::jsonrpc::{MethodRegistry, RpcContext};
use jsonrpsee::server::RpcModule;
use nostr::JsonUtil;
+ use nostr::{EventBuilder, Kind, Tag};
use radroots_identity::RadrootsIdentity;
- use radroots_nostr::prelude::{
- RadrootsNostrMetadata, RadrootsNostrTimestamp, radroots_nostr_build_event,
- };
+ use radroots_nostr::prelude::{RadrootsNostrMetadata, RadrootsNostrTimestamp};
use radroots_transport_nostr::RadrootsMockRelayPublishAdapter;
use radroots_transport_publish_protocol::{
NostrPublishTargetSourcePolicy, TransportPublishTargetPolicyName,
@@ -156,16 +155,13 @@ mod tests {
}
fn signed_event_json(identity: &RadrootsIdentity) -> String {
- radroots_nostr_build_event(
- 30_402,
- "{}",
- vec![vec!["d".to_owned(), "listing-1".to_owned()]],
- )
- .expect("event builder")
- .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
- .sign_with_keys(identity.keys())
- .expect("signed event")
- .as_json()
+ // The JSON-RPC server consumes an already-signed transport fixture.
+ EventBuilder::new(Kind::Custom(30_402), "{}")
+ .tag(Tag::identifier("listing-1"))
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000))
+ .sign_with_keys(identity.keys())
+ .expect("signed event")
+ .as_json()
}
async fn post_json(addr: SocketAddr, body: &str, token: Option<&str>) -> String {
diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs
@@ -14,9 +14,10 @@ use crate::core::nip46::session::{
Nip46Session, PendingNostrRequest, session_expires_at, sign_event_allowed,
};
use crate::core::state::Radrootsd;
+use crate::transport::nostr::protocol::sign_nip46_message;
use radroots_nostr::prelude::{
- RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind,
- RadrootsNostrRelayPoolNotification, RadrootsNostrTimestamp, radroots_nostr_filter_tag,
+ RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrRelayPoolNotification,
+ RadrootsNostrTimestamp, radroots_nostr_filter_tag,
};
const DEFAULT_TIMEOUT_SECS: u64 = 10;
@@ -90,13 +91,9 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> {
};
let response = handle_request(&radrootsd, &event.pubkey, &request_id, request).await;
let response_message = NostrConnectMessage::response(request_id, response);
- let response_event = RadrootsNostrEventBuilder::nostr_connect(
- &radrootsd.keys,
- event.pubkey,
- response_message,
- )
- .map_err(|err| anyhow!("nip46 response build failed: {err}"))?;
- let _ = radrootsd.client.send_event_builder(response_event).await;
+ let response_event = sign_nip46_message(&radrootsd.keys, event.pubkey, response_message)
+ .map_err(|err| anyhow!("nip46 response build failed: {err}"))?;
+ let _ = radrootsd.client.send_event(&response_event).await;
}
}
diff --git a/src/transport/nostr/mod.rs b/src/transport/nostr/mod.rs
@@ -1 +1,2 @@
pub mod listener;
+pub(crate) mod protocol;
diff --git a/src/transport/nostr/protocol.rs b/src/transport/nostr/protocol.rs
@@ -0,0 +1,55 @@
+#![forbid(unsafe_code)]
+
+use nostr::nips::nip46::NostrConnectMessage;
+use nostr::{Event, EventBuilder};
+use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrPublicKey};
+
+/// Encrypts and locally signs a NIP-46 transport message.
+///
+/// The completed event is returned for transport-only publication. NIP-46
+/// kind 24133 is protocol traffic, not a generic Radroots product-authoring
+/// surface.
+pub(crate) fn sign_nip46_message(
+ sender_keys: &RadrootsNostrKeys,
+ receiver_pubkey: RadrootsNostrPublicKey,
+ message: NostrConnectMessage,
+) -> Result<Event, nostr::event::builder::Error> {
+ EventBuilder::nostr_connect(sender_keys, receiver_pubkey, message)?.sign_with_keys(sender_keys)
+}
+
+#[cfg(test)]
+mod tests {
+ use nostr::JsonUtil;
+ use nostr::nips::{nip44, nip46::NostrConnectRequest};
+ use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrKind};
+
+ use super::sign_nip46_message;
+
+ #[test]
+ fn signed_nip46_message_is_bound_to_sender_and_receiver() {
+ let sender = RadrootsNostrKeys::generate();
+ let receiver = RadrootsNostrKeys::generate();
+ let message = nostr::nips::nip46::NostrConnectMessage::request(&NostrConnectRequest::Ping);
+ let request_id = message.id().to_owned();
+
+ let event = sign_nip46_message(&sender, receiver.public_key(), message)
+ .expect("signed NIP-46 message");
+
+ assert_eq!(event.kind, RadrootsNostrKind::NostrConnect);
+ assert_eq!(event.pubkey, sender.public_key());
+ assert!(
+ event
+ .tags
+ .iter()
+ .any(|tag| { tag.as_slice() == ["p".to_owned(), receiver.public_key().to_hex()] })
+ );
+ assert!(event.verify().is_ok());
+
+ let plaintext = nip44::decrypt(receiver.secret_key(), &sender.public_key(), &event.content)
+ .expect("decrypt NIP-46 message");
+ let decrypted = nostr::nips::nip46::NostrConnectMessage::from_json(plaintext)
+ .expect("parse NIP-46 message");
+ assert!(decrypted.is_request());
+ assert_eq!(decrypted.id(), request_id);
+ }
+}
diff --git a/tests/source_boundary.rs b/tests/source_boundary.rs
@@ -690,3 +690,44 @@ fn line_number(source: &str, index: usize) -> usize {
.count()
+ 1
}
+
+#[test]
+fn nip46_transport_uses_completed_events_and_exact_sign_event_binding() {
+ let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+ let protocol = read_source(
+ manifest_dir
+ .join("src/transport/nostr/protocol.rs")
+ .as_path(),
+ );
+ let client = read_source(
+ manifest_dir
+ .join("src/transport/jsonrpc/nip46/client.rs")
+ .as_path(),
+ );
+ let production_sources = [
+ "src/transport/nostr/listener.rs",
+ "src/transport/jsonrpc/nip46/client.rs",
+ "src/transport/jsonrpc/methods/nip46/connect.rs",
+ "src/transport/jsonrpc/methods/nip46/session_authorize.rs",
+ ]
+ .map(|path| read_source(manifest_dir.join(path).as_path()))
+ .join("\n");
+
+ assert!(protocol.contains("EventBuilder::nostr_connect"));
+ assert!(protocol.contains(".sign_with_keys(sender_keys)"));
+ assert!(!production_sources.contains("RadrootsNostrEventBuilder"));
+ assert!(!production_sources.contains("radroots_nostr_build_event"));
+ assert!(!production_sources.contains(".send_event_builder("));
+ for required in [
+ "unsigned.verify_id()",
+ "let expected_event_id = unsigned.id()",
+ "event.pubkey != expected_public_key",
+ "event.id != expected_event_id",
+ ".verify()",
+ ] {
+ assert!(
+ client.contains(required),
+ "NIP-46 client must retain exact sign_event binding witness `{required}`"
+ );
+ }
+}