commit 8de30640731b40dc3135fdae781efc3a096047b9
parent d4cf0b41c681051489c3fc3c8501ba7ecbacf690
Author: triesap <tyson@radroots.org>
Date: Fri, 17 Jul 2026 22:00:25 +0000
nostr_connect: add current NIP-46 conformance vectors
- check in deterministic current-session protocol cases
- execute vectors across requests URIs responses and failures
- document portable signer and relay-client lifecycle behavior
- qualify the combined public crate regression surface
Diffstat:
5 files changed, 543 insertions(+), 0 deletions(-)
diff --git a/contracts/conformance/vectors/nip46/current_session.v1.json b/contracts/conformance/vectors/nip46/current_session.v1.json
@@ -0,0 +1,350 @@
+{
+ "suite": "nip46_current_session",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "nip46_connect_legacy_one_parameter_001",
+ "kind": "nip46.request.valid",
+ "input": {
+ "message": {
+ "id": "connect-legacy-1",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ]
+ }
+ },
+ "expected": {
+ "normalized_message": {
+ "id": "connect-legacy-1",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ]
+ }
+ }
+ },
+ {
+ "id": "nip46_connect_legacy_two_parameters_002",
+ "kind": "nip46.request.valid",
+ "input": {
+ "message": {
+ "id": "connect-legacy-2",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "fixture-client-secret"
+ ]
+ }
+ },
+ "expected": {
+ "normalized_message": {
+ "id": "connect-legacy-2",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "fixture-client-secret"
+ ]
+ }
+ }
+ },
+ {
+ "id": "nip46_connect_legacy_three_parameters_003",
+ "kind": "nip46.request.valid",
+ "input": {
+ "message": {
+ "id": "connect-legacy-3",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "fixture-client-secret",
+ "nip44_encrypt,sign_event:1"
+ ]
+ }
+ },
+ "expected": {
+ "normalized_message": {
+ "id": "connect-legacy-3",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "fixture-client-secret",
+ "nip44_encrypt,sign_event:1"
+ ]
+ }
+ }
+ },
+ {
+ "id": "nip46_connect_current_metadata_004",
+ "kind": "nip46.request.valid",
+ "input": {
+ "message": {
+ "id": "connect-current",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "fixture-client-secret",
+ "nip44_encrypt,sign_event:1",
+ "{\"name\":\" Example Client \",\"url\":\"https://app.example.com\",\"image\":\"https://cdn.example.com/logo.png\"}"
+ ]
+ }
+ },
+ "expected": {
+ "normalized_message": {
+ "id": "connect-current",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "fixture-client-secret",
+ "nip44_encrypt,sign_event:1",
+ "{\"name\":\"Example Client\",\"url\":\"https://app.example.com/\",\"image\":\"https://cdn.example.com/logo.png\"}"
+ ]
+ }
+ }
+ },
+ {
+ "id": "nip46_metadata_name_bound_005",
+ "kind": "nip46.metadata.invalid",
+ "input": {
+ "field": "name",
+ "repeat": "a",
+ "count": 129
+ },
+ "expected": {
+ "error": "invalid_client_metadata"
+ }
+ },
+ {
+ "id": "nip46_connect_malformed_metadata_006",
+ "kind": "nip46.request.invalid",
+ "input": {
+ "message": {
+ "id": "connect-invalid-metadata",
+ "method": "connect",
+ "params": [
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "",
+ "",
+ "{\"name\":\"\"}"
+ ]
+ }
+ },
+ "expected": {
+ "error": "invalid_client_metadata"
+ }
+ },
+ {
+ "id": "nip46_nostrconnect_ordered_relays_007",
+ "kind": "nip46.uri.valid",
+ "input": {
+ "uri": "nostrconnect://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com&relay=wss%3A%2F%2Frelay-2.example.com&secret=fixture-client-secret&perms=nip44_encrypt%2Csign_event%3A1&name=+Example+Client+&url=https%3A%2F%2Fapp.example.com&image=https%3A%2F%2Fcdn.example.com%2Flogo.png"
+ },
+ "expected": {
+ "variant": "nostrconnect",
+ "relays": [
+ "wss://relay.example.com",
+ "wss://relay-2.example.com"
+ ],
+ "secret": "fixture-client-secret",
+ "metadata": {
+ "name": "Example Client",
+ "url": "https://app.example.com/",
+ "image": "https://cdn.example.com/logo.png",
+ "permissions": "nip44_encrypt,sign_event:1"
+ }
+ }
+ },
+ {
+ "id": "nip46_nostrconnect_secret_required_008",
+ "kind": "nip46.uri.invalid",
+ "input": {
+ "uri": "nostrconnect://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com"
+ },
+ "expected": {
+ "error": "missing_secret"
+ }
+ },
+ {
+ "id": "nip46_bunker_secret_optional_009",
+ "kind": "nip46.uri.valid",
+ "input": {
+ "uri": "bunker://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com"
+ },
+ "expected": {
+ "variant": "bunker",
+ "relays": [
+ "wss://relay.example.com"
+ ],
+ "secret": null
+ }
+ },
+ {
+ "id": "nip46_bunker_secret_present_010",
+ "kind": "nip46.uri.valid",
+ "input": {
+ "uri": "bunker://585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df?relay=wss%3A%2F%2Frelay.example.com&secret=fixture-bunker-secret"
+ },
+ "expected": {
+ "variant": "bunker",
+ "relays": [
+ "wss://relay.example.com"
+ ],
+ "secret": "fixture-bunker-secret"
+ }
+ },
+ {
+ "id": "nip46_connect_secret_echo_011",
+ "kind": "nip46.response.valid",
+ "input": {
+ "method": "connect",
+ "envelope": {
+ "id": "connect-current",
+ "result": "fixture-client-secret"
+ }
+ },
+ "expected": {
+ "normalized_envelope": {
+ "id": "connect-current",
+ "result": "fixture-client-secret"
+ }
+ }
+ },
+ {
+ "id": "nip46_switch_relays_ordered_012",
+ "kind": "nip46.response.valid",
+ "input": {
+ "method": "switch_relays",
+ "envelope": {
+ "id": "switch-relays",
+ "result": [
+ "wss://relay-2.example.com",
+ "wss://relay-3.example.com"
+ ]
+ }
+ },
+ "expected": {
+ "normalized_envelope": {
+ "id": "switch-relays",
+ "result": [
+ "wss://relay-2.example.com",
+ "wss://relay-3.example.com"
+ ]
+ }
+ }
+ },
+ {
+ "id": "nip46_switch_relays_unchanged_013",
+ "kind": "nip46.response.valid",
+ "input": {
+ "method": "switch_relays",
+ "envelope": {
+ "id": "switch-relays-unchanged",
+ "result": null
+ }
+ },
+ "expected": {
+ "normalized_envelope": {
+ "id": "switch-relays-unchanged",
+ "result": null
+ }
+ }
+ },
+ {
+ "id": "nip46_auth_url_continuation_014",
+ "kind": "nip46.response.valid",
+ "input": {
+ "method": "sign_event",
+ "envelope": {
+ "id": "sign-with-auth",
+ "result": "auth_url",
+ "error": "https://auth.example.com/challenge"
+ }
+ },
+ "expected": {
+ "normalized_envelope": {
+ "id": "sign-with-auth",
+ "result": "auth_url",
+ "error": "https://auth.example.com/challenge"
+ }
+ }
+ },
+ {
+ "id": "nip46_logout_request_015",
+ "kind": "nip46.request.valid",
+ "input": {
+ "message": {
+ "id": "logout-current",
+ "method": "logout",
+ "params": []
+ }
+ },
+ "expected": {
+ "normalized_message": {
+ "id": "logout-current",
+ "method": "logout",
+ "params": []
+ }
+ }
+ },
+ {
+ "id": "nip46_logout_acknowledgement_016",
+ "kind": "nip46.response.valid",
+ "input": {
+ "method": "logout",
+ "envelope": {
+ "id": "logout-current",
+ "result": "ack"
+ }
+ },
+ "expected": {
+ "normalized_envelope": {
+ "id": "logout-current",
+ "result": "ack"
+ }
+ }
+ },
+ {
+ "id": "nip46_logout_rejects_parameters_017",
+ "kind": "nip46.request.invalid",
+ "input": {
+ "message": {
+ "id": "logout-invalid",
+ "method": "logout",
+ "params": [
+ "unexpected"
+ ]
+ }
+ },
+ "expected": {
+ "error": "invalid_params"
+ }
+ },
+ {
+ "id": "nip46_malformed_response_result_018",
+ "kind": "nip46.response.invalid",
+ "input": {
+ "method": "ping",
+ "envelope": {
+ "id": "ping-invalid",
+ "result": 7
+ }
+ },
+ "expected": {
+ "error": "invalid_response_payload"
+ }
+ },
+ {
+ "id": "nip46_malformed_response_missing_result_019",
+ "kind": "nip46.response.invalid",
+ "input": {
+ "method": "logout",
+ "envelope": {
+ "id": "logout-missing-result"
+ }
+ },
+ "expected": {
+ "error": "missing_result"
+ }
+ }
+ ]
+}
diff --git a/crates/nostr/README b/crates/nostr/README
@@ -12,6 +12,14 @@ primitives for the `radroots` core libraries.
representations;
* optional NIP-11 and NIP-17 support alongside `std` and `no_std` builds.
+## Portable relay-client lifecycle
+
+With the `client` feature, callers can subscribe and publish to selected relay
+sets and explicitly unsubscribe through Radroots types. The wrapper does not
+create detached listeners or take ownership of subscription lifetime. The
+`client,events` feature combination is qualified for native targets and
+`wasm32-unknown-unknown`.
+
## Copyright
Except as otherwise noted, all files in the `radroots_nostr` distribution are
diff --git a/crates/nostr_connect/README b/crates/nostr_connect/README
@@ -11,6 +11,20 @@ connection and URI models for the `radroots` core libraries.
* portable shared models with optional serialization and TypeScript export
support.
+## Current NIP-46 behavior
+
+`connect` accepts the current one-to-four-parameter wire shape. The fourth
+parameter carries bounded, normalized display metadata (`name`, `url`, and
+`image`), while requested permissions remain the third parameter. Legacy
+one-, two-, and three-parameter requests continue to decode. `nostrconnect://`
+requires an ordered relay set and a non-empty secret; `bunker://` preserves
+ordered relays and keeps its secret optional.
+
+The typed client models support secret-echo connection responses,
+`auth_url` continuation, relay switching, and zero-parameter `logout` with an
+`ack` response. Deterministic protocol cases live in
+`contracts/conformance/vectors/nip46/current_session.v1.json`.
+
## Copyright
Except as otherwise noted, all files in the `radroots_nostr_connect`
diff --git a/crates/nostr_connect/tests/protocol.rs b/crates/nostr_connect/tests/protocol.rs
@@ -423,3 +423,163 @@ fn sign_event_response_roundtrips_signed_event_json_string() {
assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event));
}
+
+#[test]
+fn checked_in_current_session_vectors_match_protocol_behavior() {
+ let vectors =
+ include_str!("../../../contracts/conformance/vectors/nip46/current_session.v1.json");
+ let document: Value = serde_json::from_str(vectors).expect("NIP-46 vector JSON");
+ assert_eq!(document["suite"], "nip46_current_session");
+ assert_eq!(document["contract_version"], "0.1.0");
+ let entries = document["vectors"].as_array().expect("NIP-46 vectors");
+
+ for entry in entries {
+ let id = entry["id"].as_str().expect("vector id");
+ let kind = entry["kind"].as_str().expect("vector kind");
+ let input = &entry["input"];
+ let expected = &entry["expected"];
+
+ match kind {
+ "nip46.request.valid" => {
+ let message: RadrootsNostrConnectRequestMessage =
+ serde_json::from_value(input["message"].clone())
+ .unwrap_or_else(|error| panic!("{id}: parse request: {error}"));
+ let normalized = serde_json::to_value(message)
+ .unwrap_or_else(|error| panic!("{id}: serialize request: {error}"));
+ assert_eq!(normalized, expected["normalized_message"], "{id}");
+ }
+ "nip46.request.invalid" => {
+ let error = serde_json::from_value::<RadrootsNostrConnectRequestMessage>(
+ input["message"].clone(),
+ )
+ .expect_err("invalid request vector");
+ assert_vector_error(id, expected, error);
+ }
+ "nip46.metadata.invalid" => {
+ let count = input["count"].as_u64().expect("metadata repeat count") as usize;
+ let repeat = input["repeat"].as_str().expect("metadata repeat value");
+ let metadata = RadrootsNostrConnectClientMetadata {
+ name: Some(repeat.repeat(count)),
+ ..RadrootsNostrConnectClientMetadata::default()
+ };
+ let error = metadata.normalized().expect_err("invalid metadata vector");
+ assert_vector_error(id, expected, error);
+ }
+ "nip46.uri.valid" => {
+ let uri = input["uri"].as_str().expect("NIP-46 URI");
+ let parsed = RadrootsNostrConnectUri::parse(uri)
+ .unwrap_or_else(|error| panic!("{id}: parse URI: {error}"));
+ assert_uri_vector(id, parsed, expected);
+ }
+ "nip46.uri.invalid" => {
+ let uri = input["uri"].as_str().expect("NIP-46 URI");
+ let error = RadrootsNostrConnectUri::parse(uri).expect_err("invalid URI vector");
+ assert_vector_error(id, expected, error);
+ }
+ "nip46.response.valid" => {
+ let method = input["method"]
+ .as_str()
+ .expect("response method")
+ .parse::<RadrootsNostrConnectMethod>()
+ .expect("typed response method");
+ let envelope: RadrootsNostrConnectResponseEnvelope =
+ serde_json::from_value(input["envelope"].clone())
+ .unwrap_or_else(|error| panic!("{id}: parse envelope: {error}"));
+ let request_id = envelope.id.clone();
+ let response = RadrootsNostrConnectResponse::from_envelope(&method, envelope)
+ .unwrap_or_else(|error| panic!("{id}: parse response: {error}"));
+ let normalized = response
+ .into_envelope(request_id)
+ .unwrap_or_else(|error| panic!("{id}: serialize response: {error}"));
+ let normalized = serde_json::to_value(normalized)
+ .unwrap_or_else(|error| panic!("{id}: serialize envelope: {error}"));
+ assert_eq!(normalized, expected["normalized_envelope"], "{id}");
+ }
+ "nip46.response.invalid" => {
+ let method = input["method"]
+ .as_str()
+ .expect("response method")
+ .parse::<RadrootsNostrConnectMethod>()
+ .expect("typed response method");
+ let envelope: RadrootsNostrConnectResponseEnvelope =
+ serde_json::from_value(input["envelope"].clone())
+ .unwrap_or_else(|error| panic!("{id}: parse envelope: {error}"));
+ let error = RadrootsNostrConnectResponse::from_envelope(&method, envelope)
+ .expect_err("invalid response vector");
+ assert_vector_error(id, expected, error);
+ }
+ other => panic!("{id}: unknown NIP-46 vector kind {other}"),
+ }
+ }
+}
+
+fn assert_uri_vector(id: &str, parsed: RadrootsNostrConnectUri, expected: &Value) {
+ let expected_relays = expected["relays"]
+ .as_array()
+ .expect("expected relays")
+ .iter()
+ .map(|relay| relay.as_str().expect("expected relay"))
+ .collect::<Vec<_>>();
+
+ match parsed {
+ RadrootsNostrConnectUri::Bunker(uri) => {
+ assert_eq!(expected["variant"], "bunker", "{id}");
+ let relays = uri
+ .relays
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>();
+ assert_eq!(relays, expected_relays, "{id}");
+ assert_eq!(uri.secret.as_deref(), expected["secret"].as_str(), "{id}");
+ }
+ RadrootsNostrConnectUri::Client(uri) => {
+ assert_eq!(expected["variant"], "nostrconnect", "{id}");
+ let relays = uri
+ .relays
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>();
+ assert_eq!(relays, expected_relays, "{id}");
+ assert_eq!(uri.secret, expected["secret"].as_str().expect("secret"));
+ assert_eq!(
+ uri.metadata.name.as_deref(),
+ expected["metadata"]["name"].as_str(),
+ "{id}"
+ );
+ assert_eq!(
+ uri.metadata.url.as_deref(),
+ expected["metadata"]["url"].as_str(),
+ "{id}"
+ );
+ assert_eq!(
+ uri.metadata.image.as_deref(),
+ expected["metadata"]["image"].as_str(),
+ "{id}"
+ );
+ assert_eq!(
+ uri.metadata.requested_permissions.to_string(),
+ expected["metadata"]["permissions"]
+ .as_str()
+ .expect("permissions"),
+ "{id}"
+ );
+ }
+ }
+}
+
+fn assert_vector_error(id: &str, expected: &Value, error: impl ToString) {
+ let class = expected["error"].as_str().expect("expected error class");
+ let needle = match class {
+ "invalid_client_metadata" => "invalid NIP-46 client metadata",
+ "invalid_params" => "invalid parameter count",
+ "invalid_response_payload" => "invalid response payload",
+ "missing_result" => "missing response result",
+ "missing_secret" => "missing secret",
+ other => panic!("{id}: unknown expected error class {other}"),
+ };
+ let message = error.to_string();
+ assert!(
+ message.contains(needle),
+ "{id}: expected {class}, got {message}"
+ );
+}
diff --git a/crates/nostr_signer/README b/crates/nostr_signer/README
@@ -15,6 +15,17 @@ libraries.
* file, memory, and native SQLite stores for persisted signer state;
* optional native SQLite support for persistent signer services.
+## Current NIP-46 behavior
+
+Connection proposals, drafts, and records preserve optional validated client
+display metadata without treating it as an authorization input. JSON written
+before metadata support remains readable, and the native SQLite migration
+adds the same optional state without replacing existing connections.
+
+Connection revocation is explicit and idempotent. A transport service handling
+`logout` is responsible for publishing the typed `ack` before it revokes the
+session; storage transitions do not hide or reorder transport publication.
+
## Copyright
Except as otherwise noted, all files in the `radroots_nostr_signer`