Add relayinfo package and utility modules for NIP-11 support

Introduce the `relayinfo` package with `NIP-11` utilities, including `Fees`, `Limits`, and `NIPs` structures. Add utility modules for handling numbers, timestamps, and kinds. Integrate functionality for fetching and managing relay information.
This commit is contained in:
2025-08-21 15:22:17 +01:00
parent ecaf52b98f
commit 8add32bb78
16 changed files with 1541 additions and 2 deletions

424
pkg/encoders/kind/kind.go Normal file
View File

@@ -0,0 +1,424 @@
// Package kind includes a type for convenient handling of event kinds, and a
// kind database with reverse lookup for human-readable information about event
// kinds.
package kind
import (
"sync"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/encoders/ints"
"golang.org/x/exp/constraints"
)
// K - which will be externally referenced as kind.K is the event type in the
// nostr protocol, the use of the capital K signifying type, consistent with Go
// idiom, the Go standard library, and much, conformant, existing code.
type K struct {
K uint16
}
// New creates a new kind.K with a provided integer value. Note that anything larger than 2^16
// will be truncated.
func New[V constraints.Integer](k V) (ki *K) { return &K{uint16(k)} }
// ToInt returns the value of the kind.K as an int.
func (k *K) ToInt() int {
if k == nil {
return 0
}
return int(k.K)
}
// ToU16 returns the value of the kind.K as an uint16 (the native form).
func (k *K) ToU16() uint16 {
if k == nil {
return 0
}
return k.K
}
// ToI32 returns the value of the kind.K as an int32.
func (k *K) ToI32() int32 {
if k == nil {
return 0
}
return int32(k.K)
}
// ToU64 returns the value of the kind.K as an uint64.
func (k *K) ToU64() uint64 {
if k == nil {
return 0
}
return uint64(k.K)
}
// Name returns the human readable string describing the semantics of the kind.K.
func (k *K) Name() string { return GetString(k) }
// Equal checks if
func (k *K) Equal(k2 *K) bool {
if k == nil || k2 == nil {
return false
}
return k.K == k2.K
}
var Privileged = []*K{
EncryptedDirectMessage,
GiftWrap,
GiftWrapWithKind4,
JWTBinding,
ApplicationSpecificData,
Seal,
PrivateDirectMessage,
}
// IsPrivileged returns true if the type is the kind of message nobody else than the pubkeys in
// the event and p tags of the event are party to.
func (k *K) IsPrivileged() (is bool) {
for i := range Privileged {
if k.Equal(Privileged[i]) {
return true
}
}
return
}
// Marshal renders the kind.K into bytes containing the ASCII string form of the kind number.
func (k *K) Marshal(dst []byte) (b []byte) { return ints.New(k.ToU64()).Marshal(dst) }
// Unmarshal decodes a byte string into a kind.K.
func (k *K) Unmarshal(b []byte) (r []byte, err error) {
n := ints.New(0)
if r, err = n.Unmarshal(b); chk.T(err) {
return
}
k.K = n.Uint16()
return
}
// GetString returns a human-readable identifier for a kind.K.
func GetString(t *K) string {
if t == nil {
return ""
}
MapMx.Lock()
defer MapMx.Unlock()
return Map[t.K]
}
// IsEphemeral returns true if the event kind is an ephemeral event. (not to be
// stored)
func (k *K) IsEphemeral() bool {
return k.K >= EphemeralStart.K && k.K < EphemeralEnd.K
}
// IsReplaceable returns true if the event kind is a replaceable kind - that is,
// if the newest version is the one that is in force (eg follow lists, relay
// lists, etc.
func (k *K) IsReplaceable() bool {
return k.K == ProfileMetadata.K || k.K == FollowList.K ||
(k.K >= ReplaceableStart.K && k.K < ReplaceableEnd.K)
}
// IsParameterizedReplaceable is a kind of event that is one of a group of
// events that replaces based on matching criteria.
func (k *K) IsParameterizedReplaceable() bool {
return k.K >= ParameterizedReplaceableStart.K &&
k.K < ParameterizedReplaceableEnd.K
}
// Directory events are events that necessarily need to be readable by anyone in
// order to interact with users who have access to the relay, in order to
// facilitate other users to find and interact with users on an auth-required
// relay.
var Directory = []*K{
ProfileMetadata,
FollowList,
EventDeletion,
Reporting,
RelayListMetadata,
MuteList,
DMRelaysList,
}
// IsDirectoryEvent returns whether an event kind is a Directory event, which
// should grant permission to read such events without requiring authentication.
func (k *K) IsDirectoryEvent() bool {
for i := range Directory {
if k.Equal(Directory[i]) {
return true
}
}
return false
}
var (
// ProfileMetadata is an event type that stores user profile data, pet
// names, bio, lightning address, etc.
ProfileMetadata = &K{0}
// SetMetadata is a synonym for ProfileMetadata.
SetMetadata = &K{0}
// TextNote is a standard short text note of plain text a la twitter
TextNote = &K{1}
// RecommendServer is an event type that...
RecommendServer = &K{2}
RecommendRelay = &K{2}
// FollowList an event containing a list of pubkeys of users that should be
// shown as follows in a timeline.
FollowList = &K{3}
Follows = &K{3}
// EncryptedDirectMessage is an event type that...
EncryptedDirectMessage = &K{4}
// Deletion is an event type that...
Deletion = &K{5}
EventDeletion = &K{5}
// Repost is an event type that...
Repost = &K{6}
// Reaction is an event type that...
Reaction = &K{7}
// BadgeAward is an event type
BadgeAward = &K{8}
// Seal is an event that wraps a PrivateDirectMessage and is placed inside a
// GiftWrap or GiftWrapWithKind4
Seal = &K{13}
// PrivateDirectMessage is a nip-17 direct message with a different
// construction. It doesn't actually appear as an event a relay might receive
// but only as the stringified content of a GiftWrap or GiftWrapWithKind4 inside
// a
PrivateDirectMessage = &K{14}
// ReadReceipt is a type of event that marks a list of tagged events (e
// tags) as being seen by the client, its distinctive feature is the
// "expiration" tag which indicates a time after which the marking expires
ReadReceipt = &K{15}
// GenericRepost is an event type that...
GenericRepost = &K{16}
// ChannelCreation is an event type that...
ChannelCreation = &K{40}
// ChannelMetadata is an event type that...
ChannelMetadata = &K{41}
// ChannelMessage is an event type that...
ChannelMessage = &K{42}
// ChannelHideMessage is an event type that...
ChannelHideMessage = &K{43}
// ChannelMuteUser is an event type that...
ChannelMuteUser = &K{44}
// Bid is an event type that...
Bid = &K{1021}
// BidConfirmation is an event type that...
BidConfirmation = &K{1022}
// OpenTimestamps is an event type that...
OpenTimestamps = &K{1040}
GiftWrap = &K{1059}
GiftWrapWithKind4 = &K{1060}
// FileMetadata is an event type that...
FileMetadata = &K{1063}
// LiveChatMessage is an event type that...
LiveChatMessage = &K{1311}
// BitcoinBlock is an event type created for the Nostrocket
BitcoinBlock = &K{1517}
// LiveStream from zap.stream
LiveStream = &K{1808}
// ProblemTracker is an event type used by Nostrocket
ProblemTracker = &K{1971}
// MemoryHole is an event type contains a report about an event (usually
// text note or other human readable)
MemoryHole = &K{1984}
Reporting = &K{1984}
// Label is an event type has L and l tags, namespace and type - NIP-32
Label = &K{1985}
// CommunityPostApproval is an event type that...
CommunityPostApproval = &K{4550}
JobRequestStart = &K{5000}
JobRequestEnd = &K{5999}
JobResultStart = &K{6000}
JobResultEnd = &K{6999}
JobFeedback = &K{7000}
ZapGoal = &K{9041}
// ZapRequest is an event type that...
ZapRequest = &K{9734}
// Zap is an event type that...
Zap = &K{9735}
Highlights = &K{9882}
// ReplaceableStart is an event type that...
ReplaceableStart = &K{10000}
// MuteList is an event type that...
MuteList = &K{10000}
BlockList = &K{10000}
// PinList is an event type that...
PinList = &K{10001}
// RelayListMetadata is an event type that...
RelayListMetadata = &K{10002}
BookmarkList = &K{10003}
CommunitiesList = &K{10004}
PublicChatsList = &K{10005}
BlockedRelaysList = &K{10006}
SearchRelaysList = &K{10007}
InterestsList = &K{10015}
UserEmojiList = &K{10030}
DMRelaysList = &K{10050}
FileStorageServerList = &K{10096}
// JWTBinding is an event kind that creates a link between a JWT certificate and a pubkey
JWTBinding = &K{13004}
// NWCWalletServiceInfo is an event type that...
NWCWalletServiceInfo = &K{13194}
WalletServiceInfo = &K{13194}
// ReplaceableEnd is an event type that...
ReplaceableEnd = &K{19999}
// EphemeralStart is an event type that...
EphemeralStart = &K{20000}
LightningPubRPC = &K{21000}
// ClientAuthentication is an event type that...
ClientAuthentication = &K{22242}
// NWCWalletRequest is an event type that...
NWCWalletRequest = &K{23194}
WalletRequest = &K{23194}
// NWCWalletResponse is an event type that...
NWCWalletResponse = &K{23195}
WalletResponse = &K{23195}
NWCNotification = &K{23196}
WalletNotificationNip4 = &K{23196}
WalletNotification = &K{23197}
// NostrConnect is an event type that...
NostrConnect = &K{24133}
HTTPAuth = &K{27235}
// EphemeralEnd is an event type that...
EphemeralEnd = &K{29999}
// ParameterizedReplaceableStart is an event type that...
ParameterizedReplaceableStart = &K{30000}
// CategorizedPeopleList is an event type that...
CategorizedPeopleList = &K{30000}
FollowSets = &K{30000}
// CategorizedBookmarksList is an event type that...
CategorizedBookmarksList = &K{30001}
GenericLists = &K{30001}
RelaySets = &K{30002}
BookmarkSets = &K{30003}
CurationSets = &K{30004}
// ProfileBadges is an event type that...
ProfileBadges = &K{30008}
// BadgeDefinition is an event type that...
BadgeDefinition = &K{30009}
InterestSets = &K{30015}
// StallDefinition creates or updates a stall
StallDefinition = &K{30017}
// ProductDefinition creates or updates a product
ProductDefinition = &K{30018}
MarketplaceUIUX = &K{30019}
ProductSoldAsAuction = &K{30020}
// Article is an event type that...
Article = &K{30023}
LongFormContent = &K{30023}
DraftLongFormContent = &K{30024}
EmojiSets = &K{30030}
// ApplicationSpecificData is an event type stores data about application
// configuration, this, like DMs and giftwraps must be protected by user
// auth.
ApplicationSpecificData = &K{30078}
LiveEvent = &K{30311}
UserStatuses = &K{30315}
ClassifiedListing = &K{30402}
DraftClassifiedListing = &K{30403}
DateBasedCalendarEvent = &K{31922}
TimeBasedCalendarEvent = &K{31923}
Calendar = &K{31924}
CalendarEventRSVP = &K{31925}
HandlerRecommendation = &K{31989}
HandlerInformation = &K{31990}
// WaveLakeTrack which has no spec and uses malformed tags
WaveLakeTrack = &K{32123}
CommunityDefinition = &K{34550}
ACLEvent = &K{39998}
// ParameterizedReplaceableEnd is an event type that...
ParameterizedReplaceableEnd = &K{39999}
)
var MapMx sync.Mutex
var Map = map[uint16]string{
ProfileMetadata.K: "ProfileMetadata",
TextNote.K: "TextNote",
RecommendRelay.K: "RecommendRelay",
FollowList.K: "FollowList",
EncryptedDirectMessage.K: "EncryptedDirectMessage",
EventDeletion.K: "EventDeletion",
Repost.K: "Repost",
Reaction.K: "Reaction",
BadgeAward.K: "BadgeAward",
ReadReceipt.K: "ReadReceipt",
GenericRepost.K: "GenericRepost",
ChannelCreation.K: "ChannelCreation",
ChannelMetadata.K: "ChannelMetadata",
ChannelMessage.K: "ChannelMessage",
ChannelHideMessage.K: "ChannelHideMessage",
ChannelMuteUser.K: "ChannelMuteUser",
Bid.K: "Bid",
BidConfirmation.K: "BidConfirmation",
OpenTimestamps.K: "OpenTimestamps",
FileMetadata.K: "FileMetadata",
LiveChatMessage.K: "LiveChatMessage",
ProblemTracker.K: "ProblemTracker",
Reporting.K: "Reporting",
Label.K: "Label",
CommunityPostApproval.K: "CommunityPostApproval",
JobRequestStart.K: "JobRequestStart",
JobRequestEnd.K: "JobRequestEnd",
JobResultStart.K: "JobResultStart",
JobResultEnd.K: "JobResultEnd",
JobFeedback.K: "JobFeedback",
ZapGoal.K: "ZapGoal",
ZapRequest.K: "ZapRequest",
Zap.K: "Zap",
Highlights.K: "Highlights",
BlockList.K: "BlockList",
PinList.K: "PinList",
RelayListMetadata.K: "RelayListMetadata",
BookmarkList.K: "BookmarkList",
CommunitiesList.K: "CommunitiesList",
PublicChatsList.K: "PublicChatsList",
BlockedRelaysList.K: "BlockedRelaysList",
SearchRelaysList.K: "SearchRelaysList",
InterestsList.K: "InterestsList",
UserEmojiList.K: "UserEmojiList",
DMRelaysList.K: "DMRelaysList",
FileStorageServerList.K: "FileStorageServerList",
NWCWalletServiceInfo.K: "NWCWalletServiceInfo",
LightningPubRPC.K: "LightningPubRPC",
ClientAuthentication.K: "ClientAuthentication",
WalletRequest.K: "WalletRequest",
WalletResponse.K: "WalletResponse",
WalletNotificationNip4.K: "WalletNotificationNip4",
WalletNotification.K: "WalletNotification",
NostrConnect.K: "NostrConnect",
HTTPAuth.K: "HTTPAuth",
FollowSets.K: "FollowSets",
GenericLists.K: "GenericLists",
RelaySets.K: "RelaySets",
BookmarkSets.K: "BookmarkSets",
CurationSets.K: "CurationSets",
ProfileBadges.K: "ProfileBadges",
BadgeDefinition.K: "BadgeDefinition",
InterestSets.K: "InterestSets",
StallDefinition.K: "StallDefinition",
ProductDefinition.K: "ProductDefinition",
MarketplaceUIUX.K: "MarketplaceUIUX",
ProductSoldAsAuction.K: "ProductSoldAsAuction",
LongFormContent.K: "LongFormContent",
DraftLongFormContent.K: "DraftLongFormContent",
EmojiSets.K: "EmojiSets",
ApplicationSpecificData.K: "ApplicationSpecificData",
ParameterizedReplaceableEnd.K: "ParameterizedReplaceableEnd",
LiveEvent.K: "LiveEvent",
UserStatuses.K: "UserStatuses",
ClassifiedListing.K: "ClassifiedListing",
DraftClassifiedListing.K: "DraftClassifiedListing",
DateBasedCalendarEvent.K: "DateBasedCalendarEvent",
TimeBasedCalendarEvent.K: "TimeBasedCalendarEvent",
Calendar.K: "Calendar",
CalendarEventRSVP.K: "CalendarEventRSVP",
HandlerRecommendation.K: "HandlerRecommendation",
HandlerInformation.K: "HandlerInformation",
CommunityDefinition.K: "CommunityDefinition",
}

