core

package
v0.8.0-rc1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 18, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

client/core/eventSerializer.go

Index

Examples

Constants

View Source
const (
	MediaKindBlossom = "blossom" // BUD-03 kind 10063; BUD-02 /upload, BUD-04 /mirror
	MediaKindNIP96   = "nip96"   // kind 10096; NIP-96 multipart upload + NIP-98 auth
)

Media-server protocol kinds. Blossom is nostr-native (blobs addressed by sha256, signed kind-24242 auth); NIP-96 is traditional HTTP storage authed with NIP-98. grain prefers Blossom and treats NIP-96 as a legacy fallback.

View Source
const DefaultClientTagName = "grain"

DefaultClientTagName is grain's client-tag value when none is configured. The "on by default" semantics live in the resolving handler + config loader; this is just the fallback name.

Variables

This section is empty.

Functions

func ApplyClientTag

func ApplyClientTag(event *nostr.Event, enabled bool, name string)

ApplyClientTag rewrites an event's client tag in place per grain's policy: strip any foreign `client` tag always, add grain's own when enabled. Safe on a nil event. Build handlers call this after assembling an event grain authors.

Example

Stamping grain's NIP-89 client tag on an event before signing (any foreign client tag is stripped first; pass false to strip without re-adding).

package main

import (
	"fmt"

	"github.com/0ceanslim/grain/client/core"
	nostr "github.com/0ceanslim/grain/server/types"
)

func main() {
	ev := &nostr.Event{Kind: 1, Content: "gm"}
	core.ApplyClientTag(ev, true, "grain")
	fmt.Println(len(ev.Tags))
}

func AssembleMediaServerEvent

func AssembleMediaServerEvent(existing *nostr.Event, kind int, pubkey string, servers []string) (*nostr.Event, error)

AssembleMediaServerEvent builds an UNSIGNED media-server list event (Blossom kind 10063 or NIP-96 kind 10096) from the user's existing one, replacing the server list while preserving every other tag and the content — the same conservative "don't drop data" rule as AssembleProfileEvent.

servers are written as `["server", <url>]` tags in the given order (primary first), normalised and de-duplicated. existing may be nil (a first list). The returned event has no ID or Sig: the caller signs it.

func AssembleProfileEvent

func AssembleProfileEvent(existing *nostr.Event, edits map[string]string) (*nostr.Event, error)

AssembleProfileEvent builds an UNSIGNED kind-0 (profile metadata) event from an existing one by applying edits. It is deliberately conservative about not losing data:

  • Every existing content field is preserved; only edited fields are overwritten. The content JSON stays the interoperable source of truth that all clients read.
  • Every existing tag is preserved; for each edited field a [key, value] tag is upserted — replacing a prior tag with the same key rather than duplicating — so tag-aware clients can read the field too (dual-write).

The returned event has no ID or Sig: the caller signs it. Returns an error if the existing content isn't valid JSON, rather than silently clobbering a profile we can't safely parse.

func AssembleRelayListEvent

func AssembleRelayListEvent(existing *nostr.Event, kind int, pubkey string, entries []RelayListEntry) (*nostr.Event, error)

AssembleRelayListEvent builds an UNSIGNED relay-list event of the given kind from the user's existing one, rewriting the relay tags while preserving the content and every non-relay tag — the same conservative "don't drop data" rule as the profile and media-server editors.

Tag shape by kind:

  • 10002 (NIP-65): ["r", url] (both), ["r", url, "read"], or ["r", url, "write"]
  • 10050 / 10006 / 10007 / 10012: ["relay", url]

existing may be nil (a first list). The returned event has no ID or Sig: the caller signs it.

Example

Writing a relay list: assemble an UNSIGNED 10002 from the desired entries, then sign + outbox-route + publish it with the user context.

package main

import (
	"context"
	"fmt"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	client := core.NewClient(core.DefaultConfig())
	signer, err := core.NewEventSigner("64-char-hex-private-key")
	if err != nil {
		return
	}
	uc := client.NewUserContext(signer.PublicKey(), core.WithSigner(signer))

	entries := []core.RelayListEntry{
		{URL: "wss://out.example.com", Write: true},              // outbox only
		{URL: "wss://in.example.com", Read: true},                // inbox only
		{URL: "wss://both.example.com", Read: true, Write: true}, // both (unmarked)
	}
	unsigned, err := core.AssembleRelayListEvent(nil, 10002, uc.PublicKey(), entries)
	if err != nil {
		return
	}
	results, err := uc.SignAndPublish(context.Background(), unsigned)
	if err != nil {
		return
	}
	fmt.Printf("published to %d relays\n", len(results))
}

func ComputeEventID

func ComputeEventID(event *nostr.Event) (string, error)

ComputeEventID computes the event ID according to NIP-01

func ContactListFilter

func ContactListFilter(pubkey string) nostr.Filter

ContactListFilter creates a filter for contact lists (kind 3)

func CreateNostrMessage

func CreateNostrMessage(messageType string, args ...interface{}) ([]byte, error)

CreateNostrMessage creates a properly formatted Nostr protocol message

func DerivePublicKey

func DerivePublicKey(privateKeyHex string) (string, error)

DerivePublicKey derives a public key from a private key hex

func DeserializeEvent

func DeserializeEvent(data []byte) (*nostr.Event, error)

DeserializeEvent deserializes JSON bytes to an event

func EventFromJSON

func EventFromJSON(data []byte) (*nostr.Event, error)

EventFromJSON parses an event from JSON

func EventToJSON

func EventToJSON(event *nostr.Event) ([]byte, error)

EventToJSON converts an event to pretty-printed JSON

func GeneratePrivateKey

func GeneratePrivateKey() (string, error)

GeneratePrivateKey generates a new random private key

func GetPublicKeyFromExtension

func GetPublicKeyFromExtension() (string, error)

GetPublicKeyFromExtension attempts to get public key from browser extension

func NotesFilter

func NotesFilter(authors []string, limit int) nostr.Filter

NotesFilter creates a filter for notes from specific authors

func ParseNostrMessage

func ParseNostrMessage(data []byte) (messageType string, args []interface{}, err error)

ParseNostrMessage parses a Nostr protocol message

func ParseRelayTagURLs

func ParseRelayTagURLs(event *nostr.Event) []string

ParseRelayTagURLs extracts the `relay` tag URLs from a NIP-17 / NIP-51 relay-list event (kinds 10050 / 10006 / 10007 / 10012), normalised and de-duplicated, in order.

func ProfileFilter

func ProfileFilter(pubkey string) nostr.Filter

ProfileFilter creates a filter for user profiles (kind 0)

func ReactionsFilter

func ReactionsFilter(eventID string) nostr.Filter

ReactionsFilter creates a filter for reactions to a specific event

func RecentNotesFilter

func RecentNotesFilter(limit int, maxAge time.Duration) nostr.Filter

RecentNotesFilter creates a filter for recent notes

func RelayListFilter

func RelayListFilter(pubkey string) nostr.Filter

RelayListFilter creates a filter for relay lists (kind 10002)

func SerializeEvent

func SerializeEvent(evt nostr.Event) string

SerializeEvent manually constructs the JSON string for event serialization according to NIP-01

func SerializeEventArray

func SerializeEventArray(events []*nostr.Event) ([]byte, error)

SerializeEventArray serializes an event for inclusion in a Nostr message array

func SetLogger

func SetLogger(l Logger)

SetLogger replaces the process-wide logger the client library writes through. It is package-global by design: the library logs from many package-level functions with no Client in scope, so threading a per-Client logger everywhere would add a parameter to most of the surface for little gain. Call it once at startup. A nil logger is ignored; passing log.ClientCore() restores the default. Safe for concurrent use.

Example

Replacing grain's logging so the library writes through a consumer's logger. Any *slog.Logger satisfies core.Logger; a consumer can also implement the four methods (Debug/Info/Warn/Error) over their own logging stack.

package main

import (
	"log/slog"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	core.SetLogger(slog.Default())
}

func SignEventWithExtension

func SignEventWithExtension(event *nostr.Event) error

SignEventWithExtension attempts to sign an event using browser extension (NIP-07)

func TimeRangeFilter

func TimeRangeFilter(since, until time.Time, kinds []int) nostr.Filter

TimeRangeFilter creates a filter for events within a specific time range

func ValidateEventStructure

func ValidateEventStructure(event *nostr.Event) error

ValidateEventStructure validates the basic structure of an event

func VerifyEventSignature

func VerifyEventSignature(event *nostr.Event) bool

VerifyEventSignature verifies an event's signature

Types

type AuthState

type AuthState struct {
	Relay     string    `json:"relay"`
	Challenge string    `json:"challenge"`
	Authed    bool      `json:"authed"`
	At        time.Time `json:"at"`
}

AuthState is what the pool has observed about one relay's NIP-42 status this session: its latest challenge and whether we've answered it.

type BroadcastResult

type BroadcastResult struct {
	RelayURL string
	Success  bool
	Accepted bool
	Reason   string
	Error    error
	Message  string
	Duration time.Duration
}

BroadcastResult represents the result of broadcasting to a single relay. Success means the EVENT was sent; Accepted is the relay's NIP-20 OK verdict (whether it actually stored the event), with Reason carrying any message.

func BroadcastEvent

func BroadcastEvent(ctx context.Context, event *nostr.Event, relays []string, pool *RelayPool) []BroadcastResult

