33 KiB
| name | description |
|---|---|
| welshman-util | Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, keys, NIPs (13/42/86/98), relay urls, lightning, wallets, slash commands, or any core nostr data structure. Also covers relay selection — the RelaySelection routing DSL, Resolver, RelayScenario and fallback policies all live here (there is no @welshman/router package). (Profiles, lists, handlers, and rooms now live in @welshman/domain as Reader/Writer classes.) |
welshman/util — Core Nostr Utilities
@welshman/util is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, zaps, relays, and Lightning wallet integration. Higher level welshman packages (@welshman/net, @welshman/app, @welshman/store, etc.) depend on the types and utilities defined here.
Installation
npm install @welshman/util
# or
pnpm add @welshman/util
# or
yarn add @welshman/util
Key Exports
Event Types
| Type | Description |
|---|---|
EventContent |
{ tags, content } — base content structure |
EventTemplate |
EventContent + kind |
StampedEvent |
EventTemplate + created_at |
OwnedEvent |
StampedEvent + pubkey |
HashedEvent |
OwnedEvent + id |
SignedEvent |
HashedEvent + sig |
TrustedEvent |
HashedEvent + optional sig — most common in-app type |
Event Utilities
| Export | Description |
|---|---|
verifiedSymbol |
Symbol (re-exported from nostr-tools) used as a key on events; set event[verifiedSymbol] = true to skip signature re-validation |
makeEvent(kind, opts?) |
Create a StampedEvent with optional content, tags, created_at |
verifyEvent(event) |
Verify event signature; returns false for unsigned events (no sig field) even if verifiedSymbol is set, because isSignedEvent is checked first; returns true immediately for signed events where event[verifiedSymbol] is already set |
getIdentifier(event) |
Get d tag value |
getIdOrAddress(event) |
Returns address string for replaceable events, id otherwise |
getIdAndAddress(event) |
Returns array with both id and address (if applicable) |
deduplicateEvents(events) |
Deduplicate by id or address |
compareEventsAsc(a, b) / compareEventsDesc(a, b) |
Canonical event order: created_at, then id to break a same-second tie |
sortEventsAsc(events) / sortEventsDesc(events) |
Sort an iterable of events by that order |
isEphemeral(event) |
True for ephemeral kinds (20000–29999) |
isReplaceable(event) |
True for plain or parameterized replaceable |
isPlainReplaceable(event) |
True for kinds 10000–19999 and metadata/contacts |
isParameterizedReplaceable(event) |
True for kinds 30000–39999 |
sortEventsAsc(events) / sortEventsDesc(events) |
Sort by created_at |
asEventTemplate, asStampedEvent, asOwnedEvent, asHashedEvent, asSignedEvent |
Narrow an event down to the fields of a given level |
NIP-10 and NIP-22 threading lives on the Note and Comment classes in @welshman/domain, not here. The free helpers getAncestors, getParentIdOrAddr, isChildOf, getReplyTags and getCommentTags do not exist in this package.
Type Guards
isEventTemplate, isStampedEvent, isOwnedEvent, isHashedEvent, isSignedEvent
Keys & event construction
| Export | Description |
|---|---|
makeSecret() |
Cryptographically secure random hex private key |
getPubkey(secret) |
Derive the hex pubkey from a hex secret |
stamp(event, created_at?) |
EventTemplate → StampedEvent |
own(event, pubkey) |
StampedEvent → OwnedEvent |
hash(event) / getHash(event) |
OwnedEvent → HashedEvent / the id alone |
sign(event, secret) / getSig(event, secret) |
HashedEvent → SignedEvent / the sig alone |
prep(event, pubkey, created_at?) |
Stamp + own + hash in one step — an unsigned rumor |
Proof of work (NIP-13)
| Export | Description |
|---|---|
makePow(event, difficulty) |
Mine a nonce tag until the id has difficulty leading zero bits; returns a ProofOfWork |
getPow(event) |
Leading zero bits on an event's id |
estimateWork(difficulty) / benchmarkDifficulty |
Rough cost estimate for a difficulty target |
Event Kinds (constants)
All constants are exported by name from @welshman/util.
Core / NIP-01
PROFILE = 0 NOTE = 1 FOLLOWS = 3
DELETE = 5 REPOST = 6 REACTION = 7
BADGE_AWARD = 8 MESSAGE = 9 THREAD = 11
SEAL = 13 DIRECT_MESSAGE = 14 DIRECT_MESSAGE_FILE = 15
GENERIC_REPOST = 16 PICTURE_NOTE = 20 VANISH = 62
COMMENT = 1111 GENERIC_REPOST = 16
Channels (NIP-28)
CHANNEL_CREATE = 40 CHANNEL_UPDATE = 41 CHANNEL_MESSAGE = 42
CHANNEL_HIDE_MESSAGE = 43 CHANNEL_MUTE_USER = 44
Wrapped / encrypted (NIP-59)
WRAP = 1059 WRAP_NIP04 = 1060
WRAPPED_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] // convenience array
Media / files
FILE_METADATA = 1063 PICTURE_NOTE = 20 AUDIO = 31337
Polls
POLL = 1068 POLL_RESPONSE = 1018
Marketplace / auction
BID = 1021 BID_CONFIRMATION = 1022
STALL = 30017 PRODUCT = 30018 MARKET_UI = 30019
PRODUCT_SOLD_AS_AUCTION = 30020
CLASSIFIED = 30402 DRAFT_CLASSIFIED = 30403
Git (NIP-34)
GIT_PATCH = 1617 GIT_ISSUE = 1621 GIT_REPLY = 1622
GIT_STATUS_OPEN = 1630 GIT_STATUS_COMPLETE = 1631
GIT_STATUS_CLOSED = 1632 GIT_STATUS_DRAFT = 1633
GIT_REPOSITORY = 30403
Social / community
REMIX = 1808 REPORT = 1984 LABEL = 1985
REVIEW = 1986 HIGHLIGHT = 9802 APPROVAL = 4550
NOSTROCKET_PROBLEM = 1971
COMMUNITY = 34550
BADGE_DEFINITION = 30009 BADGES = 30008
LIVE_EVENT = 30311 LIVE_CHAT_MESSAGE = 1311
Rooms (NIP-29)
ROOM_CREATE = 9007 ROOM_DELETE = 9008 ROOM = 35834
ROOM_JOIN = 9021 ROOM_LEAVE = 9022 ROOM_META = 39000
ROOM_ADMINS = 39001 ROOM_MEMBERS = 39002 ROOM_EDIT_META = 9002
ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001
ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004
ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006
ROOM_CREATE_PERMISSION = 19004
ROOM_UPDATE_PINS = 9010 ROOM_PINS = 39005
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936
RELAY_ROLE = 33534
Pinboards
PINBOARD = 30067 PIN = 39067
Slash commands
COMMAND = 31992
Replaceable lists (kinds 10000–10099)
MUTES = 10000 PINS = 10001 RELAYS = 10002
BOOKMARKS = 10003 COMMUNITIES = 10004 CHANNELS = 10005
BLOCKED_RELAYS = 10006 SEARCH_RELAYS = 10007 ROOMS = 10009
FEEDS = 10014 TOPICS = 10015 EMOJIS = 10030
MESSAGING_RELAYS = 10050 BLOSSOM_SERVERS = 10063
FILE_SERVERS = 10096
Parameterized replaceable lists (kinds 30000–30102)
NAMED_PEOPLE = 30000 NAMED_RELAYS = 30002 NAMED_BOOKMARKS = 30003
NAMED_CURATIONS = 30004 NAMED_TOPICS = 30015
NAMED_WIKI_AUTHORS = 30101 NAMED_WIKI_RELAYS = 30102
NAMED_EMOJIS = 30030 NAMED_ARTIFACTS = 30063
NAMED_COMMUNITIES = 30064
Long-form / wiki / publishing (NIP-23)
LONG_FORM = 30023 LONG_FORM_DRAFT = 30024
WIKI = 30818 APP_DATA = 30078
FEED = 31890
Calendar (NIP-52)
CALENDAR = 31924 EVENT_DATE = 31922 EVENT_TIME = 31923
EVENT_RSVP = 31925
Handlers (NIP-89)
HANDLER_INFORMATION = 31990 HANDLER_RECOMMENDATION = 31989
Status / alerts
STATUS = 30315
ALERT_EMAIL = 32830 ALERT_STATUS = 32831 ALERT_WEB = 32832
ALERT_ANDROID = 32833 ALERT_IOS = 32834
Zaps / wallet / Lightning
ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RECEIPT = 9735
WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195
LIGHTNING_PUB_RPC = 21000
OTS = 1040
Auth
CLIENT_AUTH = 22242 BLOSSOM_AUTH = 24242 HTTP_AUTH = 27235
NOSTR_CONNECT = 24133
Follow packs
FOLLOW_PACK = 39089
Promenade protocol
PROMENADE_REGISTER_ACCOUNT = 16430 PROMENADE_SHARD_SHARE = 26428
PROMENADE_SHARD_ACK = 26429 PROMENADE_CONFIG = 26430
PROMENADE_COMMIT = 26431 PROMENADE_REQUEST = 26432
PROMENADE_RESULT = 26433
Deprecated
DEPRECATED_RELAY_RECOMMENDATION = 2
DEPRECATED_DIRECT_MESSAGE = 4
DEPRECATED_NAMED_GENERIC = 30001
DVM — Data Vending Machines (NIP-90, kinds 5000–7000)
Requests (5xxx) and their paired responses (6xxx):
DVM_REQUEST_TEXT_EXTRACTION = 5000 DVM_RESPONSE_TEXT_EXTRACTION = 6000
DVM_REQUEST_TEXT_SUMMARY = 5001 DVM_RESPONSE_TEXT_SUMMARY = 6001
DVM_REQUEST_TEXT_TRANSLATION = 5002 DVM_RESPONSE_TEXT_TRANSLATION = 6002
DVM_REQUEST_TEXT_GENERATION = 5050 DVM_RESPONSE_TEXT_GENERATION = 6050
DVM_REQUEST_IMAGE_GENERATION = 5100 DVM_RESPONSE_IMAGE_GENERATION = 6100
DVM_REQUEST_VIDEO_CONVERSION = 5200 DVM_RESPONSE_VIDEO_CONVERSION = 6200
DVM_REQUEST_VIDEO_TRANSLATION = 5201 DVM_RESPONSE_VIDEO_TRANSLATION = 6201
DVM_REQUEST_IMAGE_TO_VIDEO_CONVERSION = 5202
DVM_RESPONSE_IMAGE_TO_VIDEO_CONVERSION = 6202
DVM_REQUEST_TEXT_TO_SPEECH = 5250 DVM_RESPONSE_TEXT_TO_SPEECH = 6250
DVM_REQUEST_DISCOVER_CONTENT = 5300 DVM_RESPONSE_DISCOVER_CONTENT = 6300
DVM_REQUEST_DISCOVER_PEOPLE = 5301 DVM_RESPONSE_DISCOVER_PEOPLE = 6301
DVM_REQUEST_SEARCH_CONTENT = 5302 DVM_RESPONSE_SEARCH_CONTENT = 6302
DVM_REQUEST_SEARCH_PEOPLE = 5303 DVM_RESPONSE_SEARCH_PEOPLE = 6303
DVM_REQUEST_COUNT = 5400 DVM_RESPONSE_COUNT = 6400
DVM_REQUEST_MALWARE_SCAN = 5500 DVM_RESPONSE_MALWARE_SCAN = 6500
DVM_REQUEST_OTS = 5900 DVM_RESPONSE_OTS = 6900
DVM_REQUEST_OP_RETURN = 5901 DVM_RESPONSE_OP_RETURN = 6901
DVM_REQUEST_PUBLISH_SCHEDULE = 5905 DVM_RESPONSE_PUBLISH_SCHEDULE = 6905
DVM_FEEDBACK = 7000
Use isDVMKind(kind) to test if a kind falls in the DVM range (5000–7000).
Kind classifiers
isRegularKind(kind) // 1000–9999 and select low kinds
isPlainReplaceableKind(kind) // 0, 3, and 10000–19999
isEphemeralKind(kind) // 20000–29999
isParameterizedReplaceableKind(kind) // 30000–39999
isReplaceableKind(kind) // plain OR parameterized replaceable
isDVMKind(kind) // 5000–7000
Tags
| Export | Description |
|---|---|
tagSpec(keys, matchValue?, normalizeValue?) |
Build a TagSpec — keys is a string or string[]; optional value filter/normalizer |
hexTags(keys) |
Spec matching 32-byte hex values (isHex32) — e/p tags |
addressTags(keys) |
Spec matching replaceable addresses (Address.isAddress) — a tags |
relayTags(keys) |
Spec matching relay urls (isRelayUrl) — r/relay tags |
topicTags(keys) |
Spec that strips a leading # from values — t tags |
kindTags(keys) |
Spec whose values parse to number — k tags |
matchTags(spec, tags) |
All tags matching the spec — spec first, then the tags array |
matchTag(spec, tags) |
First tag matching the spec, or undefined |
tagValues(spec, tags) |
Values (index 1, normalized) of all matching tags; undefined dropped |
tagValue(spec, tags) |
Value of the first matching tag, or undefined |
tagMatcher(spec) / tagValueExtractor(spec) |
The raw (tag)=>boolean / (tag)=>T for a spec |
tagValueMatcher(spec, value) |
Predicate for one value, compared after normalization — matches however the tag spelled it |
The old getTagValue/getPubkeyTagValues/getEventTags/… accessors were removed — use the spec selectors above (e.g. tagValues(hexTags("p"), tags), tagValue(tagSpec("title"), tags)).
Filters
| Export | Description |
|---|---|
matchFilter(filter, event) |
Test if event matches a single filter |
matchFilters(filters, event) |
Test if event matches any filter |
getIdFilters(idsOrAddresses) |
Build filters from mixed ids and addresses |
getReplyFilters(events, filter?) |
Build filters to find replies |
addRepostFilters(filters) |
Add kind 6/16 repost filters |
unionFilters(filters) |
Merge overlapping filters |
intersectFilters(groups) |
Intersect arrays of filter groups |
trimFilter(filter) / trimFilters(filters) |
Limit array fields to 1000 items |
getFilterId(filter) |
Compact string key for a filter |
getFilterGenerality(filter) |
0 = specific, 1 = general |
guessFilterDelta(filters, max?) |
Estimate appropriate time window in seconds |
getFilterResultCardinality(filter) |
Expected result count for id-based filters |
Address
| Export | Description |
|---|---|
Address class |
Handles kind:pubkey:identifier and NIP-19 naddr format |
Address.isAddress(s) |
Validate address string format |
Address.from(s, relays?) |
Parse from kind:pubkey:identifier string |
Address.fromNaddr(naddr) |
Parse from NIP-19 naddr |
Address.fromEvent(event, relays?) |
Create from addressable event |
address.toString() |
Serialize to kind:pubkey:identifier |
address.toNaddr() |
Serialize to NIP-19 naddr |
getAddress(event) |
Convenience: get address string from event |
Relay
| Export | Description |
|---|---|
LOCAL_RELAY_URL |
"local://welshman.relay/" — the conventional url for the in-memory repository |
isRelayUrl(url) |
Validate relay URL |
isShareableRelayUrl(url) |
True if valid relay URL and not a local address |
isOnionUrl(url) |
Tor address check |
isLocalUrl(url) |
Local address check |
isIPAddress(url) |
IP address check |
normalizeRelayUrl(url) |
Normalize to standard wss:// format (passes LOCAL_RELAY_URL through unchanged) |
displayRelayUrl(url) |
Strip protocol and trailing slash |
RelayMode, RelayProfile and displayRelayProfile are gone. Read/write intent is expressed by the NIP-65 RelayList reader/writer in @welshman/domain (readUrls()/writeUrls(), addReadUrl/addWriteUrl), and NIP-11 relay info by the Relay type in @welshman/domain plus app.use(Relays).
Relay Selection (routing DSL)
RelaySelection.ts holds the relay-routing DSL. A relay selection names a source, such as
"the author's outbox" or "the relays this event was seen on", rather than a list of urls. Turning
one into urls needs relay lists, a tracker and a repository, so producing and resolving selections
are separate steps: this package defines and scores them, and @welshman/app's Router plugin
supplies the one ResolveRoute implementation that dereferences them (see the welshman-app
skill). @welshman/domain readers/writers/queries emit selections, and @welshman/feeds asks for
the capability as its FeedRouter interface.
Route + selection types
| Type | Description |
|---|---|
EventRef |
{ id?, pubkey?, kind?, identifier?, relays? } — all optional and additive. A known pubkey routes directly without finding the event; id (or kind+pubkey+identifier) lets the resolver look it up; relays are hints for that lookup and a last-resort fallback |
RelayRoute |
Discriminated union: userInbox / userOutbox / userMessaging, pubkeyInbox / pubkeyOutbox / pubkeyMessaging ({pubkey}), eventInbox / eventOutbox / seen ({ref}), relay ({url}), index, search |
RelaySelection |
{ route: RelayRoute; weight: number } |
DSL constructors (each defaults weight = 1)
| Export | Returns | Description |
|---|---|---|
inbox(pubkey, weight?) |
RelaySelection |
that pubkey's read relays |
outbox(pubkey, weight?) |
RelaySelection |
that pubkey's write relays |
messaging(pubkey, weight?) |
RelaySelection |
that pubkey's NIP-17 messaging relays |
userInbox(weight?) / userOutbox(weight?) / userMessaging(weight?) |
RelaySelection |
the current user's relays |
eventInbox(ref, weight?) / eventOutbox(ref, weight?) |
RelaySelection |
the referenced event's author's relays |
seen(ref, weight?) |
RelaySelection |
relays the event was found on (tracker + ref hints) |
relay(url, weight?) |
RelaySelection |
a literal relay url (formerly relayHint) |
relays(urls, weight?) |
RelaySelection[] |
one selection per url (formerly relayHints) |
inboxes(pubkeys, weight?) |
RelaySelection[] |
uniq(pubkeys).map(inbox) — inbox per referenced pubkey |
indexers(weight?) |
RelaySelection |
profile/relay-list index relays |
searchRelays(weight?) |
RelaySelection |
full-text search relays |
Note relays and inboxes return arrays — spread them with ... into a route list; every
other constructor returns a single RelaySelection.
Resolved selections + fallback policies
| Export | Description |
|---|---|
Selection |
{ weight: number; relays: string[] } — a concrete, resolved weighted relay set |
makeSelection(relays, weight?) |
Build a Selection, filtering isRelayUrl and normalizing each url |
FallbackPolicy |
(count: number, limit: number) => number — how many defaults to add |
addNoFallbacks |
Never add fallback relays (the default) |
addMinimalFallbacks |
Add one fallback only if nothing else was found |
addMaximalFallbacks |
Top up to the limit with fallbacks |
RelayScenario — scores and picks concrete relays from weighted Selections:
new RelayScenario(selections: Selection[], options?: RelayScenarioOptions)
// options: { policy?, limit?, allowLocal?, allowOnion?, allowInsecure?,
// getRelayQuality?, getDefaultRelays? }
scenario.limit(n) // chainable (returns a cloned scenario)
scenario.policy(fn) // chainable
scenario.allowLocal(bool) / allowOnion(bool) / allowInsecure(bool) // chainable
scenario.getUrls() // string[]
scenario.getUrl() // first of getUrls()
getUrls() drops onion, local and plain-ws:// urls unless explicitly allowed, sums each url's
weight across selections, scores as quality * (1 + log(weight)) times a random factor, takes the
best limit (default 3), then adds shuffled getDefaultRelays() urls per the fallback policy. The
log keeps a relay that many selections name from dominating, and the random factor lets lower-ranked
relays get picked occasionally.
Resolver — bundles a single route-resolution function with the scenario options to apply to
everything it produces:
type ResolveRoute = (route: RelayRoute) => MaybeAsync<string[]>
new Resolver(routeResolver: ResolveRoute, options?: RelayScenarioOptions)
await resolver.scenario(selections) // Promise<RelayScenario> — resolves each route, builds a scenario
await resolver.relays(selections) // Promise<string[]> — scenario(...).getUrls()
await resolver.relay(selections) // Promise<string | undefined> — scenario(...).getUrl()
In an app, @welshman/app's Router owns the Resolver (app.use(Router).resolver), built with
getRelayQuality/getDefaultRelays from app config, and injects it into every domain kind's
context. Domain readers/writers then call def.context.resolver.scenario(...) / .relay(...).
import {outbox, inboxes, relay, Resolver} from '@welshman/util'
// Declarative selections: author's write relays (weight 1) + each mentioned
// pubkey's read relays (weight 0.5) + an explicit relay hint.
const selections = [
outbox(authorPubkey),
...inboxes(mentionedPubkeys, 0.5),
relay('wss://relay.example.com'),
]
// A Resolver dereferences routes -> urls given some route resolver.
const resolver = new Resolver(resolveRoute, {limit: 5, getRelayQuality})
const urls = await resolver.relays(selections) // string[]
Routing gotchas
- Resolution is async, because resolving an outbox may have to load a NIP-65 list first.
- A scenario that resolves to nothing yields an empty array. Add
.policy(addMinimalFallbacks)where an empty result would break the caller. - Weights express preference, not selection. Naming the same url in ten selections does not make it ten times more likely. To force a relay, use
forceRoutes(domain writers) orsetRoutes(domain queries). - Two calls with identical selections can return different urls. In tests, assert on membership rather than exact url lists, or inject a deterministic
getRelayQuality. - A relay whose
getRelayQualityis 0 is dropped entirely, because the scenario filters on the score and-0is falsy. A scenario can come back empty even though its selections resolved to urls.
Slash commands
Command.ts models NIP-89-style slash commands: CommandArg/CommandArgType/COMMAND_ARG_TYPES
(pubkey, event, address, relay, number, bool, enum, word, text) with
validateCommandArgs; CommandScope/CommandScopeTarget with parseCommandScope,
renderCommandScope, matchesCommandScopes, commandScopeRelays, commandScopesToFilter; and
the invocation grammar — parseCommandInvocation, renderCommandInvocation, bindCommandArgs,
parseCommandArgs, getActiveCommandArgIndex. Kind COMMAND is 31992. @welshman/editor's
CommandExtension/CommandSuggestion render and autocomplete these in the composer.
Lightning (NIP-57 support)
| Export | Description |
|---|---|
getLnUrl(address) |
Convert a lud16 address, HTTPS URL, or existing lnurl1… to an LNURL; undefined if invalid |
getInvoiceAmount(bolt11) |
Extract the millisatoshi amount from a BOLT11 invoice |
hrpToMillisat(hrpString) |
Convert a human-readable BTC amount to millisats (bigint) |
toMsats(sats) / fromMsats(msats) |
Unit conversion |
The Zapper and Zap types moved to @welshman/domain (other/Zapper.ts), alongside the ZapRequest/ZapReceipt/ZapGoal kinds. Receipt validation is app.use(Zappers).validateZapReceipt(...).
NIP-05 handles
| Export | Description |
|---|---|
Handle |
{ nip05, pubkey?, nip46?, relays? } |
queryProfile(nip05) |
Resolve a NIP-05 identifier via /.well-known/nostr.json; undefined on failure |
displayNip05(nip05) / displayHandle(handle) |
Drop a leading _@ for display |
Pubkey
Pubkey wraps a hex pubkey plus relay hints. Pubkey.from(entity, relays?) accepts hex, npub… or nprofile…; instances expose toString(), toNpub(), toNprofile().
Wallet
| Export | Description |
|---|---|
WalletType |
Enum: WebLN, NWC |
Wallet |
Union: `WebLNWallet |
isWebLNWallet(wallet) |
Type guard |
isNWCWallet(wallet) |
Type guard |
NIP-42 (Relay Auth)
makeRelayAuth(url: string, challenge: string): StampedEvent
// Creates kind 22242 auth event; sign before sending
NIP-98 (HTTP Auth)
makeHttpAuth(url: string, method?: string, body?: string): Promise<StampedEvent>
makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr <base64>"
NIP-86 (Relay Management)
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
// ManagementResponse = { result?: any; error?: string }
Requests are built by make* factories rather than an enum: makeBanPubkey, makeAllowPubkey, makeBanEvent, makeAllowEvent, makeCreateRole/makeEditRole/makeDeleteRole, makeAssignRole/makeUnassignRole, makeAssignMethod/makeUnassignMethod, makeCreateClaim/makeDeleteClaim/makeListClaims, makeChangeRelayName/Description/Icon, makeAllowKind/makeDisallowKind, makeBlockIp/makeUnblockIp, makeSignEvent, makeSupportedMethods, and the matching makeList* readers.
ManagementApi is a client class that pairs a relay url with a ManagementSign function so you don't have to build the NIP-98 auth event per call. app.use(RelayManagement).forUrl(url) returns one bound to the app's user.
Links
fromNostrURI(s: string): string // strips "nostr:" or "nostr://" prefix
toNostrURI(s: string): string // ensures "nostr:" prefix
Blossom (Media Servers)
makeBlossomAuthEvent(opts: BlossomAuthEventOpts): StampedEvent
uploadBlob(server, blob, opts?): Promise<Response>
getBlob(server, sha256, opts?): Promise<Response>
deleteBlob(server, sha256, opts?): Promise<Response>
listBlobs(server, pubkey, opts?): Promise<Response>
checkBlobExists(server, sha256, opts?): Promise<{exists, size?}>
buildBlobUrl(server, sha256, extension?): string
encryptFile(file: Blob): Promise<EncryptedFile>
decryptFile(encryptedFile: EncryptedFile): Promise<Uint8Array>
Common Patterns
Creating and inspecting events
import { makeEvent, NOTE, PROFILE, RELAYS, LONG_FORM, getIdentifier, getIdOrAddress } from '@welshman/util'
// Text note (kind 1)
const note = makeEvent(NOTE, {
content: 'Hello Nostr!',
tags: [['t', 'nostr']],
})
// Profile update (kind 0)
const profile = makeEvent(PROFILE, {
content: JSON.stringify({ name: 'Alice', about: 'Nostr dev' }),
tags: [],
})
// Relay list (kind 10002)
const relayList = makeEvent(RELAYS, {
content: '',
tags: [
['r', 'wss://relay.example.com', 'read'],
['r', 'wss://relay2.example.com', 'write'],
],
})
Pre-verifying persisted events with verifiedSymbol
When loading events from a local store (IndexedDB, localStorage, etc.) at startup, you can skip expensive signature re-validation by marking them as already verified:
import { verifiedSymbol } from '@welshman/util'
import type { TrustedEvent } from '@welshman/util'
// Load from storage
const storedEvents: TrustedEvent[] = await db.getAll('events')
// Mark as pre-verified — verifyEvent() will return true immediately (without
// re-running the cryptographic check) for events that have a sig field
for (const event of storedEvents) {
event[verifiedSymbol] = true
}
app.repository.load(storedEvents)
Only do this for events you persisted yourself after they were validated. Never set
verifiedSymbol on events received directly from untrusted external sources.
Working with tags
import {
tagSpec,
hexTags,
topicTags,
relayTags,
tagValue,
tagValues,
} from '@welshman/util'
// A selector takes a spec FIRST, then the tags array
const title = tagValue(tagSpec('title'), event.tags) // string | undefined
const urls = tagValues(tagSpec('r'), event.tags) // string[]
// Multiple keys at once
const ids = tagValues(tagSpec(['e', 'a']), event.tags) // string[]
const mentions = tagValues(hexTags('p'), event.tags) // string[]
const topics = tagValues(topicTags('t'), event.tags) // string[] ("#x" -> "x")
const relays = tagValues(relayTags(['r', 'relay']), event.tags)
Matching and building filters
import { matchFilters, getIdFilters, getReplyFilters, addRepostFilters, NOTE } from '@welshman/util'
import { ago, HOUR } from '@welshman/lib'
// Does this event match our subscription?
const active = matchFilters([{ kinds: [NOTE], authors: [myPubkey] }], event)
// Fetch a set of events by id or address
const filters = getIdFilters([
'abc123', // event id
'30023:deadbeef:my-slug', // address
])
// Find all replies to a set of events
const replyFilters = getReplyFilters(events, { since: ago(HOUR) })
// Automatically include repost kinds
const withReposts = addRepostFilters([{ kinds: [NOTE] }])
Addresses for replaceable events
import { Address, getAddress } from '@welshman/util'
// From an addressable event
const addr = Address.fromEvent(event, ['wss://relay.example.com'])
console.log(addr.toString()) // '30023:deadbeef:my-slug'
console.log(addr.toNaddr()) // 'naddr1...'
// Round-trip from naddr
const parsed = Address.fromNaddr('naddr1...')
// Quick string form
const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
Zap flow
import { getLnUrl, makeEvent, ZAP_REQUEST } from '@welshman/util'
import { Zappers } from '@welshman/app'
// Step 1: resolve LNURL
const lnurl = getLnUrl('satoshi@getalby.com')
if (!lnurl) throw new Error('Invalid lightning address')
// Step 2: build zap request (kind 9734)
const zapRequest = makeEvent(ZAP_REQUEST, {
content: 'Great post!',
tags: [
['p', recipientPubkey],
['e', targetEventId],
['amount', '5000'], // millisats
['lnurl', lnurl],
['relays', 'wss://relay.damus.io'],
],
})
// Step 3: sign, send to LNURL callback, pay invoice...
// Step 4: validate receipt (kind 9735)
const zap = await app.use(Zappers).validateZapReceipt(zapReceipt, zappedEvent)
if (zap) {
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
}
NIP-42 relay authentication
import { makeRelayAuth } from '@welshman/util'
// Inside relay AUTH handler
const authEvent = makeRelayAuth('wss://relay.example.com', challengeFromRelay)
const signed = await signer.sign(authEvent)
// send signed AUTH message to relay
NIP-98 HTTP authentication
import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util'
const body = JSON.stringify({ data: 'example' })
const authEvent = await makeHttpAuth('https://api.example.com/upload', 'POST', body)
const signed = await signer.signEvent(authEvent)
await fetch('https://api.example.com/upload', {
method: 'POST',
body,
headers: {
Authorization: makeHttpAuthHeader(signed),
'Content-Type': 'application/json',
},
})
Integration Notes
@welshman/net— usesTrustedEvent,Filter,SignedEventfrom this package as the wire types for relay connections and subscriptions.@welshman/store— provides Svelte stores over repositories built onTrustedEvent; relies onisReplaceable,getAddress, etc. for deduplication.@welshman/app— high-level application layer; composes net/store/domain and uses the lightning helpers from this package (profile/list/handler/room helpers now live in@welshman/domain).@welshman/app'sRouterplugin — dereferences theRelaySelectionDSL defined here, and injects itsResolverinto every@welshman/domainkind.@welshman/signer— producesSignedEventobjects that satisfy types defined here; signers also provide thenip44encrypt/decrypt functions used by@welshman/domainlist writers to encrypt private (NIP-44) tags.
Gotchas & Tips
-
TrustedEventvsSignedEvent: Most in-app code should acceptTrustedEvent(has id, may have sig). Only requireSignedEventwhen you need to ensure the event has a signature. -
Replaceable event identity: Use
getIdOrAddressrather thanevent.idwhen referencing events that may be addressable — the address string is stable across updates, the id is not. -
app.use(Zappers).validateZapReceiptreturnsundefinedon any validation failure including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps usevalidZapReceipts(receipts, parent), which re-validates as each recipient's zapper loads. -
getLnUrlhandles three input forms: bare lightning address (user@domain), full HTTPS URL, or already-encodedlnurl1.... Returnsundefinedfor anything else. -
normalizeTopicis not exported.Topics.tsisn't re-exported from the index; usetopicTags("t")to get normalized topic values off an event's tags. -
normalizeRelayUrlvsdisplayRelayUrl: UsenormalizeRelayUrlbefore storing or comparing relay URLs. UsedisplayRelayUrlonly for human-readable display (strips protocol/trailing slash). -
Address.isAddresschecks thekind:pubkey:identifierformat only, not naddr. To validate an naddr string, useAddress.fromNaddrinside a try/catch. -
Tag selector argument order: the spec comes first, the tags array second —
tagValue(tagSpec('title'), event.tags),tagValues(hexTags('p'), event.tags). Mixing up the order produces no TypeScript error but silently returnsundefinedor[]. -
verifiedSymbolis a Symbol key: you must importverifiedSymbolfrom@welshman/utiland use it as a computed property key —event[verifiedSymbol] = true. You cannot use a string key. The symbol is re-exported fromnostr-tools/pure, so it is the same identity as the one used internally byverifyEvent.
Related skills
welshman-app(welshman-app skill) — theRouterplugin that dereferences the routing DSL above, plusRelayStats.getQualityfor the scoring input.@welshman/domain(welshman-domain skill) — Profiles, lists, handlers, rooms, and event routing moved out of@welshman/utiland now live here. The old free functions (readProfile/makeProfile,readList/makeList,PublishedProfile/PublishedList,Encryptable, the handler/room helpers, …) were replaced by configurableKindFactorybundles:Kind.configure(context).reader(event)returns an async Reader that decodes the event, and.writer(reader?)builds/edits one. Private (NIP-44) list tags are handled inside the list Reader/Writer, soEncryptable/DecryptedEventno longer exist.