View File

@@ -0,0 +1,37 @@
package kind
import (
"testing"
"lol.mleku.dev/chk"
"lukechampine.com/frand"
)
func TestMarshalUnmarshal(t *testing.T) {
var err error
k := make([]*K, 1000000)
for i := range k {
k[i] = New(uint16(frand.Intn(65535)))
}
mk := make([][]byte, len(k))
for i := range mk {
mk[i] = make([]byte, 0, 5) // 16 bits max 65535 = 5 characters
}
for i := range k {
mk[i] = k[i].Marshal(mk[i])
}
k2 := make([]*K, len(k))
for i := range k2 {
k2[i] = New(0)
}
for i := range k2 {
var r []byte
if r, err = k2[i].Unmarshal(mk[i]); chk.E(err) {
t.Fatal(err)
}
if len(r) != 0 {
t.Fatalf("remainder after unmarshal: '%s'", r)
}
}
}

150
pkg/encoders/kind/kinds.go Normal file
View File

@@ -0,0 +1,150 @@
// Package kinds is a set of helpers for dealing with lists of kind numbers
// including comparisons and encoding.
package kind
import (
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/ints"
)
// S is an array of kind.K, used in filter.K and filter.S for searches.
type S struct {
K []*K
}
// NewS creates a new kinds.S, if no parameter is given it just creates an empty zero kinds.S.
func NewS(k ...*K) *S { return &S{k} }
// NewWithCap creates a new empty kinds.S with a given slice capacity.
func NewWithCap(c int) *S { return &S{make([]*K, 0, c)} }
// FromIntSlice converts a []int into a kinds.S.
func FromIntSlice(is []int) (k *S) {
k = &S{}
for i := range is {
k.K = append(k.K, New(uint16(is[i])))
}
return
}
// Len returns the number of elements in a kinds.S.
func (k *S) Len() (l int) {
if k == nil {
return
}
return len(k.K)
}
// Less returns which of two elements of a kinds.S is lower.
func (k *S) Less(i, j int) bool { return k.K[i].K < k.K[j].K }
// Swap switches the position of two kinds.S elements.
func (k *S) Swap(i, j int) {
k.K[i].K, k.K[j].K = k.K[j].K, k.K[i].K
}
// ToUint16 returns a []uint16 version of the kinds.S.
func (k *S) ToUint16() (o []uint16) {
for i := range k.K {
o = append(o, k.K[i].ToU16())
}
return
}
// Clone makes a new kind.K with the same members.
func (k *S) Clone() (c *S) {
c = &S{K: make([]*K, len(k.K))}
for i := range k.K {
c.K[i] = k.K[i]
}
return
}
// Contains returns true if the provided element is found in the kinds.S.
//
// Note that the request must use the typed kind.K or convert the number thus.
// Even if a custom number is found, this codebase does not have the logic to
// deal with the kind so such a search is pointless and for which reason static
// typing always wins. No mistakes possible with known quantities.
func (k *S) Contains(s *K) bool {
for i := range k.K {
if k.K[i].Equal(s) {
return true
}
}
return false
}
// Equals checks that the provided kind.K matches.
func (k *S) Equals(t1 *S) bool {
if len(k.K) != len(t1.K) {
return false
}
for i := range k.K {
if k.K[i] != t1.K[i] {
return false
}
}
return true
}
// Marshal renders the kinds.S into a JSON array of integers.
func (k *S) Marshal(dst []byte) (b []byte) {
b = dst
b = append(b, '[')
for i := range k.K {
b = k.K[i].Marshal(b)
if i != len(k.K)-1 {
b = append(b, ',')
}
}
b = append(b, ']')
return
}
// Unmarshal decodes a provided JSON array of integers into a kinds.S.
func (k *S) Unmarshal(b []byte) (r []byte, err error) {
r = b
var openedBracket bool
for ; len(r) > 0; r = r[1:] {
if !openedBracket && r[0] == '[' {
openedBracket = true
continue
} else if openedBracket {
if r[0] == ']' {
// done
return
} else if r[0] == ',' {
continue
}
kk := ints.New(0)
if r, err = kk.Unmarshal(r); chk.E(err) {
return
}
k.K = append(k.K, New(kk.Uint16()))
if r[0] == ']' {
r = r[1:]
return
}
}
}
if !openedBracket {
return nil, errorf.E(
"kinds: failed to unmarshal\n%s\n%s\n%s", k,
b, r,
)
}
return
}
// IsPrivileged returns true if any of the elements of a kinds.S are privileged (ie, they should
// be privacy protected).
func (k *S) IsPrivileged() (priv bool) {
for i := range k.K {
if k.K[i].IsPrivileged() {
return true
}
}
return
}