BroadcastEvent sends an event to multiple relays using the relay pool. The context bounds the per-relay connect and the NIP-20 OK-collection wait, so a caller can cancel or deadline the whole publish.

func BroadcastToUserRelays

func BroadcastToUserRelays(ctx context.Context, event *nostr.Event, pubkey string, client *Client) []BroadcastResult

BroadcastToUserRelays broadcasts an event to a user's preferred relays

func BroadcastWithRetry

func BroadcastWithRetry(ctx context.Context, event *nostr.Event, relays []string, pool *RelayPool, maxRetries int) []BroadcastResult

BroadcastWithRetry broadcasts an event with retry logic

func PublishEvent

func PublishEvent(ctx context.Context, client *Client, signer *EventSigner, eventBuilder *EventBuilder, targetRelays []string) (*nostr.Event, []BroadcastResult, error)

PublishEvent is a high-level function to build, sign, and broadcast an event

func PublishEventWithRetry

func PublishEventWithRetry(ctx context.Context, client *Client, signer *EventSigner, eventBuilder *EventBuilder, targetRelays []string, maxRetries int) (*nostr.Event, []BroadcastResult, error)

PublishEventWithRetry publishes an event with retry logic

type BroadcastSummary

type BroadcastSummary struct {
	TotalRelays     int
	Successful      int
	Failed          int
	SuccessRate     float64
	AverageDuration time.Duration
	Errors          []string
}

BroadcastSummary provides a summary of broadcast results

func SummarizeBroadcast

func SummarizeBroadcast(results []BroadcastResult) BroadcastSummary

SummarizeBroadcast creates a summary of broadcast results

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client represents the main Nostr client with connection pooling

Example (ReadOnly)

Read-only: fetch a user's recent notes from their outbox relays. No signer is attached, so the context can read but not publish.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	client := core.NewClient(core.DefaultConfig())
	uc := client.NewUserContext("author-pubkey-hex")

	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
	defer cancel()

	notes := uc.FetchNotes(ctx, "author-pubkey-hex", core.WithLimit(20))
	fmt.Printf("fetched %d notes\n", len(notes))
}

func NewClient

func NewClient(config *Config) *Client

NewClient creates a new Nostr client instance

func (*Client) AppRelays

func (c *Client) AppRelays(role Role) []string

AppRelays returns the session relays for a locally-configured role. RoleIndexer falls back to the configured index relays when the user hasn't overridden it; the other roles return nil when unset.

func (*Client) AuthChallenge

func (c *Client) AuthChallenge(url string) string

AuthChallenge returns a relay's pending AUTH challenge ("" if none).

func (*Client) AuthRequests

func (c *Client) AuthRequests() []AuthState

AuthRequests returns the relays that have challenged the pool for NIP-42 AUTH.

func (*Client) ClearFixedRelays

func (c *Client) ClearFixedRelays()

ClearFixedRelays disables the override and restores default outbox routing.

func (*Client) Close

func (c *Client) Close() error

func (*Client) ConnectToRelays

func (c *Client) ConnectToRelays(urls []string) error

ConnectToRelays establishes connections to multiple relay URLs

func (*Client) ConnectToRelaysWithRetry

func (c *Client) ConnectToRelaysWithRetry(urls []string, maxRetries int) error

ConnectToRelaysWithRetry establishes connections with retry logic

func (*Client) DisconnectFromRelay added in v0.4.12

func (c *Client) DisconnectFromRelay(relayURL string) error

DisconnectFromRelay closes a specific relay connection

func (*Client) DisconnectFromRelays added in v0.4.12

func (c *Client) DisconnectFromRelays(relayURLs []string) error

DisconnectFromRelays closes connections to multiple relays

func (*Client) FetchEvents

func (c *Client) FetchEvents(ctx context.Context, filters []nostr.Filter, relays []string, limit int, timeout time.Duration) []*nostr.Event

FetchEvents collects up to limit distinct events (deduped by id) matching filters from the given relays, returning when every relay has sent EOSE, the limit is reached, or the timeout elapses. Unlike collectLatestReplaceable (which keeps only the single newest), this gathers a batch — used for bulk queries like relay-list seeding.

func (*Client) FetchMediaServerList

func (c *Client) FetchMediaServerList(pubkey string, kind int) *nostr.Event

FetchMediaServerList returns the user's latest raw event of the given media-server list kind (10063 Blossom or 10096 NIP-96), or nil if none is found. Used by the build flow to preserve any non-server tags when republishing the list. Queries the index relays plus the user's cached outbox, where these lists most often live.

func (*Client) FetchRelayInfo

func (c *Client) FetchRelayInfo(url string) *RelayInfo

FetchRelayInfo returns a relay's NIP-11 document, TTL-cached. It is a plain HTTP GET of the relay's root with `Accept: application/nostr+json` — not a pool/WebSocket connection — so the known-relays browser can show name, software, supported NIPs, and the auth/payment flags. Returns nil when the relay serves no NIP-11 or it can't be parsed (also cached, to avoid retry storms on a relay that doesn't advertise one).

func (*Client) FetchRelayList

func (c *Client) FetchRelayList(pubkey string, kind int) *nostr.Event

FetchRelayList returns the user's latest raw relay-list event of the given kind, or nil. Used by the build flow to preserve non-relay tags on republish. Mirrors FetchMediaServerList: index relays plus the user's cached outbox.

func (*Client) FetchUserRelayLists

func (c *Client) FetchUserRelayLists(pubkey string) *UserRelayLists

FetchUserRelayLists resolves a user's relay lists for every kind the relay manager shows, fetching the kinds concurrently. Each goroutine writes a distinct field, so no locking is needed.

Example

Reading a user's relay lists: NIP-65 (with read/write markers) plus the NIP-51/37 mailbox lists.

package main

import (
	"fmt"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	client := core.NewClient(core.DefaultConfig())

	lists := client.FetchUserRelayLists("author-pubkey-hex")
	for _, e := range lists.NIP65 {
		fmt.Println(e.URL, "read:", e.Read, "write:", e.Write)
	}
	fmt.Println("dm relays:", lists.DM)
}

func (*Client) FixedRelaysEnabled

func (c *Client) FixedRelaysEnabled() bool

FixedRelaysEnabled reports whether the fixed-relay override is active.

func (*Client) GetConnectedRelays

func (c *Client) GetConnectedRelays() []string

GetConnectedRelays returns a list of currently connected relay URLs

func (*Client) GetRelayStatus

func (c *Client) GetRelayStatus() map[string]string

GetRelayStatus returns detailed status of all relay connections

func (*Client) GetUserProfile

func (c *Client) GetUserProfile(ctx context.Context, pubkey string, relayHints []string) (*nostr.Event, error)

func (*Client) GetUserRelays

func (c *Client) GetUserRelays(pubkey string) (*Mailboxes, error)

GetUserRelays retrieves a user's relay list (NIP-65), resolved through the directory — an index-relay query that is TTL-cached and single-flighted — rather than fanning a REQ out to every connected relay. That fan-out is what melted the dashboard during a mutelist sync once the outbox pool had grown to dozens of connections: each author's lookup blasted all of them. Returns empty mailboxes (not an error) when the user has published no relay list.

func (*Client) InvalidateMediaServers

func (c *Client) InvalidateMediaServers(pubkey string)

InvalidateMediaServers drops a user's cached media-server lists so the next resolve re-fetches — used after the user publishes an updated list.

func (*Client) InvalidateUserRelayLists

func (c *Client) InvalidateUserRelayLists(pubkey string)

InvalidateUserRelayLists drops a user's cached relay lists so the next resolve re-fetches — call after they publish a new relay-list event.

func (*Client) InvalidateUserRelays

func (c *Client) InvalidateUserRelays(pubkey string)

InvalidateUserRelays drops a user's cached relay-role resolution so the next lookup re-resolves — call after the user republishes their own 10002 / 10050 so routing picks up the change immediately.

func (*Client) KnownRelays

func (c *Client) KnownRelays() []string

KnownRelays returns the known set as a sorted slice — the relays behind PoolStats.Known, for the known-relays browser.

Example

Browsing known relays: the live set, NIP-11 metadata, and TCP latency.

package main

import (
	"fmt"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	client := core.NewClient(core.DefaultConfig())

	known := client.KnownRelays()
	pings := client.PingRelays(known) // map[url]ms, concurrent

	for _, url := range known {
		name := url
		if info := client.FetchRelayInfo(url); info != nil && info.Name != "" {
			name = info.Name // NIP-11, TTL-cached; nil if the relay serves none
		}
		fmt.Printf("%s — %dms\n", name, pings[url])
	}
}

func (*Client) KnownRelaysWithStatus

func (c *Client) KnownRelaysWithStatus() []KnownRelayStatus

KnownRelaysWithStatus returns every known relay (sorted) annotated with its live pool status. NIP-11 detail is fetched separately, lazily, per relay.

func (*Client) NewUserContext

func (c *Client) NewUserContext(pubkey string, opts ...UserOption) *UserContext

NewUserContext creates a UserContext for pubkey (64-char hex) on this client. Apply options such as WithSigner to attach a signer.

func (*Client) OwnListRelays

func (c *Client) OwnListRelays(pubkey string) []string

OwnListRelays returns the relay set to read a user's own replaceable lists from: the index relays plus their resolved read+write relays. A user's own NIP-51/17 lists live on their own relays, which a cold load may not have cached. Shared by FetchUserRelayLists and the live-sync subscription.

