Skip to content

Commit

Permalink
Relay request and response additions (nostr-sdk#151)
Browse files Browse the repository at this point in the history
* Reorder relay request and relay response enums to match the listed ordering in the specs for easier maintainability

* Fix RelayResponse to not drop human readable messages for OK messages

* Add CLOSED relay response

* Add AUTH relay request and response

* Apply suggestions from code review

Co-authored-by: Bryan Montz <[email protected]>

* Shorten struct names

* Change RelayResponseDecodingTest to use XCTUnwrap instead of if let to make the test code cleaner

---------

Co-authored-by: Bryan Montz <[email protected]>
  • Loading branch information
2 people authored and RandyMcMillan committed Sep 1, 2024
1 parent 963a5af commit 290e886
Show file tree
Hide file tree
Showing 13 changed files with 297 additions and 138 deletions.
11 changes: 10 additions & 1 deletion Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,13 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
///
/// See [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists)
case bookmarksList


/// This kind of event provides a way for clients to authenticate to relays by signing an ephemeral event.
/// This kind is not meant to be published or queried.
///
/// See [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md).
case authentication

/// This kind of event is for long-form texxt content, generally referred to as "articles" or "blog posts".
///
/// See [NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md).
Expand Down Expand Up @@ -112,6 +118,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
.report,
.muteList,
.bookmarksList,
.authentication,
.longformContent,
.dateBasedCalendarEvent,
.timeBasedCalendarEvent,
Expand Down Expand Up @@ -141,6 +148,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
case .report: return 1984
case .muteList: return 10000
case .bookmarksList: return 10003
case .authentication: return 22242
case .longformContent: return 30023
case .dateBasedCalendarEvent: return 31922
case .timeBasedCalendarEvent: return 31923
Expand All @@ -164,6 +172,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
case .report: return ReportEvent.self
case .muteList: return MuteListEvent.self
case .bookmarksList: return BookmarksListEvent.self
case .authentication: return AuthenticationEvent.self
case .longformContent: return LongformContentEvent.self
case .dateBasedCalendarEvent: return DateBasedCalendarEvent.self
case .timeBasedCalendarEvent: return TimeBasedCalendarEvent.self
Expand Down
55 changes: 55 additions & 0 deletions Sources/NostrSDK/Events/AuthenticationEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// AuthenticationEvent.swift
//
//
// Created by Terry Yiu on 5/1/24.
//

import Foundation

/// An event that provides a way for clients to authenticate to relays.
/// This kind is not meant to be published or queried.
///
/// See [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md).
public final class AuthenticationEvent: NostrEvent, RelayProviding, RelayURLValidating {
public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
try super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

init(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
try super.init(kind: .authentication, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

/// The relay URL this event authenticates to.
public var relayURL: URL? {
guard let relayURLString = firstValueForRawTagName("relay") else {
return nil
}

return try? validateRelayURLString(relayURLString)
}

/// The challenge string as received from the relay.
public var challenge: String? {
firstValueForRawTagName("challenge")
}
}

public extension EventCreating {

func authenticate(relayURL: URL, challenge: String, signedBy keypair: Keypair) throws -> AuthenticationEvent {
let validatedRelayURL = try RelayURLValidator.shared.validateRelayURL(relayURL)

let tags: [Tag] = [
Tag(name: "relay", value: validatedRelayURL.absoluteString),
Tag(name: "challenge", value: challenge)
]

return try AuthenticationEvent(content: "", tags: tags, signedBy: keypair)
}
}
15 changes: 9 additions & 6 deletions Sources/NostrSDK/RelayRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,25 @@
import Foundation

enum RelayRequest {
case close(subscriptionId: String)
case event(NostrEvent)
case count(subscriptionId: String, filter: Filter)
case request(subscriptionId: String, filter: Filter)
case close(subscriptionId: String)
case auth(AuthenticationEvent)
case count(subscriptionId: String, filter: Filter)

var encoded: String? {
let payload: [AnyEncodable]
switch self {
case .close(let subscriptionId):
payload = [AnyEncodable("CLOSE"), AnyEncodable(subscriptionId)]
case .event(let event):
payload = [AnyEncodable("EVENT"), AnyEncodable(event)]
case .count(let subscriptionId, let filter):
payload = [AnyEncodable("COUNT"), AnyEncodable(subscriptionId), AnyEncodable(filter)]
case .request(let subscriptionId, let filter):
payload = [AnyEncodable("REQ"), AnyEncodable(subscriptionId), AnyEncodable(filter)]
case .close(let subscriptionId):
payload = [AnyEncodable("CLOSE"), AnyEncodable(subscriptionId)]
case .auth(let event):
payload = [AnyEncodable("AUTH"), AnyEncodable(event)]
case .count(let subscriptionId, let filter):
payload = [AnyEncodable("COUNT"), AnyEncodable(subscriptionId), AnyEncodable(filter)]
}

guard let data = try? JSONEncoder().encode(payload) else {
Expand Down
64 changes: 37 additions & 27 deletions Sources/NostrSDK/RelayResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,49 +29,55 @@ enum RelayResponse: Decodable {

enum MessageType: String, Codable {
case event = "EVENT"
case notice = "NOTICE"
case eose = "EOSE"
case ok = "OK"
case count = "COUNT"
case eose = "EOSE"
case closed = "CLOSED"
case notice = "NOTICE"
case auth = "AUTH"
case count = "COUNT"
}

struct OKMessage {
let type: OKMessageType
let message: String?
struct Message {
let prefix: MessagePrefix
let message: String

init(rawMessage: String) {
let components = rawMessage.split(separator: ":")
let components = rawMessage.split(separator: ":", maxSplits: 1)
if let firstComponent = components.first {
type = OKMessageType(rawValue: String(firstComponent)) ?? .unknown
prefix = MessagePrefix(rawValue: String(firstComponent)) ?? .unknown
} else {
type = .unknown
prefix = .unknown
}

if components.count >= 2 {
message = components[1].trimmingCharacters(in: .whitespaces)

if prefix == .unknown {
message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines)
} else if components.count >= 2 {
message = components[1].trimmingCharacters(in: .whitespacesAndNewlines)
} else {
message = nil
message = ""
}
}
}

enum OKMessageType: String, Codable {
enum MessagePrefix: String, Codable {
case unknown
case duplicate
case pow
case blocked
case rateLimited = "rate-limited"
case invalid
case error
case authRequired = "auth-required"
case restricted
}

case notice(message: String)
case eose(subscriptionId: String)
case event(subscriptionId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, message: OKMessage)
case count(subscriptionId: String, count: Int)
case ok(eventId: String, success: Bool, message: Message)
case eose(subscriptionId: String)
case closed(subscriptionId: String, message: Message)
case notice(message: String)
case auth(challenge: String)
case count(subscriptionId: String, count: Int)

init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
Expand All @@ -89,24 +95,28 @@ enum RelayResponse: Decodable {
let event = try container2.decode(kindMapper.classForKind.self)

self = .event(subscriptionId: subscriptionId, event: event)
case .notice:
case .ok:
let eventId = try container.decode(String.self)
let success = try container.decode(Bool.self)
let message = try container.decode(String.self)
self = .notice(message: message)
self = .ok(eventId: eventId, success: success, message: Message(rawMessage: message))
case .eose:
let subscriptionId = try container.decode(String.self)
self = .eose(subscriptionId: subscriptionId)
case .ok:
let eventId = try container.decode(String.self)
let success = try container.decode(Bool.self)
case .closed:
let subscriptionId = try container.decode(String.self)
let message = try container.decode(String.self)
self = .closed(subscriptionId: subscriptionId, message: Message(rawMessage: message))
case .notice:
let message = try container.decode(String.self)
self = .ok(eventId: eventId, success: success, message: OKMessage(rawMessage: message))
self = .notice(message: message)
case .auth:
let challenge = try container.decode(String.self)
self = .auth(challenge: challenge)
case .count:
let subscriptionId = try container.decode(String.self)
let countResponse = try container.decode(CountResponse.self)
self = .count(subscriptionId: subscriptionId, count: countResponse.count)
case .auth:
let challenge = try container.decode(String.self)
self = .auth(challenge: challenge)
}
}

Expand Down
41 changes: 41 additions & 0 deletions Tests/NostrSDKTests/Events/AuthenticationEventTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// AuthenticationEventTests.swift
//
//
// Created by Terry Yiu on 5/2/24.
//

@testable import NostrSDK
import XCTest

final class AuthenticationEventTests: XCTestCase, EventCreating, EventVerifying, FixtureLoading {

func testCreateAuthenticationEvent() throws {
let relayURL = try XCTUnwrap(URL(string: "wss://relay.example.com/"))
let event = try authenticate(relayURL: relayURL, challenge: "some-challenge-string", signedBy: Keypair.test)

XCTAssertEqual(event.kind, .authentication)
XCTAssertEqual(event.relayURL, relayURL)
XCTAssertEqual(event.challenge, "some-challenge-string")

try verifyEvent(event)
}

func testDecodeAuthenticationEvent() throws {
let event: AuthenticationEvent = try decodeFixture(filename: "authentication_event")

XCTAssertEqual(event.id, "adb599bc2f6b4cf97d927de2cb36829326c86013e0a6e8f51159f80938a5c246")
XCTAssertEqual(event.pubkey, "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340")
XCTAssertEqual(event.createdAt, 1714625219)
XCTAssertEqual(event.kind, .authentication)

let expectedTags: [Tag] = [
Tag(name: "relay", value: "wss://relay.example.com/"),
Tag(name: "challenge", value: "some-challenge-string")
]
XCTAssertEqual(event.tags, expectedTags)
XCTAssertEqual(event.content, "")
XCTAssertEqual(event.signature, "b27181e0b72872c463ac75ebf3ad2c2502696d81551bbae6b2a391c67614daf2e321eb5ab724b04520b26fcf7a4c9823fefdb47b10d66c088db44162ba9c1291")
}

}
1 change: 1 addition & 0 deletions Tests/NostrSDKTests/Fixtures/auth_request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["AUTH",{"id":"adb599bc2f6b4cf97d927de2cb36829326c86013e0a6e8f51159f80938a5c246","pubkey":"9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340","created_at":1714625219,"kind":22242,"tags":[["relay","wss://relay.example.com/"],["challenge","some-challenge-string"]],"content":"","sig":"b27181e0b72872c463ac75ebf3ad2c2502696d81551bbae6b2a391c67614daf2e321eb5ab724b04520b26fcf7a4c9823fefdb47b10d66c088db44162ba9c1291"}]
12 changes: 12 additions & 0 deletions Tests/NostrSDKTests/Fixtures/authentication_event.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "adb599bc2f6b4cf97d927de2cb36829326c86013e0a6e8f51159f80938a5c246",
"pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340",
"created_at": 1714625219,
"kind": 22242,
"tags": [
["relay", "wss://relay.example.com/"],
["challenge", "some-challenge-string"]
],
"content": "",
"sig": "b27181e0b72872c463ac75ebf3ad2c2502696d81551bbae6b2a391c67614daf2e321eb5ab724b04520b26fcf7a4c9823fefdb47b10d66c088db44162ba9c1291"
}
1 change: 1 addition & 0 deletions Tests/NostrSDKTests/Fixtures/closed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["CLOSED", "some-subscription-id", "error: shutting down idle subscription"]
2 changes: 1 addition & 1 deletion Tests/NostrSDKTests/Fixtures/ok_success_reason.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow: difficulty 25>=24"]
["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow: difficulty: 25>=24"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow:"]
1 change: 1 addition & 0 deletions Tests/NostrSDKTests/Fixtures/ok_unknown_reason.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "unknown: reason: unknown"]
36 changes: 22 additions & 14 deletions Tests/NostrSDKTests/RelayRequestEncodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,7 @@
@testable import NostrSDK
import XCTest

final class RelayRequestEncodingTests: XCTestCase, FixtureLoading, JSONTesting {

func testEncodeClose() throws {
let request = try XCTUnwrap(RelayRequest.close(subscriptionId: "some-subscription-id"), "failed to encode request")
let expected = try loadFixtureString("close_request")

XCTAssertEqual(request.encoded, expected)
}
final class RelayRequestEncodingTests: XCTestCase, EventCreating, FixtureLoading, JSONTesting {

func testEncodeEvent() throws {
let eventTag = Tag.event("93930d65435d49db723499335473920795e7f13c45600dcfad922135cf44bd63")
Expand All @@ -34,7 +27,7 @@ final class RelayRequestEncodingTests: XCTestCase, FixtureLoading, JSONTesting {
XCTAssertTrue(areEquivalentJSONArrayStrings(request.encoded, expected))
}

func testEncodeCount() throws {
func testEncodeReq() throws {
let filter = Filter(ids: nil,
authors: ["some-pubkey"],
kinds: [1, 7],
Expand All @@ -44,13 +37,28 @@ final class RelayRequestEncodingTests: XCTestCase, FixtureLoading, JSONTesting {
until: nil,
limit: nil)

let request = try XCTUnwrap(RelayRequest.count(subscriptionId: "some-subscription-id", filter: filter), "failed to encode request")
let expected = try loadFixtureString("count_request")
let request = try XCTUnwrap(RelayRequest.request(subscriptionId: "some-subscription-id", filter: filter), "failed to encode request")
let expected = try loadFixtureString("req")

XCTAssertTrue(areEquivalentJSONArrayStrings(request.encoded, expected))
}

func testEncodeReq() throws {
func testEncodeClose() throws {
let request = try XCTUnwrap(RelayRequest.close(subscriptionId: "some-subscription-id"), "failed to encode request")
let expected = try loadFixtureString("close_request")

XCTAssertEqual(request.encoded, expected)
}

func testEncodeAuth() throws {
let authenticationEvent: AuthenticationEvent = try decodeFixture(filename: "authentication_event")
let request = try XCTUnwrap(RelayRequest.auth(authenticationEvent))
let expected = try loadFixtureString("auth_request")

XCTAssertTrue(areEquivalentJSONArrayStrings(request.encoded, expected))
}

func testEncodeCount() throws {
let filter = Filter(ids: nil,
authors: ["some-pubkey"],
kinds: [1, 7],
Expand All @@ -60,8 +68,8 @@ final class RelayRequestEncodingTests: XCTestCase, FixtureLoading, JSONTesting {
until: nil,
limit: nil)

let request = try XCTUnwrap(RelayRequest.request(subscriptionId: "some-subscription-id", filter: filter), "failed to encode request")
let expected = try loadFixtureString("req")
let request = try XCTUnwrap(RelayRequest.count(subscriptionId: "some-subscription-id", filter: filter), "failed to encode request")
let expected = try loadFixtureString("count_request")

XCTAssertTrue(areEquivalentJSONArrayStrings(request.encoded, expected))
}
Expand Down
Loading

0 comments on commit 290e886

Please sign in to comment.