View File

@@ -0,0 +1,36 @@
package kind
import (
"testing"
"lol.mleku.dev/chk"
"lukechampine.com/frand"
)
func TestUnmarshalKindsArray(t *testing.T) {
k := &S{make([]*K, 100)}
for i := range k.K {
k.K[i] = New(uint16(frand.Intn(65535)))
}
var dst []byte
var err error
if dst = k.Marshal(dst); chk.E(err) {
t.Fatal(err)
}
k2 := &S{}
var rem []byte
if rem, err = k2.Unmarshal(dst); chk.E(err) {
return
}
if len(rem) > 0 {
t.Fatalf("failed to unmarshal, remnant afterwards '%s'", rem)
}
for i := range k.K {
if *k.K[i] != *k2.K[i] {
t.Fatalf(
"failed to unmarshal at element %d; got %x, expected %x",
i, k.K[i], k2.K[i],
)
}
}
}

View File

@@ -0,0 +1,119 @@
// Package timestamp is a set of helpers for working with timestamps including
// encoding and conversion to various integer forms, from time.Time and varints.
package timestamp
import (
"encoding/binary"
"time"
"unsafe"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/ints"
)
// T is a convenience type for UNIX 64 bit timestamps of 1 second
// precision.
type T struct{ V int64 }
// New creates a new timestamp.T, as zero or optionally from the first variadic
// parameter as int64.
func New(x ...int64) (t *T) {
t = &T{}
if len(x) > 0 {
t.V = x[0]
}
return
}
// Now returns the current UNIX timestamp of the current second.
func Now() *T {
tt := T{time.Now().Unix()}
return &tt
}
// U64 returns the current UNIX timestamp of the current second as uint64.
func (t *T) U64() uint64 {
if t == nil {
return 0
}
return uint64(t.V)
}
// I64 returns the current UNIX timestamp of the current second as int64.
func (t *T) I64() int64 {
if t == nil {
return 0
}
return t.V
}
// Time converts a timestamp.Time value into a canonical UNIX 64 bit 1 second
// precision timestamp.
func (t *T) Time() time.Time { return time.Unix(t.V, 0) }
// Int returns the timestamp as an int.
func (t *T) Int() int {
if t == nil {
return 0
}
return int(t.V)
}
// Bytes returns a timestamp as an 8 byte thing.
func (t *T) Bytes() (b []byte) {
b = make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(t.V))
return
}
// FromTime returns a T from a time.Time
func FromTime(t time.Time) *T { return &T{t.Unix()} }
// FromUnix converts from a standard int64 unix timestamp.
func FromUnix(t int64) *T { return &T{t} }
func (t *T) FromInt(i int) { *t = T{int64(i)} }
// FromBytes converts from a string of raw bytes.
func FromBytes(b []byte) *T { return &T{int64(binary.BigEndian.Uint64(b))} }
// FromVarint decodes a varint and returns the remainder of the bytes and the encoded
// timestamp.T.
func FromVarint(b []byte) (t *T, rem []byte, err error) {
n, read := binary.Varint(b)
if read < 1 {
err = errorf.E("failed to decode varint timestamp %v", b)
return
}
t = &T{n}
rem = b[:read]
return
}
// String renders a timestamp.T as a string.
func (t *T) String() (s string) {
b := make([]byte, 0, 20)
tt := ints.New(t.U64())
b = tt.Marshal(b)
return unsafe.String(&b[0], len(b))
}
// Marshal a timestamp.T into bytes and append to a provided byte slice.
func (t *T) Marshal(dst []byte) (b []byte) { return ints.New(t.U64()).Marshal(dst) }
// Unmarshal a byte slice with an encoded timestamp.T value and append it to a provided byte
// slice.
func (t *T) Unmarshal(b []byte) (r []byte, err error) {
n := ints.New(0)
if r, err = n.Unmarshal(b); chk.E(err) {
return
}
*t = T{n.Int64()}
return
}
// MarshalJSON marshals a timestamp.T using the json MarshalJSON interface.
func (t *T) MarshalJSON() ([]byte, error) {
return ints.New(t.U64()).Marshal(nil), nil
}