func (*Client) PinRelays

func (c *Client) PinRelays(urls ...string)

PinRelays marks relays so the idle sweeper never evicts them — used for the index/seed relays. Pinning does not dial; the connection is still established on demand by Acquire or the startup connect.

func (*Client) PingRelay

func (c *Client) PingRelay(relayURL string) int

PingRelay measures TCP-connect latency to a relay — a cheap reachability/ latency probe, not a WebSocket or NIP-01 round-trip — and TTL-caches it. Returns milliseconds, or -1 if the host can't be reached within the timeout (also cached, so onion/unroutable hosts don't stall every sort). Used by the known-relays "fastest first" ordering.

func (*Client) PingRelays

func (c *Client) PingRelays(urls []string) map[string]int

PingRelays pings a set of relays in parallel (bounded worker pool), returning url -> ms (-1 for unreachable). The relay manager calls this for just the rows currently in view, so the set is small even though the known set is large.

func (*Client) PoolStats

func (c *Client) PoolStats() PoolStats

PoolStats returns a snapshot of the pool's counts plus Known — the distinct relays the client is aware of: configured defaults, every relay tracked in the pool, and every relay from the indexer-seeded mailbox lists the directory has resolved. Known climbs as you interact; connections grow on demand.

func (*Client) PublishEvent

func (c *Client) PublishEvent(ctx context.Context, event *nostr.Event, targetRelays []string) ([]BroadcastResult, error)

PublishEvent publishes an event to specified relays

func (*Client) PublishEventStream

func (c *Client) PublishEventStream(ctx context.Context, event *nostr.Event, relays []string) <-chan BroadcastResult

PublishEventStream broadcasts an already-signed event to the given relays and returns a channel that emits each relay's result as it resolves. The caller ranges the channel until it closes. Used by the streaming publish endpoint to drive the live broadcast toast.

func (*Client) PublishEventWithRetry

func (c *Client) PublishEventWithRetry(ctx context.Context, event *nostr.Event, targetRelays []string, maxRetries int) ([]BroadcastResult, error)

PublishEventWithRetry publishes an event with retry logic

func (*Client) QueryEvents

func (c *Client) QueryEvents(ctx context.Context, filter nostr.Filter, relays []string, opts ...StreamOption) []*nostr.Event

QueryEvents runs Client.StreamEvents and collects every event it yields, in arrival order. A blocking convenience for callers that don't need incremental delivery; pass WithLimit / WithTimeout to bound it.

func (*Client) RemoveAuth

func (c *Client) RemoveAuth(url string)

RemoveAuth forgets a relay's session AUTH state.

func (*Client) ReplaceRelayConnections added in v0.4.12

func (c *Client) ReplaceRelayConnections(newRelays []RelayConfig) error

ReplaceRelayConnections swaps the relays held for the current session.

In the outbox model this is ADDITIVE: it does not tear the shared pool down. It releases the previous session's leases (so those connections become idle-evictable once nothing else needs them) and acquires the new set, holding one lease on each for the session's lifetime. Index/seed relays are pinned separately and are never affected by a session switch.

func (*Client) ResolveMediaServers

func (c *Client) ResolveMediaServers(pubkey string) *MediaServers

ResolveMediaServers returns a user's Blossom (kind 10063) and NIP-96 (kind 10096) media-server lists, resolved from their published events and cached with a TTL. Safe for concurrent use; the logged-in user is just another pubkey.

Example

Resolving a user's media servers (Blossom + NIP-96) before an upload. HasAny is the "open the picker vs. prompt to set some up" decision.

package main

import (
	"fmt"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	client := core.NewClient(core.DefaultConfig())

	ms := client.ResolveMediaServers("author-pubkey-hex")
	if !ms.HasAny() {
		fmt.Println("no media servers configured")
		return
	}
	fmt.Printf("blossom: %v\n", ms.Blossom) // primary first
}

func (*Client) ResolveRelays

func (c *Client) ResolveRelays(pubkey string) *UserRelays

ResolveRelays returns a target user's per-target relay roles (outbox / inbox / DM inbox), resolved from their published relay-list events and cached with a TTL. Safe for concurrent use; the logged-in user is just another pubkey.

func (*Client) ResolveUserRelayLists

func (c *Client) ResolveUserRelayLists(pubkey string) *UserRelayLists

ResolveUserRelayLists returns a user's relay lists from cache when fresh, otherwise resolves them from the network and caches the result. The relay manager reads through this so the page renders from cache once login-hydration has warmed it.

func (*Client) Route

func (c *Client) Route(op RouteOp, target string) []string

Route returns the relays a read operation against target would use under the current routing, honouring the fixed-relay override. It is a thin, inspectable facade over Client.RouteFetch / Client.RouteMetadata.