View File

@@ -0,0 +1,48 @@
package relayinfo
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/utils/normalize"
)
// Fetch fetches the NIP-11 Info.
func Fetch(c context.Context, u []byte) (info *T, err error) {
if _, ok := c.Deadline(); !ok {
// if no timeout is set, force it to 7 seconds
var cancel context.CancelFunc
c, cancel = context.WithTimeout(c, 7*time.Second)
defer cancel()
}
u = normalize.URL(u)
var req *http.Request
if req, err = http.NewRequestWithContext(
c, http.MethodGet, string(u), nil,
); chk.E(err) {
return
}
// add the NIP-11 header
req.Header.Add("Accept", "application/nostr+json")
// send the response
var resp *http.Response
if resp, err = http.DefaultClient.Do(req); chk.E(err) {
err = errorf.E("request failed: %w", err)
return
}
defer chk.E(resp.Body.Close())
var b []byte
if b, err = io.ReadAll(resp.Body); chk.E(err) {
return
}
info = &T{}
if err = json.Unmarshal(b, info); chk.E(err) {
return
}
return
}

View File

@@ -0,0 +1,15 @@
package relayinfo
import "testing"
func TestAddSupportedNIP(t *testing.T) {
info := NewInfo(nil)
info.AddNIPs(12, 12, 13, 1, 12, 44, 2, 13, 2, 13, 0, 17, 19, 1, 18)
for i, v := range []int{0, 1, 2, 12, 13, 17, 18, 19, 44} {
if !info.HasNIP(v) {
t.Errorf("expected info.nips[%d] to equal %v, got %v",
i, v, info.Nips)
return
}
}
}

View File

@@ -0,0 +1,39 @@
package relayinfo
// AddSupportedNIP appends a supported NIP number to a RelayInfo.
func (ri *T) AddSupportedNIP(n int) {
idx, exists := ri.Nips.HasNumber(n)
if exists {
return
}
ri.Nips = append(ri.Nips, -1)
copy(ri.Nips[idx+1:], ri.Nips[idx:])
ri.Nips[idx] = n
}
// Admission is the cost of opening an account with a relay.
type Admission struct {
Amount int `json:"amount"`
Unit string `json:"unit"`
}
// Subscription is the cost of keeping an account open for a specified period of time.
type Subscription struct {
Amount int `json:"amount"`
Unit string `json:"unit"`
Period int `json:"period"`
}
// Publication is the cost and restrictions on storing events on a relay.
type Publication []struct {
Kinds []int `json:"kinds"`
Amount int `json:"amount"`
Unit string `json:"unit"`
}
// Fees defines the fee structure used for a paid relay.
type Fees struct {
Admission []Admission `json:"admission,omitempty"`
Subscription []Subscription `json:"subscription,omitempty"`
Publication []Publication `json:"publication,omitempty"`
}

View File

@@ -0,0 +1,358 @@
package relayinfo
import (
"encoding/json"
"errors"
"os"
"sort"
"sync"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/utils/number"
)
// NIP is a number and description of a nostr "improvement" possibility.
type NIP struct {
Description string
Number int
}
// N returns the number of a nostr "improvement" possibility.
func (n NIP) N() int { return n.Number }
// GetList converts a NIP into a number.List of simple numbers, sorted in
// ascending order.
func GetList(items ...NIP) (n number.List) {
for _, item := range items {
n = append(n, item.N())
}
sort.Sort(n)
return
}
// this is the list of all nips and their titles for use in the supported_nips
// field
var (
BasicProtocol = NIP{"Basic protocol flow description", 1}
NIP1 = BasicProtocol
FollowList = NIP{"Follow List", 2}
NIP2 = FollowList
OpenTimestampsAttestations = NIP{
"OpenTimestamps Attestations for Events", 3,
}
NIP3 = OpenTimestampsAttestations
EncryptedDirectMessage = NIP{
"Direct Message deprecated in favor of NIP-44", 4,
}
NIP4 = EncryptedDirectMessage
MappingNostrKeysToDNS = NIP{
"Mapping Nostr keys to DNS-based identifiers", 5,
}
NIP5 = MappingNostrKeysToDNS
HandlingMentions = NIP{
"Handling Mentions deprecated in favor of NIP-27", 8,
}
NIP8 = HandlingMentions
EventDeletion = NIP{"Event Deletion", 9}
NIP9 = EventDeletion
RelayInformationDocument = NIP{"Client Information Document", 11}
NIP11 = RelayInformationDocument
GenericTagQueries = NIP{"Generic Tag Queries", 12}
NIP12 = GenericTagQueries
SubjectTag = NIP{"Subject tag in text events", 14}
NIP14 = SubjectTag
NostrMarketplace = NIP{
"Nostr Marketplace (for resilient marketplaces)", 15,
}
NIP15 = NostrMarketplace
EventTreatment = NIP{"EVent Treatment", 16}
NIP16 = EventTreatment
Reposts = NIP{"Reposts", 18}
NIP18 = Reposts
Bech32EncodedEntities = NIP{"bech32-encoded entities", 19}
NIP19 = Bech32EncodedEntities
CommandResults = NIP{"Command Results", 20}
NIP20 = CommandResults
NostrURIScheme = NIP{"nostr: URI scheme", 21}
NIP21 = NostrURIScheme
Comment = NIP{"Comment", 22}
NIP22 = Comment
LongFormContent = NIP{"Long-form Content", 23}
NIP23 = LongFormContent
ExtraMetadata = NIP{"Extra metadata fields and tags", 24}
NIP24 = ExtraMetadata
Reactions = NIP{"Reactions", 25}
NIP25 = Reactions
DelegatedEventSigning = NIP{"Delegated Event Signing", 26}
NIP26 = DelegatedEventSigning
TextNoteReferences = NIP{"Text Note References", 27}
NIP27 = TextNoteReferences
PublicChat = NIP{"Public Chat", 28}
NIP28 = PublicChat
CustomEmoji = NIP{"Custom Emoji", 30}
NIP30 = CustomEmoji
Labeling = NIP{"Labeling", 32}
NIP32 = Labeling
ParameterizedReplaceableEvents = NIP{"Parameterized Replaceable Events", 33}
NIP33 = ParameterizedReplaceableEvents
SensitiveContent = NIP{"Sensitive Content", 36}
NIP36 = SensitiveContent
UserStatuses = NIP{"User Statuses", 38}
NIP38 = UserStatuses
ExternalIdentitiesInProfiles = NIP{"External Identities in Profiles", 39}
NIP39 = ExternalIdentitiesInProfiles
ExpirationTimestamp = NIP{"Expiration Timestamp", 40}
NIP40 = ExpirationTimestamp
Authentication = NIP{
"Authentication of clients to relays", 42,
}
NIP42 = Authentication
VersionedEncryption = NIP{"Versioned Encryption", 44}
NIP44 = VersionedEncryption
CountingResults = NIP{"Counting results", 45}
NIP45 = CountingResults
NostrConnect = NIP{"Nostr Connect", 46}
NIP46 = NostrConnect
WalletConnect = NIP{"Wallet Connect", 47}
NIP47 = WalletConnect
ProxyTags = NIP{"Proxy Tags", 48}
NIP48 = ProxyTags
SearchCapability = NIP{"Search Capability", 50}
NIP50 = SearchCapability
Lists = NIP{"Lists", 51}
NIP51 = Lists
CalendarEvents = NIP{"Calendar Events", 52}
NIP52 = CalendarEvents
LiveActivities = NIP{"Live Activities", 53}
NIP53 = LiveActivities
Reporting = NIP{"Reporting", 56}
NIP56 = Reporting
LightningZaps = NIP{"Lightning Zaps", 57}
NIP57 = LightningZaps
Badges = NIP{"Badges", 58}
NIP58 = Badges
RelayListMetadata = NIP{"Client List Metadata", 65}
NIP65 = RelayListMetadata
ProtectedEvents = NIP{"Protected Events", 70}
NIP70 = ProtectedEvents
ModeratedCommunities = NIP{"Moderated Communities", 72}
NIP72 = ModeratedCommunities
ZapGoals = NIP{"Zap Goals", 75}
NIP75 = ZapGoals
ApplicationSpecificData = NIP{"Application-specific data", 78}
NIP78 = ApplicationSpecificData
Highlights = NIP{"Highlights", 84}
NIP84 = Highlights
RecommendedApplicationHandlers = NIP{"Recommended Application Handlers", 89}
NIP89 = RecommendedApplicationHandlers
DataVendingMachines = NIP{"Data Vending Machines", 90}
NIP90 = DataVendingMachines
FileMetadata = NIP{"File Metadata", 94}
NIP94 = FileMetadata
HTTPFileStorageIntegration = NIP{"HTTP File Storage Integration", 96}
NIP96 = HTTPFileStorageIntegration
HTTPAuth = NIP{"HTTP IsAuthed", 98}
NIP98 = HTTPAuth
ClassifiedListings = NIP{"Classified Listings", 99}
NIP99 = ClassifiedListings
)
var NIPMap = map[int]NIP{
1: NIP1, 2: NIP2, 3: NIP3, 4: NIP4, 5: NIP5, 8: NIP8, 9: NIP9,
11: NIP11, 12: NIP12, 14: NIP14, 15: NIP15, 16: NIP16, 18: NIP18, 19: NIP19,
20: NIP20,
21: NIP21, 22: NIP22, 23: NIP23, 24: NIP24, 25: NIP25, 26: NIP26, 27: NIP27,
28: NIP28,
30: NIP30, 32: NIP32, 33: NIP33, 36: NIP36, 38: NIP38, 39: NIP39, 40: NIP40,
42: NIP42,
44: NIP44, 45: NIP45, 46: NIP46, 47: NIP47, 48: NIP48, 50: NIP50, 51: NIP51,
52: NIP52,
53: NIP53, 56: NIP56, 57: NIP57, 58: NIP58, 65: NIP65, 72: NIP72, 75: NIP75,
78: NIP78,
84: NIP84, 89: NIP89, 90: NIP90, 94: NIP94, 96: NIP96, 98: NIP98, 99: NIP99,
}
// Limits are rules about what is acceptable for events and filters on a relay.
type Limits struct {
// MaxMessageLength is the maximum number of bytes for incoming JSON that
// the relay will attempt to decode and act upon. When you send large
// subscriptions, you will be limited by this value. It also effectively
// limits the maximum size of any event. Value is calculated from [ to ] and
// is after UTF-8 serialization (so some Unicode characters will cost 2-3
// bytes). It is equal to the maximum size of the WebSocket message frame.
MaxMessageLength int `json:"max_message_length,omitempty"`
// MaxSubscriptions is the total number of subscriptions that may be active
// on a single websocket connection to this relay. It's possible that
// authenticated clients with a (paid) relationship to the relay may have
// higher limits.
MaxSubscriptions int `json:"max_subscriptions,omitempty"`
// MaxFilter is the maximum number of filter values in each subscription.
// Must be one or higher.
MaxFilters int `json:"max_filters,omitempty"`
// MaxLimit is the relay server will clamp each filter's limit value to this
// number. This means the client won't be able to get more than this number
// of events from a single subscription filter. This clamping is typically
// done silently by the relay, but with this number, you can know that there
// are additional results if you narrowed your filter's time range or other
// parameters.
MaxLimit int `json:"max_limit,omitempty"`
// MaxSubidLength is the maximum length of subscription id as a string.
MaxSubidLength int `json:"max_subid_length,omitempty"`
// MaxEventTags in any event, this is the maximum number of elements in the
// tags list.
MaxEventTags int `json:"max_event_tags,omitempty"`
// MaxContentLength maximum number of characters in the content field of any
// event. This is a count of Unicode characters. After serializing into JSON
// it may be larger (in bytes), and is still subject to the
// max_message_length, if defined.
MaxContentLength int `json:"max_content_length,omitempty"`
// MinPowDifficulty new events will require at least this difficulty of PoW,
// based on NIP-13, or they will be rejected by this server.
MinPowDifficulty int `json:"min_pow_difficulty,omitempty"`
// AuthRequired means the relay requires NIP-42 authentication to happen
// before a new connection may perform any other action. Even if set to
// False, authentication may be required for specific actions.
AuthRequired bool `json:"auth_required"`
// PaymentRequired this relay requires payment before a new connection may
// perform any action.
PaymentRequired bool `json:"payment_required"`
// RestrictedWrites means this relay requires some kind of condition to be
// fulfilled to accept events (not necessarily, but including
// payment_required and min_pow_difficulty). This should only be set to true
// when users are expected to know the relay policy before trying to write
// to it -- like belonging to a special pubkey-based whitelist or writing
// only events of a specific niche kind or content. Normal anti-spam
// heuristics, for example, do not qualify.q
RestrictedWrites bool `json:"restricted_writes"`
Oldest *timestamp.T `json:"created_at_lower_limit,omitempty"`
Newest *timestamp.T `json:"created_at_upper_limit,omitempty"`
}
// Payment is an amount and currency unit name.
type Payment struct {
Amount int `json:"amount"`
Unit string `json:"unit"`
}
// Sub is a subscription, with the Payment and the period it yields.
type Sub struct {
Payment
Period int `json:"period"`
}
// Pub is a limitation for what you can store on the relay as a kinds.S and the
// cost (for???).
type Pub struct {
Kinds kind.S `json:"kinds"`
Payment
}
// T is the relay information document.
type T struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
PubKey string `json:"pubkey,omitempty"`
Contact string `json:"contact,omitempty"`
Nips number.List `json:"supported_nips"`
Software string `json:"software"`
Version string `json:"version"`
Limitation Limits `json:"limitation,omitempty"`
Retention any `json:"retention,omitempty"`
RelayCountries []string `json:"relay_countries,omitempty"`
LanguageTags []string `json:"language_tags,omitempty"`
Tags []string `json:"tags,omitempty"`
PostingPolicy string `json:"posting_policy,omitempty"`
PaymentsURL string `json:"payments_url,omitempty"`
Fees *Fees `json:"fees,omitempty"`
Icon string `json:"icon"`
sync.Mutex
}
// NewInfo populates the nips map, and if an Info structure is provided, it is
// used and its nips map is populated if it isn't already.
func NewInfo(inf *T) (info *T) {
if inf != nil {
info = inf
} else {
info = &T{
Limitation: Limits{
MaxLimit: 500,
},
}
}
return
}
// Clone replicates a relayinfo.T.
// todo: this could be done better, but i don't think it's in use.
func (ri *T) Clone() (r2 *T, err error) {
r2 = new(T)
var b []byte
if b, err = json.Marshal(ri); chk.E(err) {
return
}
if err = json.Unmarshal(b, r2); chk.E(err) {
return
}
return
}
// AddNIPs adds one or more numbers to the list of NIPs.
func (ri *T) AddNIPs(n ...int) {
ri.Lock()
for _, num := range n {
ri.Nips = append(ri.Nips, num)
}
ri.Unlock()
}
// HasNIP returns true if the given number is found in the list.
func (ri *T) HasNIP(n int) (ok bool) {
ri.Lock()
for i := range ri.Nips {
if ri.Nips[i] == n {
ok = true
break
}
}
ri.Unlock()
return
}
// Save the relayinfo.T to a given file as JSON.
func (ri *T) Save(filename string) (err error) {
if ri == nil {
err = errors.New("cannot save nil relay info document")
log.E.Ln(err)
return
}
var b []byte
if b, err = json.MarshalIndent(ri, "", " "); chk.E(err) {
return
}
if err = os.WriteFile(filename, b, 0600); chk.E(err) {
return
}
return
}
// Load a given file and decode the JSON relayinfo.T encoded in it.
func (ri *T) Load(filename string) (err error) {
if ri == nil {
err = errors.New("cannot load into nil config")
log.E.Ln(err)
return
}
var b []byte
if b, err = os.ReadFile(filename); chk.E(err) {
return
}
// log.S.ToSliceOfBytes("realy information document\n%s", string(b))
if err = json.Unmarshal(b, ri); chk.E(err) {
return
}
return
}

View File

@@ -0,0 +1,143 @@
// Package normalize is a set of tools for cleaning up URL s and formatting
// nostr OK and CLOSED messages.
package normalize
import (
"bytes"
"fmt"
"net/url"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/ints"
)
var (
hp = bytes.HasPrefix
WS = []byte("ws://")
WSS = []byte("wss://")
HTTP = []byte("http://")
HTTPS = []byte("https://")
)
// URL normalizes the URL
//
// - Adds wss:// to addresses without a port, or with 443 that have no protocol
// prefix
//
// - Adds ws:// to addresses with any other port
//
// - Converts http/s to ws/s
func URL[V string | []byte](v V) (b []byte) {
u := []byte(v)
if len(u) == 0 {
return nil
}
u = bytes.TrimSpace(u)
u = bytes.ToLower(u)
// if the address has a port number, we can probably assume it is insecure
// websocket as most public or production relays have a domain name and a
// well-known port 80 or 443 and thus no port number.
//
// if a protocol prefix is present, we assume it is already complete.
// Converting http/s to websocket-equivalent will be done later anyway.
if bytes.Contains(u, []byte(":")) &&
!(hp(u, HTTP) || hp(u, HTTPS) || hp(u, WS) || hp(u, WSS)) {
split := bytes.Split(u, []byte(":"))
if len(split) != 2 {
log.D.F("Error: more than one ':' in URL: '%s'", u)
// this is a malformed URL if it has more than one ":", return empty
// since this function does not return an error explicitly.
return
}
p := ints.New(0)
_, err := p.Unmarshal(split[1])
if chk.E(err) {
log.D.F("Error normalizing URL '%s': %s", u, err)
// again, without an error, we must return nil
return
}
if p.Uint64() > 65535 {
log.D.F(
"Port on address %d: greater than maximum 65535",
p.Uint64(),
)
return
}
// if the port is explicitly set to 443 we assume it is wss:// and drop
// the port.
if p.Uint16() == 443 {
u = append(WSS, split[0]...)
} else {
u = append(WSS, u...)
}
}
// if the prefix isn't specified as http/s or websocket, assume secure
// websocket and add wss prefix (this is the most common).
if !(hp(u, HTTP) || hp(u, HTTPS) || hp(u, WS) || hp(u, WSS)) {
u = append(WSS, u...)
}
var err error
var p *url.URL
if p, err = url.Parse(string(u)); chk.E(err) {
return
}
// convert http/s to ws/s
switch p.Scheme {
case "https":
p.Scheme = "wss"
case "http":
p.Scheme = "ws"
}
// remove trailing path slash
p.Path = string(bytes.TrimRight([]byte(p.Path), "/"))
return []byte(p.String())
}
// Msg constructs a properly formatted message with a machine-readable prefix
// for OK and CLOSED envelopes.
func Msg(prefix Reason, format string, params ...any) []byte {
if len(prefix) < 1 {
prefix = Error
}
return []byte(fmt.Sprintf(prefix.S()+": "+format, params...))
}
// Reason is the machine-readable prefix before the colon in an OK or CLOSED
// envelope message. Below are the most common kinds that are mentioned in
// NIP-01.
type Reason []byte
var (
AuthRequired = Reason("auth-required")
PoW = Reason("pow")
Duplicate = Reason("duplicate")
Blocked = Reason("blocked")
RateLimited = Reason("rate-limited")
Invalid = Reason("invalid")
Error = Reason("error")
Unsupported = Reason("unsupported")
Restricted = Reason("restricted")
)
// S returns the Reason as a string
func (r Reason) S() string { return string(r) }
// B returns the Reason as a byte slice.
func (r Reason) B() []byte { return r }
// IsPrefix returns whether a text contains the same Reason prefix.
func (r Reason) IsPrefix(reason []byte) bool {
return bytes.HasPrefix(
reason, r.B(),
)
}
// F allows creation of a full Reason text with a printf style format.
func (r Reason) F(format string, params ...any) []byte {
return Msg(
r, format, params...,
)
}

View File

@@ -0,0 +1,35 @@
package normalize
import (
"fmt"
"testing"
)
func TestURL(t *testing.T) {
fmt.Println(URL([]byte("")))
fmt.Println(URL([]byte("wss://x.com/y")))
fmt.Println(URL([]byte("wss://x.com/y/")))
fmt.Println(URL([]byte("http://x.com/y")))
fmt.Println(URL(URL([]byte("http://x.com/y"))))
fmt.Println(URL([]byte("wss://x.com")))
fmt.Println(URL([]byte("wss://x.com/")))
fmt.Println(URL(URL(URL([]byte("wss://x.com/")))))
fmt.Println(URL([]byte("x.com")))
fmt.Println(URL([]byte("x.com/")))
fmt.Println(URL([]byte("x.com////")))
fmt.Println(URL([]byte("x.com/?x=23")))
// Output:
//
// wss://x.com/y
// wss://x.com/y
// ws://x.com/y
// ws://x.com/y
// wss://x.com
// wss://x.com
// wss://x.com
// wss://x.com
// wss://x.com
// wss://x.com
// wss://x.com?x=23
}

36
pkg/utils/number/list.go Normal file
View File

@@ -0,0 +1,36 @@
// Package number implements a simple number list, used with relayinfo package
// for NIP support lists.
package number
import "fmt"
// List is a simple list of numbers with a sort implementation and number match.
type List []int
func (l List) Len() int { return len(l) }
func (l List) Less(i, j int) bool { return l[i] < l[j] }
func (l List) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
// HasNumber returns true if the list contains a given number
func (l List) HasNumber(n int) (idx int, has bool) {
for idx = range l {
if l[idx] == n {
has = true
return
}
}
return
}
// String outputs a number.List as a minified JSON array.
func (l List) String() (s string) {
s += "["
for i := range l {
if i > 0 {
s += ","
}
s += fmt.Sprint(l[i])
}
s += "]"
return
}

View File

@@ -9,4 +9,4 @@ var V string
var Description = "relay powered by the orly framework https://next.orly.dev"
var URL = "https://nextorly.dev"
var URL = "https://next.orly.dev"