Publishing is intentionally not covered here: it routes per-event (author outbox ∪ each recipient's inbox), so use Client.RoutePublish with the event.

func (*Client) RouteFetch

func (c *Client) RouteFetch(pubkey string) []string

RouteFetch returns the relays to read a user's authored events from: their outbox (NIP-65 write) relays, falling back to the index/seed relays when the user has no published list. If the fixed-relay override is enabled, the pinned read set is used instead (outbox routing off).

func (*Client) RouteMetadata

func (c *Client) RouteMetadata(pubkey string) []string

RouteMetadata returns the relays to fetch a user's profile/metadata (kind 0) and replaceable lists from.

Flow: the index/profile-indexer relays aggregate everyone's metadata and are already connected, so they are always queried (fast, reliable). The user's own outbox often carries a fresher copy, so it is added too — but only when it is ALREADY cached, never via a blocking resolve. Resolving every profile view synchronously is what made the dashboard crawl; the cache is warmed by the relay-list lookups that happen anyway, so subsequent views include the outbox for free. Honours the fixed-relay override.

func (*Client) RoutePublish

func (c *Client) RoutePublish(event *nostr.Event) []string

RoutePublish returns the relays an event should be published to under the outbox model: the author's own outbox PLUS every p-tagged recipient's inbox (their DM inbox for NIP-17 gift wraps), so the event reaches both the author's audience and its intended recipients. Falls back to the index/seed relays when nothing resolves.

func (*Client) SeedKnownRelays

func (c *Client) SeedKnownRelays()

SeedKnownRelays bulk-fetches recent relay-list events (NIP-65 kind 10002 and NIP-17 kind 10050) from the index relays and folds every advertised relay into the directory, so the "known" set starts broad immediately instead of growing only as the user browses. Runs once at startup; safe to re-run to refresh. All relay URLs are normalised on the way in (see normalizeRelayURL), so near-duplicates collapse.

func (*Client) SendAuth

func (c *Client) SendAuth(url string, signedEvent *nostr.Event) error

SendAuth relays a browser-signed kind-22242 event to a relay.

Example

Answering a relay's NIP-42 AUTH challenge: build + sign a kind-22242 event and forward it on the challenged connection.

package main

import (
	"github.com/0ceanslim/grain/client/core"
	nostr "github.com/0ceanslim/grain/server/types"
)

func main() {
	client := core.NewClient(core.DefaultConfig())
	signer, err := core.NewEventSigner("64-char-hex-private-key")
	if err != nil {
		return
	}
	uc := client.NewUserContext(signer.PublicKey(), core.WithSigner(signer))

	for _, req := range client.AuthRequests() {
		if req.Authed {
			continue // already answered this session
		}
		ev := &nostr.Event{
			Kind: 22242,
			Tags: [][]string{
				{"relay", req.Relay},
				{"challenge", req.Challenge},
			},
		}
		if err := uc.Sign(ev); err != nil {
			continue
		}
		if err := client.SendAuth(req.Relay, ev); err != nil {
			continue
		}
	}
}

func (*Client) SetAppRelays

func (c *Client) SetAppRelays(role Role, urls []string)

SetAppRelays sets (or clears, when urls is empty) the session override for a locally-configured role. Clearing RoleIndexer restores the configured default.

func (*Client) SetFixedRelays

func (c *Client) SetFixedRelays(readRelays, writeRelays []string)

SetFixedRelays enables the fixed-relay override: every read uses readRelays and every write uses writeRelays, bypassing outbox routing entirely.

This DISABLES the outbox model — replies will not reach other users' inbox relays — and is intended only for users who explicitly want a fixed- or single-relay client. It is off by default and not recommended.

func (*Client) StartEvictionSweeper

func (c *Client) StartEvictionSweeper(ctx context.Context, interval time.Duration)

StartEvictionSweeper starts the pool's idle-connection sweeper, bounded to ctx.

func (*Client) StreamEvents

func (c *Client) StreamEvents(ctx context.Context, filter nostr.Filter, relays []string, opts ...StreamOption) <-chan *nostr.Event

StreamEvents subscribes to filter across relays and streams matching events on the returned channel as each relay answers, de-duplicated by event id. The channel closes when every relay has signalled end-of-stored-events (a bounded fetch; see WithLive), the WithLimit count is reached, ctx is cancelled, or the WithTimeout elapses — whichever comes first.

This is the general, multi-relay form of a live feed: point it at a single relay (e.g. grain's own) for a single-source stream, or at a user's outbox set for an outbox feed. Per-relay failures are logged, not fatal. The caller must drain the channel (or cancel ctx) so the underlying subscription is released.

Example

Streaming: a live feed of kind-1 notes from a single relay (e.g. grain's own).

package main

import (
	"context"
	"fmt"

	"github.com/0ceanslim/grain/client/core"
	nostr "github.com/0ceanslim/grain/server/types"
)

func main() {
	client := core.NewClient(core.DefaultConfig())
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	feed := client.StreamEvents(ctx,
		nostr.Filter{Kinds: []int{1}},
		[]string{"wss://relay.example.com"},
		core.WithLive(), core.WithLimit(50))

	for ev := range feed {
		fmt.Println(ev.ID)
	}
}

func (*Client) Subscribe

func (c *Client) Subscribe(ctx context.Context, filters []nostr.Filter, relayHints []string) (*Subscription, error)

Subscribe creates a new subscription with filters and relay hints

func (*Client) SwitchToIndexRelays added in v0.5.0

func (c *Client) SwitchToIndexRelays() error

SwitchToIndexRelays releases the current session's relay leases. There is nothing to "switch back" to in the additive model: the index/seed relays are pinned and kept up by the health check, so they remain connected regardless.

func (*Client) SwitchToUserRelays added in v0.4.12

func (c *Client) SwitchToUserRelays(userRelays []RelayConfig) error

SwitchToUserRelays holds the user's relays for the session (additive — see ReplaceRelayConnections). Index relays stay connected alongside them.

func (*Client) WarmMediaServers

func (c *Client) WarmMediaServers(pubkey string)

WarmMediaServers resolves a user's media-server lists into the cache in the background — used by login-hydration so the media settings page is instant.

func (*Client) WarmRelays

func (c *Client) WarmRelays(pubkey string)

WarmRelays kicks an asynchronous resolution of a user's relay lists into the directory if not already cached, so their mailbox relays join the "known" set and the cache is ready for outbox-routed fetches next time — without blocking the caller and without dialing (resolution queries the index relays only).

func (*Client) WarmUserRelayLists

func (c *Client) WarmUserRelayLists(pubkey string)

WarmUserRelayLists resolves a user's relay lists into the cache in the background — used by login-hydration so the settings page is instant.

type ClientError

type ClientError struct {
	Message string
}

ClientError represents client-specific errors

func (*ClientError) Error

func (e *ClientError) Error() string

type Config

type Config struct {
	IndexRelays       []string      `json:"index_relays"`
	ConnectionTimeout time.Duration `json:"connection_timeout"`
	ReadTimeout       time.Duration `json:"read_timeout"`
	WriteTimeout      time.Duration `json:"write_timeout"`
	MaxConnections    int           `json:"max_connections"`
	RetryAttempts     int           `json:"retry_attempts"`
	RetryDelay        time.Duration `json:"retry_delay"`
	KeepAlive         bool          `json:"keep_alive"`
	UserAgent         string        `json:"user_agent"`

	// Outbox-pool lifecycle (#56). Zero values fall back to built-in defaults
	// in NewRelayPool / the lease methods, so older callers that build a Config
	// by hand keep working.
	DialConcurrency int           `json:"dial_concurrency"`   // max simultaneous dials
	IdleTTL         time.Duration `json:"idle_ttl"`           // evict a 0-lease conn after this idle span
	BackoffBase     time.Duration `json:"backoff_base"`       // first dial-retry backoff
	BackoffMax      time.Duration `json:"backoff_max"`        // dial-retry backoff ceiling
	RelayListTTL    time.Duration `json:"relay_list_ttl"`     // per-user relay-list directory cache TTL
	RelayListNegTTL time.Duration `json:"relay_list_neg_ttl"` // shorter TTL for "no list published"
	OpenTimeout     time.Duration `json:"open_timeout"`       // per-dial cap for on-demand outbox dials (< ConnectionTimeout)

	// Logger, when non-nil, replaces the process-wide client-library logger at
	// NewClient time (see [SetLogger]). Not serialized; defaults to grain's
	// client-core logging so behaviour is unchanged when unset.
	Logger Logger `json:"-"`

	// RelayListStore, when non-nil, backs the relay directory's per-user
	// resolutions with a custom store (see [RelayListStore]) — e.g. a database
	// for persistence across restarts. Not serialized; defaults to in-memory.
	RelayListStore RelayListStore `json:"-"`
}

Config holds client-specific configuration

Example (RelayListStore)

Backing the relay directory with a custom store (e.g. a database) instead of the default in-memory cache.

package main

import (
	"github.com/0ceanslim/grain/client/core"
)

// exampleStore is a minimal RelayListStore — a pubkey -> *UserRelays map. The
// directory owns the TTL and single-flight logic, so a store only needs to be a
// correct key-value map; a real one would persist to a database.
type exampleStore struct{ m map[string]*core.UserRelays }

func (s exampleStore) Get(pk string) (*core.UserRelays, bool) { ur, ok := s.m[pk]; return ur, ok }
func (s exampleStore) Set(pk string, ur *core.UserRelays)     { s.m[pk] = ur }
func (s exampleStore) Delete(pk string)                       { delete(s.m, pk) }
func (s exampleStore) Range(fn func(string, *core.UserRelays) bool) {
	for k, v := range s.m {
		if !fn(k, v) {
			return
		}
	}
}

func main() {
	cfg := core.DefaultConfig()
	cfg.RelayListStore = exampleStore{m: map[string]*core.UserRelays{}}
	_ = core.NewClient(cfg)
}

func ConfigFromServerConfig added in v0.4.11

func ConfigFromServerConfig(serverCfg *cfgType.ServerConfig) *Config

ConfigFromServerConfig creates a client config from server configuration

func DefaultConfig

func DefaultConfig() *Config

DefaultConfig returns a sensible default configuration. The IndexRelays seed list mirrors the indexer-relay role described in #56: relays that host metadata and relay lists for everyone, used to resolve NIP-65 / DM-relay lists for arbitrary users.

func (*Config) Validate

func (c *Config) Validate() error

Validate checks if the configuration is valid

type ConnectionStatus

type ConnectionStatus int

ConnectionStatus represents the state of a relay connection

const (
	StatusDisconnected ConnectionStatus = iota
	StatusConnecting
	StatusConnected
	StatusError
)

type EncryptedContent

type EncryptedContent struct {
	Blocked   string `json:"blocked,omitempty"`
	Search    string `json:"search,omitempty"`
	Favorites string `json:"favorites,omitempty"`
	Private   string `json:"private,omitempty"`
}

EncryptedContent holds the raw (still-encrypted) `.content` of each NIP-51/37 list that has private entries, keyed by the same names as EncryptedFlags. Empty strings when a list has no private content.

type EncryptedFlags

type EncryptedFlags struct {
	Blocked   bool `json:"blocked"`
	Search    bool `json:"search"`
	Favorites bool `json:"favorites"`
	Private   bool `json:"private"` // 10013 — typically always encrypted
}

EncryptedFlags reports which NIP-51/37 lists have private (encrypted) entries.

type Encrypter

type Encrypter interface {
	// NIP44Encrypt encrypts plaintext to peerPubKey, returning a base64 NIP-44
	// payload (v2, the deployed standard).
	NIP44Encrypt(peerPubKey, plaintext string) (string, error)
	// NIP44Decrypt decrypts a base64 NIP-44 payload from peerPubKey, accepting
	// any supported version.
	NIP44Decrypt(peerPubKey, payload string) (string, error)
}

Encrypter is an optional capability a Signer may also implement: NIP-44 payload encryption between the signer's key and a peer pubkey, used for NIP-51 private list content, NIP-17 DMs, and similar. It mirrors the nip44Encrypt/nip44Decrypt methods a NIP-07/NIP-46 browser signer exposes, so the same calling code works whether the key lives in the browser or in a downstream Go consumer's EventSigner.

peerPubKey is 64-char lowercase hex (x-only). For self-encryption (NIP-51 lists are encrypted to the author themselves) pass the signer's own pubkey.

type EncrypterV3

type EncrypterV3 interface {
	NIP44V3Encrypt(peerPubKey string, kind uint32, scope, plaintext []byte) (string, error)
	NIP44V3Decrypt(peerPubKey string, expectedKind uint32, expectedScope []byte, payload string) ([]byte, error)
}

EncrypterV3 is the optional NIP-44 v3 capability: encryption that additionally binds an event kind and a scope string (cross-context replay protection + signer access control). v3 is a draft proposal with few deployed peers, so prefer Encrypter (v2) for interop; this exists so downstream consumers can opt in. kind is a uint32; scope is a UTF-8 byte string (may be empty).

type EventBuilder

type EventBuilder struct {
	// contains filtered or unexported fields
}

EventBuilder provides a fluent interface for building Nostr events

func NewContactList

func NewContactList() *EventBuilder

NewContactList creates a builder for a contact list (kind 3)

func NewDeletion

func NewDeletion(eventIDs []string, reason string) *EventBuilder

NewDeletion creates a builder for a deletion event (kind 5)

func NewEventBuilder

func NewEventBuilder(kind int) *EventBuilder

NewEventBuilder creates a new event builder with the specified kind

func NewProfile

func NewProfile() *EventBuilder

NewProfile creates a builder for a profile event (kind 0)

func NewReaction

func NewReaction(eventID string, content string) *EventBuilder

NewReaction creates a builder for a reaction (kind 7)

func NewRelayList

func NewRelayList() *EventBuilder

NewRelayList creates a builder for a relay list (kind 10002)

func NewRepost

func NewRepost(eventID string, relayHint string) *EventBuilder

NewRepost creates a builder for a repost (kind 6)

func NewTextNote

func NewTextNote(content string) *EventBuilder

NewTextNote creates a builder for a text note (kind 1)

func (*EventBuilder) ATag

func (eb *EventBuilder) ATag(kind int, pubkey string, dTag string, relayHint ...string) *EventBuilder

ATag adds an 'a' tag (address reference) to the event

func (*EventBuilder) Build

func (eb *EventBuilder) Build() *nostr.Event

Build constructs the final Event struct (without signing)

func (*EventBuilder) Content

func (eb *EventBuilder) Content(content string) *EventBuilder

Content sets the content of the event

func (*EventBuilder) CreatedAt

func (eb *EventBuilder) CreatedAt(t time.Time) *EventBuilder

CreatedAt sets the created_at timestamp for the event

func (*EventBuilder) DTag

func (eb *EventBuilder) DTag(identifier string) *EventBuilder

DTag adds a 'd' tag (identifier) to the event

func (*EventBuilder) ETag

func (eb *EventBuilder) ETag(eventID string, relayHint, marker string) *EventBuilder

ETag adds an 'e' tag (event reference) to the event

func (*EventBuilder) PTag

func (eb *EventBuilder) PTag(pubkey string, relayHint ...string) *EventBuilder

PTag adds a 'p' tag (pubkey reference) to the event

func (*EventBuilder) RTag

func (eb *EventBuilder) RTag(relayURL string, marker string) *EventBuilder

RTag adds an 'r' tag (relay reference) to the event

func (*EventBuilder) TTag

func (eb *EventBuilder) TTag(hashtag string) *EventBuilder

TTag adds a 't' tag (hashtag) to the event

func (*EventBuilder) Tag

func (eb *EventBuilder) Tag(name string, values ...string) *EventBuilder

Tag adds a generic tag to the event

type EventSigner

type EventSigner struct {
	// contains filtered or unexported fields
}

EventSigner handles event signing with private keys

func NewEventSigner

func NewEventSigner(privateKeyHex string) (*EventSigner, error)

NewEventSigner creates a new event signer from a hex private key

func NewEventSignerFromRandom

func NewEventSignerFromRandom() (*EventSigner, error)

NewEventSignerFromRandom creates a new event signer with a random private key

func (*EventSigner) GetPrivateKeyHex

func (es *EventSigner) GetPrivateKeyHex() string

GetPrivateKeyHex returns the private key in hex format (use carefully!)

func (*EventSigner) GetPublicKey

func (es *EventSigner) GetPublicKey() string

GetPublicKey returns the public key in hex format

func (*EventSigner) NIP44Decrypt

func (es *EventSigner) NIP44Decrypt(peerPubKey, payload string) (string, error)

NIP44Decrypt decrypts a base64 NIP-44 payload from peerPubKey (any supported version).

func (*EventSigner) NIP44Encrypt

func (es *EventSigner) NIP44Encrypt(peerPubKey, plaintext string) (string, error)

NIP44Encrypt encrypts plaintext to peerPubKey using NIP-44 v2. peerPubKey is 64-char hex (x-only); for NIP-51 private lists (encrypted to self) pass the signer's own pubkey.

Example

NIP-44 v2 conversation encryption with the built-in signer (the same key holder decrypts).

package main

import (
	"fmt"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	signer, err := core.NewEventSigner("64-char-hex-private-key")
	if err != nil {
		return
	}
	ciphertext, err := signer.NIP44Encrypt("peer-pubkey-hex", "hello")
	if err != nil {
		return
	}
	plaintext, err := signer.NIP44Decrypt("peer-pubkey-hex", ciphertext)
	if err != nil {
		return
	}
	fmt.Println(plaintext)
}

func (*EventSigner) NIP44V3Decrypt

func (es *EventSigner) NIP44V3Decrypt(peerPubKey string, expectedKind uint32, expectedScope []byte, payload string) ([]byte, error)

NIP44V3Decrypt decrypts a NIP-44 v3 payload from peerPubKey, requiring it to match the expected kind + scope.

func (*EventSigner) NIP44V3Encrypt

func (es *EventSigner) NIP44V3Encrypt(peerPubKey string, kind uint32, scope, plaintext []byte) (string, error)

NIP44V3Encrypt encrypts plaintext to peerPubKey under NIP-44 v3, binding the event kind and scope. v3 is a draft — prefer NIP44Encrypt (v2) for interop.

func (*EventSigner) PublicKey

func (es *EventSigner) PublicKey() string

PublicKey returns the public key in hex format. It satisfies the Signer seam; GetPublicKey is retained as an alias for existing callers.

func (*EventSigner) SignEvent

func (es *EventSigner) SignEvent(event *nostr.Event) error

SignEvent signs an event and sets the ID, PubKey, and Sig fields

type FilterBuilder

type FilterBuilder struct {
	// contains filtered or unexported fields
}

FilterBuilder provides a fluent interface for building Nostr filters

func NewFilterBuilder

func NewFilterBuilder() *FilterBuilder

NewFilterBuilder creates a new filter builder

func (*FilterBuilder) Authors

func (fb *FilterBuilder) Authors(pubkeys ...string) *FilterBuilder

Authors sets the authors filter

func (*FilterBuilder) Build

func (fb *FilterBuilder) Build() nostr.Filter

Build returns the constructed filter

func (*FilterBuilder) IDs

func (fb *FilterBuilder) IDs(ids ...string) *FilterBuilder

IDs sets the event IDs filter

func (*FilterBuilder) Kinds

func (fb *FilterBuilder) Kinds(kinds ...int) *FilterBuilder

Kinds sets the kinds filter

func (*FilterBuilder) Limit

func (fb *FilterBuilder) Limit(limit int) *FilterBuilder

Limit sets the limit filter

func (*FilterBuilder) Since

func (fb *FilterBuilder) Since(timestamp time.Time) *FilterBuilder

Since sets the since timestamp filter

func (*FilterBuilder) Tag

func (fb *FilterBuilder) Tag(name string, values ...string) *FilterBuilder

Tag adds a tag filter

func (*FilterBuilder) Until

func (fb *FilterBuilder) Until(timestamp time.Time) *FilterBuilder

Until sets the until timestamp filter

type KnownRelayStatus

type KnownRelayStatus struct {
	URL       string `json:"url"`
	Connected bool   `json:"connected"`
	Pinned    bool   `json:"pinned"`
	Leased    bool   `json:"leased"`
}

KnownRelayStatus pairs a known relay URL with its live pool status, for the known-relays browser. The status fields are inlined rather than embedding RelayLiveStatus so the OpenAPI generator (swag) can resolve the type — the JSON shape is identical either way.

type Logger

type Logger interface {
	Debug(msg string, args ...any)
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}

Logger is the structured-logging seam for the importable client library. It matches the method set of *slog.Logger, so the standard library logger — and grain's own log.ClientCore() — satisfy it as-is. A consumer that doesn't want to be tied to grain's logging plugs in their own with SetLogger.

type Mailboxes

type Mailboxes struct {
	Read  []string `json:"read"`
	Write []string `json:"write"`
	Both  []string `json:"both"`
}

Mailboxes represents a user's relay preferences from NIP-65

func (Mailboxes) ToStringSlice

func (m Mailboxes) ToStringSlice() []string

ToStringSlice combines Read, Write, and Both into a single []string

type MediaDirectory

type MediaDirectory struct {
	// contains filtered or unexported fields
}

MediaDirectory caches per-user media-server resolutions with a TTL, collapsing concurrent lookups for the same pubkey into a single network fetch. It is structurally identical to RelayDirectory but kept separate: media servers are a distinct concern from relays and resolve from different event kinds.

func (*MediaDirectory) Invalidate

func (d *MediaDirectory) Invalidate(pubkey string)

Invalidate drops a cached entry so the next Lookup re-resolves — call after the user publishes an updated list, so the change shows immediately.

func (*MediaDirectory) Lookup

func (d *MediaDirectory) Lookup(pubkey string) *MediaServers

Lookup returns the user's media servers, resolving and caching on a miss or stale entry. Concurrent lookups for the same pubkey share one resolution.

type MediaServerInfo

type MediaServerInfo struct {
	URL       string `json:"url"`       // normalised base URL
	Kind      string `json:"kind"`      // MediaKindBlossom | MediaKindNIP96
	Name      string `json:"name"`      // display name, e.g. "Happy Tavern"
	Cost      string `json:"cost"`      // "free" | "paid"
	Retention string `json:"retention"` // "permanent" | "ephemeral"
	Mirror    bool   `json:"mirror"`    // accepts BUD-04 /mirror (Blossom only)
	Note      string `json:"note"`      // short blurb shown under the entry
	CTA       string `json:"cta"`       // optional signup / pricing link
}

MediaServerInfo is static metadata about a media server grain knows: what protocol it speaks, what it costs, how long it keeps blobs, and whether it accepts BUD-04 mirror requests. It drives the free/paid + retention chips in the settings UI and decides which servers can be offered as mirror targets.

func LookupMediaServerInfo

func LookupMediaServerInfo(rawURL string) (MediaServerInfo, bool)

LookupMediaServerInfo returns grain's static capability metadata for a server URL if it knows it, matching on the normalised URL. The boolean reports a hit; unknown servers (a user's own additions) simply carry no chips.

func SuggestedMediaServers

func SuggestedMediaServers() []MediaServerInfo

SuggestedMediaServers returns a copy of grain's curated quick-add suggestions (Blossom preferred, NIP-96 legacy fallback).

type MediaServers

type MediaServers struct {
	Blossom   []string  // kind 10063 — Blossom servers, primary first
	NIP96     []string  // kind 10096 — legacy NIP-96 HTTP servers, primary first
	FetchedAt time.Time // when this was resolved
	Negative  bool      // user has published neither list — cached briefly
}

MediaServers holds a user's published media-server lists: Blossom servers (NIP-B7 / BUD-03 kind 10063) and legacy NIP-96 HTTP servers (kind 10096). Both are `["server", <url>]` tag lists in preference order — the first entry is the user's primary, the rest are mirrors / fallbacks. Resolved from the network and cached with a TTL, mirroring the relay directory.

func (*MediaServers) HasAny

func (m *MediaServers) HasAny() bool

HasAny reports whether the user has any media server configured at all. The upload flow uses this to decide between "open the picker" and "prompt the user to set some up".

type MessageRouter

type MessageRouter struct {
	// contains filtered or unexported fields
}

MessageRouter handles routing messages to subscriptions and OK responses to in-flight publishes.

func NewMessageRouter

func NewMessageRouter() *MessageRouter

NewMessageRouter creates a new message router

func (*MessageRouter) AuthChallenge

func (mr *MessageRouter) AuthChallenge(relayURL string) string

AuthChallenge returns a relay's current challenge ("" if none pending).

func (*MessageRouter) AuthStates

func (mr *MessageRouter) AuthStates() []AuthState

AuthStates returns a snapshot of every relay that has challenged us.

func (*MessageRouter) MarkAuthed

func (mr *MessageRouter) MarkAuthed(relayURL string)

MarkAuthed records that we've answered a relay's challenge this session.

func (*MessageRouter) RegisterOKWaiter

func (mr *MessageRouter) RegisterOKWaiter(eventID string, buf int) chan OKResult

RegisterOKWaiter starts collecting OK responses for an event being published. The returned channel is buffered for up to buf relays; UnregisterOKWaiter must be called when done.

func (*MessageRouter) RegisterSubscription

func (mr *MessageRouter) RegisterSubscription(subID string, sub *Subscription)

RegisterSubscription registers a subscription for message routing

func (*MessageRouter) RemoveAuth

func (mr *MessageRouter) RemoveAuth(relayURL string)

RemoveAuth forgets a relay's AUTH state (the user revoked trust).

func (*MessageRouter) RouteAuth

func (mr *MessageRouter) RouteAuth(relayURL, challenge string)

RouteAuth records a relay's AUTH challenge. A new challenge clears the authed flag for that relay so the manager prompts a fresh answer.

func (*MessageRouter) RouteMessage

func (mr *MessageRouter) RouteMessage(subID string, messageType string, data interface{}, relayURL string)

RouteMessage routes a message to the appropriate subscription

func (*MessageRouter) RouteOK

func (mr *MessageRouter) RouteOK(eventID string, ok OKResult)

RouteOK delivers a relay's OK response to the publisher waiting on it, if any.

func (*MessageRouter) UnregisterOKWaiter

func (mr *MessageRouter) UnregisterOKWaiter(eventID string)

UnregisterOKWaiter stops collecting OK responses for an event.

func (*MessageRouter) UnregisterSubscription

func (mr *MessageRouter) UnregisterSubscription(subID string)

UnregisterSubscription removes a subscription from message routing

type OKResult

type OKResult struct {
	Relay    string `json:"relay"`
	Accepted bool   `json:"accepted"`
	Reason   string `json:"reason,omitempty"`
}

OKResult is a relay's NIP-20 response to a published event: whether it was accepted and any human-readable reason.

type PoolStats

type PoolStats struct {
	Known     int `json:"known"`     // relays the client is aware of (defaults + resolved lists + connections)
	Total     int `json:"total"`     // relays tracked in the pool (have a connection slot)
	Connected int `json:"connected"` // currently connected
	Pinned    int `json:"pinned"`    // index/seed relays kept alive
	Leased    int `json:"leased"`    // connections with at least one active lease
}

PoolStats is a snapshot of the relay pool's connection counts, for status / observability (e.g. an "x / y relays connected" dashboard indicator).

type RelayConfig added in v0.4.12

type RelayConfig struct {
	URL   string `json:"url"`
	Read  bool   `json:"read"`
	Write bool   `json:"write"`
}

RelayConfig represents relay configuration with permissions

type RelayConnection

type RelayConnection struct {
	URL           string
	Conn          *websocket.Conn
	Status        ConnectionStatus
	LastPing      time.Time
	Subscriptions map[string]bool
	// contains filtered or unexported fields
}

RelayConnection represents a single relay connection

type RelayDirectory

type RelayDirectory struct {
	// contains filtered or unexported fields
}

RelayDirectory caches per-user relay-role resolutions with a TTL, collapsing concurrent lookups for the same pubkey into a single network fetch.

func (*RelayDirectory) Cached

func (d *RelayDirectory) Cached(pubkey string) (*UserRelays, bool)

Cached returns a user's resolved relays only if a fresh entry is already in the cache, WITHOUT triggering a network resolve. For latency-sensitive paths (e.g. rendering a profile) that must not block on resolution.

func (*RelayDirectory) Invalidate

func (d *RelayDirectory) Invalidate(pubkey string)

Invalidate drops a cached entry so the next Lookup re-resolves — e.g. after observing a newer relay-list event for the user.

func (*RelayDirectory) KnownRelays

func (d *RelayDirectory) KnownRelays() []string

KnownRelays returns the distinct relay URLs across every resolved entry — the union of all users' outbox / inbox / DM relays the directory has seen. This is the bulk of the client's "known relays" set: relays we're aware of from indexer-seeded mailbox lists, whether or not we're connected to them.

func (*RelayDirectory) Lookup

func (d *RelayDirectory) Lookup(pubkey string) *UserRelays

Lookup returns the user's relay roles, resolving and caching on a miss or stale entry. Concurrent lookups for the same pubkey share one resolution.

func (*RelayDirectory) Store

func (d *RelayDirectory) Store(pubkey string, ur *UserRelays)

Store inserts or merges a resolved relay set for a pubkey directly, without a network resolve — used by bulk seeding. A NIP-65 (10002) and a NIP-17 (10050) event for the same author arrive separately, so non-empty fields merge rather than clobber.

type RelayInfo

type RelayInfo struct {
	Name          string       `json:"name,omitempty"`
	Description   string       `json:"description,omitempty"`
	PubKey        string       `json:"pubkey,omitempty"`
	Software      string       `json:"software,omitempty"`
	Version       string       `json:"version,omitempty"`
	SupportedNIPs []int        `json:"supported_nips,omitempty"`
	Icon          string       `json:"icon,omitempty"`
	Limitation    *RelayLimits `json:"limitation,omitempty"`
}

RelayInfo is the subset of a relay's NIP-11 document the relay manager shows.

type RelayLimits

type RelayLimits struct {
	AuthRequired     bool `json:"auth_required,omitempty"`
	PaymentRequired  bool `json:"payment_required,omitempty"`
	RestrictedWrites bool `json:"restricted_writes,omitempty"`
	MaxMessageLength int  `json:"max_message_length,omitempty"`
}

RelayLimits is the NIP-11 `limitation` block — the flags that matter to the UI (whether the relay requires AUTH or payment to use).

type RelayListEntry

type RelayListEntry struct {
	URL   string `json:"url"`
	Read  bool   `json:"read"`
	Write bool   `json:"write"`
}

RelayListEntry is one relay in a relay-list event. Read / Write are only meaningful for NIP-65 kind 10002 — an entry that is both (or neither) is written unmarked, meaning "read and write". The other kinds ignore the flags.

func ParseNIP65Entries

func ParseNIP65Entries(event *nostr.Event) []RelayListEntry

ParseNIP65Entries parses a kind-10002 event's `r` tags into relay entries with read/write flags. An unmarked entry (`["r", url]`) is both read and write; `["r", url, "read"]` / `["r", url, "write"]` set one side. URLs are normalised; a relay listed twice has its flags OR-ed together.

type RelayListStore

type RelayListStore interface {
	// Get returns the stored resolution for a pubkey and whether one exists.
	Get(pubkey string) (*UserRelays, bool)
	// Set stores (or replaces) the resolution for a pubkey.
	Set(pubkey string, ur *UserRelays)
	// Delete removes any stored resolution for a pubkey.
	Delete(pubkey string)
	// Range calls fn for each stored entry until fn returns false. Used to build
	// the union "known relays" set; iteration order is unspecified.
	Range(fn func(pubkey string, ur *UserRelays) bool)
}

RelayListStore is the pluggable persistence seam for the relay directory's per-user resolutions (a user's outbox / inbox / DM relays). The default is in-memory; a consumer can plug a shared or persistent store — e.g. a database — so resolutions survive restarts or are shared across instances.

The RelayDirectory owns the TTL and single-flight logic and serializes access, so an implementation only needs to be a correct key-value map of pubkey -> *UserRelays; it does not need its own freshness or de-duplication logic. (Because the directory holds its lock across store calls, a store whose Get/Set hit slow I/O will serialize lookups — a DB-backed store that cares should keep a fast in-memory layer in front.)

type RelayLiveStatus

type RelayLiveStatus struct {
	Connected bool `json:"connected"`
	Pinned    bool `json:"pinned"`
	Leased    bool `json:"leased"`
}

RelayLiveStatus is the pool's live view of one relay for the known-relays browser. The zero value means "known but not currently in the pool".

type RelayPool

type RelayPool struct {
	// contains filtered or unexported fields
}

RelayPool manages multiple relay connections

func NewRelayPool

func NewRelayPool(config *Config) *RelayPool

NewRelayPool creates a new relay pool

func (*RelayPool) Acquire

func (rp *RelayPool) Acquire(url string) (*RelayConnection, error)

Acquire returns a connected RelayConnection for url, dialing on demand, and takes a lease on it. Every successful Acquire must be balanced by a Release so the connection can eventually be idle-evicted. Concurrent Acquires for the same url share a single dial (single-flight) and the resulting connection.

func (*RelayPool) AuthChallenge

func (rp *RelayPool) AuthChallenge(url string) string

AuthChallenge returns a relay's pending challenge ("" if none).

func (*RelayPool) AuthRequests

func (rp *RelayPool) AuthRequests() []AuthState

AuthRequests returns the relays that have sent an AUTH challenge this session.

func (*RelayPool) BroadcastMessage

func (rp *RelayPool) BroadcastMessage(message interface{}, urls []string) error

BroadcastMessage sends a message to multiple relays

func (*RelayPool) Close

func (rp *RelayPool) Close() error

Close shuts down all relay connections

func (*RelayPool) CloseConnection

func (rp *RelayPool) CloseConnection(url string) error

CloseConnection closes a specific relay connection

func (*RelayPool) Connect

func (rp *RelayPool) Connect(url string) error

Connect establishes a connection to a relay

func (*RelayPool) EnsureConnectedForSend

func (rp *RelayPool) EnsureConnectedForSend(url string, timeout time.Duration) error

EnsureConnectedForSend makes sure url is connected so the publish path can send to it, redialing a dropped target relay bounded by timeout. It ignores dial backoff because a publish is an explicit user action (unlike a background outbox dial that should fail fast), and it does not retain the lease — the caller only needs the pooled connection for the immediate send, and the sweeper evicts it later if it stays idle. Returns an error if it can't connect in time, which the broadcast surfaces as a per-relay timeout.

func (*RelayPool) GetConnectedRelays

func (rp *RelayPool) GetConnectedRelays() []string

GetConnectedRelays returns a list of connected relay URLs

func (*RelayPool) GetConnection

func (rp *RelayPool) GetConnection(url string) (*RelayConnection, error)

GetConnection returns a specific relay connection

func (*RelayPool) Pin

func (rp *RelayPool) Pin(urls ...string)

Pin marks urls so the idle sweeper never evicts them — used for the index/ seed relays that must stay connected to resolve relay lists for anyone. It does not dial; Acquire still establishes the connection on demand.

func (*RelayPool) RegisterSubscription

func (rp *RelayPool) RegisterSubscription(subID string, sub *Subscription)

RegisterSubscription registers a subscription for message routing

func (*RelayPool) Release

func (rp *RelayPool) Release(url string)

Release drops one lease on url's connection. When the last lease is released the connection is marked idle (eligible for the sweeper) but not closed, so a later Acquire can reuse it. Releasing an unknown url, or one already at zero leases, is a safe no-op — this guards against the lease-counter underflow class of bug.

func (*RelayPool) RemoveAuth

func (rp *RelayPool) RemoveAuth(url string)

RemoveAuth forgets a relay's AUTH state.

func (*RelayPool) SendAuth

func (rp *RelayPool) SendAuth(url string, signedEvent *nostr.Event) error

SendAuth sends a signed NIP-42 auth event (kind 22242) to a relay and marks it authed for the session. The connection that issued the challenge must still be open (the challenge is bound to it).

func (*RelayPool) SendMessage

func (rp *RelayPool) SendMessage(url string, message interface{}) error

SendMessage sends a message to a specific relay

func (*RelayPool) StartEvictionSweeper

func (rp *RelayPool) StartEvictionSweeper(ctx context.Context, interval time.Duration)

StartEvictionSweeper runs evictIdle on an interval until ctx is cancelled, bounded to the caller's lifetime like the relay health check (#93).

func (*RelayPool) Stats

func (rp *RelayPool) Stats() PoolStats

Stats returns a snapshot of the pool's connection counts (without Known, which the Client fills in since it spans the directory too).

func (*RelayPool) StatusOf

func (rp *RelayPool) StatusOf(url string) RelayLiveStatus

StatusOf returns the pool's live status for url.

func (*RelayPool) UnregisterSubscription

func (rp *RelayPool) UnregisterSubscription(subID string)

UnregisterSubscription removes a subscription from message routing

type Role

type Role uint16

Role identifies the function a relay serves for a user under the outbox model. Roles fall into three classes (see docs/design/outbox-relay-pool.md §3):

A relay may hold several roles at once (a relay can be both your outbox and your inbox), so Role is a bitmask: combine with | and test with Role.Has.

const (
	RoleOutbox      Role = 1 << iota // NIP-65 write (10002): you publish here; others fetch your notes here
	RoleInbox                        // NIP-65 read (10002): replies, mentions, and zaps reach you here
	RoleDMInbox                      // NIP-17 (10050): encrypted direct messages are delivered here
	RoleSearch                       // NIP-51 (10007): relays queried for NIP-50 search
	RoleBlocked                      // NIP-51 (10006): never dialed
	RoleFavorite                     // NIP-51 (10012): surfaced in UI; no routing effect
	RolePrivateHome                  // NIP-37 (10013, NIP-44 encrypted): relays for private content
	RoleIndexer                      // local config: seeds for resolving anyone's metadata / relay lists
	RoleBroadcast                    // local config: fan-out relays ("event blasters"); writes mirror here
	RoleProxy                        // local config: aggregator; when set, short-circuits outbox routing
	RoleLocal                        // local config: same-device / LAN relays, preferred for latency
	RoleTrusted                      // local config: relays the client will sign NIP-42 AUTH challenges for
)

func RoleForListKind

func RoleForListKind(kind int) (role Role, ok bool)

RoleForListKind maps a replaceable relay-list event kind to the role(s) its `relay`/`r` entries carry — e.g. 10002 → RoleOutbox|RoleInbox (the read/write split is per-entry markers), 10050 → RoleDMInbox. ok is false for kinds that are not relay lists.

func (Role) Has

func (r Role) Has(x Role) bool

Has reports whether r includes every bit of x (and x is non-zero). For a single-bit role this is a plain membership test; for a combination it checks that all of x's roles are present.

func (Role) String

func (r Role) String() string

String renders the set roles as a "|"-joined list (e.g. "outbox|inbox"), or "none" when no bits are set. Intended for logs and inspection, not parsing.

type RouteOp

type RouteOp int

RouteOp names a read-routing intent so a caller can inspect which relays an operation would use, via Client.Route, without performing it.

const (
	OpFetchNotes    RouteOp = iota // a user's authored events → their outbox (NIP-65 write)
	OpFetchMetadata                // profile / relay lists → indexers (+ cached outbox)
)

type SessionRelays

type SessionRelays struct {
	// contains filtered or unexported fields
}

SessionRelays is a user's editable, role-tagged relay configuration for one session — the set a downstream app reads and edits to inspect or steer routing (design §4.3 / §11, "expose the pool + role assignments so callers can read/edit them"). Each relay URL carries a Role bitmask. Safe for concurrent use.

It is a plain, in-memory container: a consumer seeds it from a user's resolved event-derived roles and layers local overrides on top. Wiring edits back into live routing is incremental — today the one override that affects routing is the fixed-relay pin, exposed via UserContext.PinFixedRelays.

Example

Inspecting and editing the role-tagged session relay configuration.

package main

import (
	"fmt"

	"github.com/0ceanslim/grain/client/core"
)

func main() {
	client := core.NewClient(core.DefaultConfig())
	uc := client.NewUserContext("pubkey-hex")

	uc.Relays().Set("wss://relay.example.com", core.RoleOutbox|core.RoleInbox)
	uc.Relays().Add("wss://dm.example.com", core.RoleDMInbox)

	for _, url := range uc.Relays().ByRole(core.RoleOutbox) {
		fmt.Println("outbox:", url)
	}
}

func (*SessionRelays) Add

func (s *SessionRelays) Add(url string, roles Role)

Add OR-s roles onto url, keeping any it already holds.

func (*SessionRelays) All

func (s *SessionRelays) All() map[string]Role

All returns a snapshot copy of the full url→roles map.

func (*SessionRelays) ByRole

func (s *SessionRelays) ByRole(role Role) []string

ByRole returns the relay URLs carrying the given role, sorted for stability.

func (*SessionRelays) Get

func (s *SessionRelays) Get(url string) Role

Get returns the roles assigned to url (the zero Role if absent).

func (*SessionRelays) Remove

func (s *SessionRelays) Remove(url string)

Remove drops url and all of its roles.

func (*SessionRelays) Set

func (s *SessionRelays) Set(url string, roles Role)

Set replaces the roles assigned to url. Passing the zero Role removes it.

type Signer

type Signer interface {
	// PublicKey returns the signer's public key as 64-char lowercase hex.
	PublicKey() string
	// SignEvent fills the event's PubKey, ID, and Sig fields in place: PubKey is
	// set to PublicKey(), and Sig is a Schnorr signature over the NIP-01 id
	// computed from the serialized event.
	SignEvent(event *nostr.Event) error
}

Signer produces signatures for Nostr events on behalf of one pubkey. A library consumer supplies a Signer to publish — a local key via EventSigner, or their own implementation (NIP-46 remote signer, hardware, HSM). Read-only callers supply none.

grain's own web client signs in the browser with the user's NIP-07 / NIP-46 signer, so grain's server side carries no Signer; this seam exists for downstream Go consumers building a client on the library.

type StreamOption

type StreamOption func(*streamConfig)

StreamOption configures Client.StreamEvents / Client.QueryEvents.

func WithLimit

func WithLimit(n int) StreamOption

WithLimit closes the stream after n events have been delivered (0 = no limit).

func WithLive

func WithLive() StreamOption

WithLive keeps the stream open past every relay's end-of-stored-events, so it keeps delivering newly published events until the context or timeout ends it. Default is a bounded fetch that closes once all stored events are in.

func WithTimeout

func WithTimeout(d time.Duration) StreamOption

WithTimeout caps how long the stream runs before closing (default 10s). The stream also ends when every relay reports end-of-stored-events (unless WithLive), the limit is reached, or the context is cancelled.

type Subscription

type Subscription struct {
	ID      string
	Filters []nostr.Filter
	Relays  []string
	Events  chan *nostr.Event
	Errors  chan error
	Done    chan struct{}
	EOSE    chan string // NEW: Channel for EOSE messages with relay URL
	// contains filtered or unexported fields
}

Subscription manages a Nostr subscription across multiple relays

func NewSubscription

func NewSubscription(id string, filters []nostr.Filter, relays []string, client *Client) *Subscription

NewSubscription creates a new subscription instance

func (*Subscription) AddRelay

func (s *Subscription) AddRelay(url string) error

AddRelay adds a new relay to an active subscription

func (*Subscription) Close

func (s *Subscription) Close() error

Update Close to close the EOSE channel too:

func (*Subscription) GetFilters

func (s *Subscription) GetFilters() []nostr.Filter

GetFilters returns a copy of the subscription filters

func (*Subscription) GetRelayCount

func (s *Subscription) GetRelayCount() int

GetRelayCount returns the number of relays in this subscription

func (*Subscription) IsActive

func (s *Subscription) IsActive() bool

IsActive returns whether the subscription is currently active

func (*Subscription) RemoveRelay

func (s *Subscription) RemoveRelay(url string) error

RemoveRelay removes a relay from the subscription

func (*Subscription) Start

func (s *Subscription) Start() error

Start begins the subscription on all specified relays

type UserContext

type UserContext struct {
	// contains filtered or unexported fields
}

UserContext is the per-session handle a downstream app uses to act as one user on the outbox engine: their editable relay config (SessionRelays), an optional Signer for publishing, and the shared Client that owns the connection pool. Construct it with Client.NewUserContext.

grain's own web layer (client/api, client/session, …) is a reference consumer of this surface; a CLI or bot uses it directly. Read-only callers omit the signer.

Pre-1.0 note: the network methods don't take a context.Context yet (it lands in a follow-up slice), and PublishDM is deferred until NIP-44 encryption is available. See docs/design/outbox-relay-pool.md §11.

func (*UserContext) ClearFixedRelays

func (uc *UserContext) ClearFixedRelays()

ClearFixedRelays disables the override and restores outbox routing.

func (*UserContext) Client

func (uc *UserContext) Client() *Client

Client returns the shared client that owns the connection pool.

func (*UserContext) FetchNotes

func (uc *UserContext) FetchNotes(ctx context.Context, author string, opts ...StreamOption) []*nostr.Event

FetchNotes collects author's text notes (kind 1) from their outbox relays. Best-effort: per-relay failures are logged, so an empty result means "none found" rather than a hard error. For incremental delivery use [StreamNotes].

func (*UserContext) FixedRelaysEnabled

func (uc *UserContext) FixedRelaysEnabled() bool

FixedRelaysEnabled reports whether the fixed-relay override is active.

func (*UserContext) PinFixedRelays

func (uc *UserContext) PinFixedRelays(readRelays, writeRelays []string)

PinFixedRelays enables the fixed-relay override — reads come from readRelays, writes go to writeRelays — which DISABLES the outbox model. Discouraged; for users who explicitly want a fixed-/single-relay client. The override is client-wide (see Client.SetFixedRelays), not scoped to this context.

func (*UserContext) PublicKey

func (uc *UserContext) PublicKey() string

PublicKey returns the context user's pubkey (hex).

func (*UserContext) Publish

func (uc *UserContext) Publish(ctx context.Context, event *nostr.Event) ([]BroadcastResult, error)

Publish routes an already-signed event under the outbox model (the author's outbox ∪ each p-tagged recipient's inbox; metadata also to the indexers) and broadcasts it, returning the per-relay results.

func (*UserContext) Relays

func (uc *UserContext) Relays() *SessionRelays

Relays returns the user's editable, role-tagged session relay config.

func (*UserContext) Reply

func (uc *UserContext) Reply(ctx context.Context, parent *nostr.Event, content string) (*nostr.Event, []BroadcastResult, error)

Reply builds a NIP-10 kind-1 reply to parent, signs it as this user, and publishes it under the outbox model so it reaches the parent author's inbox as well as the user's own audience. It returns the signed reply and the per-relay broadcast results. Requires a signer.

Example

Publishing: attach a local-key signer and post an outbox-routed reply.

package main

import (
	"context"
	"fmt"

	"github.com/0ceanslim/grain/client/core"
	nostr "github.com/0ceanslim/grain/server/types"
)

func main() {
	client := core.NewClient(core.DefaultConfig())

	signer, err := core.NewEventSigner("64-char-hex-private-key")
	if err != nil {
		return
	}
	uc := client.NewUserContext(signer.PublicKey(), core.WithSigner(signer))

	parent := &nostr.Event{ID: "parent-id", PubKey: "parent-author", Kind: 1}
	reply, results, err := uc.Reply(context.Background(), parent, "well said!")
	if err != nil {
		return
	}
	fmt.Printf("published %s to %d relays\n", reply.ID, len(results))
}

func (*UserContext) Sign

func (uc *UserContext) Sign(event *nostr.Event) error

Sign signs event as this user. It requires a signer and errors if the signer's public key does not match the context user, so a caller can't accidentally sign as someone else.

func (*UserContext) SignAndPublish

func (uc *UserContext) SignAndPublish(ctx context.Context, event *nostr.Event) ([]BroadcastResult, error)

SignAndPublish signs event as this user and then publishes it.

func (*UserContext) Signer

func (uc *UserContext) Signer() Signer

Signer returns the attached signer, or nil for a read-only context.

func (*UserContext) StreamNotes

func (uc *UserContext) StreamNotes(ctx context.Context, author string, opts ...StreamOption) <-chan *nostr.Event

StreamNotes streams author's text notes (kind 1) from their outbox relays as each relay answers — the lazy-hydration path for a profile feed. Pass options like WithLimit to bound it. Routing honours the fixed-relay override.

type UserOption

type UserOption func(*UserContext)

UserOption configures a UserContext at construction.

func WithSigner

func WithSigner(s Signer) UserOption

WithSigner attaches a signer so the context can publish. Without one the context is read-only and UserContext.Sign / UserContext.SignAndPublish return an error.

type UserRelayLists

type UserRelayLists struct {
	NIP65     []RelayListEntry `json:"nip65"`     // 10002
	DM        []string         `json:"dm"`        // 10050
	Blocked   []string         `json:"blocked"`   // 10006
	Search    []string         `json:"search"`    // 10007
	Favorites []string         `json:"favorites"` // 10012
	Private   []string         `json:"private"`   // 10013 (NIP-37) — usually empty; entries live in the encrypted content
	// Encrypted flags NIP-51/37 lists whose event carried NIP-44/NIP-04 private
	// content; only the public entries are listed in the fields above.
	Encrypted EncryptedFlags `json:"encrypted"`
	// EncryptedContent carries the raw private content blob per list so the
	// browser — which holds the user's signer — can decrypt it on demand (#100).
	// grain itself never decrypts; it just passes the opaque blob through.
	EncryptedContent EncryptedContent `json:"encrypted_content"`
}

UserRelayLists is a user's resolved relay lists across the kinds the relay manager edits. NIP65 carries read/write flags; the rest are plain URL lists.

type UserRelays

type UserRelays struct {
	Outbox    []string  // NIP-65 write relays — publish / fetch their notes
	Inbox     []string  // NIP-65 read relays — deliver replies / zaps here
	DMInbox   []string  // NIP-17 kind 10050 DM relays
	FetchedAt time.Time // when this was resolved
	Negative  bool      // user has no published lists — cached briefly
}

UserRelays holds a target user's per-target event-derived relay roles, resolved from their published NIP-65 (kind 10002) and NIP-17 (kind 10050) events. These are the only roles the directory resolves for arbitrary users; the richer self-only roles (search / blocked / favorite / private) load into the logged-in user's own session config, not here.

func (*UserRelays) ForRole

func (ur *UserRelays) ForRole(role Role) []string

ForRole returns the resolved per-target relays carrying the given role: RoleOutbox, RoleInbox, or RoleDMInbox. Any other role returns nil — the rest are self-only or locally configured, not part of a per-target resolution.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL