Compare commits

30 Commits

Author SHA1 Message Date
hodlbod ed17dcc412 Fix NIP conformance in domain kinds; add domain docs/skill
tests / tests (push) Failing after 5m15s
2026-06-20 09:12:18 -07:00
Jon Staab e2a6ef21cd Refine domain, integrate into app
tests / tests (push) Failing after 5m14s
2026-06-19 22:21:06 -07:00
Jon Staab 1bd62d3024 Move domain stuff to sub directory, clean up base classes 2026-06-19 11:57:40 -07:00
hodlbod bfd91f2d39 Rewrite domain objects as a Reader/Builder split
tests / tests (push) Failing after 5m7s
Replace the single DomainObject/EncryptableList classes with a read/write split
that removes the optional-event ambiguity:

- base.ts: EventReader<P> (static kind; fromEvent(event, signer?) eagerly computes
  a generic `plain`, validates leniently, throws-or-passes; lazy method accessors;
  group/protect/expires + extraTags carry-over; builder()) and EventBuilder<P>
  (chainable setters, buildTags/buildContent, validate-on-emit).
- List.ts: ListReader/ListBuilder for NIP-51 lists (decrypt-on-read into `plain`,
  re-encrypt-on-emit, tag mutators).
- Every kind converted to a <Noun> reader + <Noun>Builder pair; membership ops
  split into per-kind reader/builder pairs over a shared abstract base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
2026-06-19 16:01:42 +00:00
hodlbod 5e142e4db4 Refine domain classes: behavior tags, extra-tag passthrough, cleanups
tests / tests (push) Failing after 5m10s
Iterate on @welshman/domain following review:

- base: add `group`/`protect`/`expires` behavior tags (parsed in base, emitted
  via addBehaviorTags before hashing) and an `extraTags` passthrough (opt-in via
  reservedTagKeys) so tag carry-over lives in one place; migrate Handler, Comment,
  Thread onto it. Comment gains nested root/parent ref structs + setters.
- List: fix inverted keepTags; add clearTags/clearPublicTags/clearPrivateTags and
  use them in the relay/server list setters.
- RelayList: preserve complementary read/write capability instead of dropping
  modeless entries.
- Split Relay/Room membership ops into per-kind classes (RelayAddMember/
  RelayRemoveMember, RoomAddMember/RoomRemoveMember) over a shared base.
- TimeEvent (renamed from CalendarEvent): derive "D" day tags in toTemplate.
- Feed: default to an empty feed, fail parse when the "feed" tag is missing.
- RelaySet added; CommunityList renamed to GroupList; predicate bare add/remove
  mutators; RoomMeta uses randomId; PollResponse.selections drops pollType.
- Remove ChannelList, FileServerList, Settings, and event-asserting getAddress/
  display accessors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
2026-06-18 22:42:10 +00:00
hodlbod 99f5233e05 Add domain object classes for nostr event types
tests / tests (push) Failing after 5m6s
Build out @welshman/domain on top of the DomainObject/EncryptableList base
patterns, porting domain-object use cases from @welshman/util and flotilla
that weren't yet represented.

New classes:
- Relay lists: RelayList (NIP-65 read/write markers), Blocked/Search/Messaging
  relay lists, RelaySet (NIP-51 30002 named set)
- Server lists: Blossom, FileServer
- NIP-51 lists: Follow, Pin, Bookmark, Community, Channel, Room, Feed, Topic,
  Emoji
- Zaps: ZapRequest, ZapReceipt, ZapGoal
- NIP-89 handlers: Handler, HandlerRecommendation
- Rooms/groups (NIP-29): RoomMeta, RoomAdmins, RoomMembers, RoomMembershipOp,
  Room create/delete/join/leave, RoomCreatePermission, RelayMembers,
  RelayMembershipOp, Relay join/leave/invite
- Content: Poll, PollResponse, Thread, Comment, Classified, CalendarEvent,
  Report, Feed, Settings

Also fix unfinished accessors in Profile, method-call bugs in MuteList, and
correct RelayList.set{Read,Write}Relays to preserve a relay's complementary
read/write capability instead of dropping modeless entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
2026-06-18 21:16:27 +00:00
Jon Staab 925f540640 Clean up domain a bit
tests / tests (push) Failing after 5m5s
2026-06-18 12:49:13 -07:00
Jon Staab 0a08057786 Remove serialization from domain 2026-06-18 12:33:15 -07:00
hodlbod fe5c11b00f rename client, update docs/skills
tests / tests (push) Failing after 5m4s
2026-06-18 19:31:14 +00:00
Jon Staab dfeb7a747b Simplify client plugin interfaces
tests / tests (push) Failing after 5m11s
2026-06-18 11:48:14 -07:00
Jon Staab eb451d795b Add domain package 2026-06-18 11:41:21 -07:00
Jon Staab 393c95e107 Clean up projections 2026-06-18 11:40:39 -07:00
Jon Staab 772895e3ab Tweak relay lists 2026-06-18 11:15:02 -07:00
Jon Staab fafa3b172e Move plugins to a plugin directory 2026-06-18 10:31:48 -07:00
Jon Staab 72ab746254 Add projection utility and type
tests / tests (push) Failing after 5m56s
2026-06-18 10:15:28 -07:00
Jon Staab aae201414d Clean up store semantics 2026-06-18 08:25:23 -07:00
Jon Staab f5124a6c4e Clean up wot 2026-06-17 14:56:39 -07:00
Jon Staab 28219eb64f Small fixes, rework zaps 2026-06-17 10:36:00 -07:00
Jon Staab bc728c680e Rework thunks 2026-06-16 17:07:20 -07:00
Jon Staab abb9f20747 Split up commands and add them to domain modules 2026-06-16 16:34:43 -07:00
Jon Staab 163d2dc355 Tweak outbox loader 2026-06-16 15:00:18 -07:00
Jon Staab 9094d30b89 Add socket policy for authenticating unless blocked 2026-06-16 14:44:33 -07:00
Jon Staab f8130da2bb Rework sessions 2026-06-16 14:07:15 -07:00
Jon Staab 2e12010e26 rework client auth 2026-06-16 13:06:29 -07:00
Jon Staab 87d8a0832d remove net global state 2026-06-16 12:31:46 -07:00
Jon Staab 34065a18cf Remove router singleton 2026-06-16 11:08:46 -07:00
Jon Staab 96b0116c9b Auto register client plugins 2026-06-16 10:32:59 -07:00
Jon Staab ea9cc0bf26 AI refactor 2026-06-16 09:22:26 -07:00
Jon Staab 28339976b9 Add more stuff to client 2026-06-15 18:59:29 -07:00
Jon Staab e0e9ad5834 Add client package 2026-06-15 18:59:28 -07:00
230 changed files with 13252 additions and 6791 deletions
+2
View File
@@ -2,4 +2,6 @@ node_modules
docs docs
docs/reference docs/reference
docs/.vitepress/cache docs/.vitepress/cache
dist
build build
.git
+1
View File
@@ -9,3 +9,4 @@ results
*.tsbuildinfo *.tsbuildinfo
.vscode .vscode
docs/**/*.html docs/**/*.html
.local
+22 -13
View File
@@ -24,16 +24,29 @@ export default defineConfig({
text: "@welshman/app", text: "@welshman/app",
link: "/app/", link: "/app/",
items: [ items: [
{text: "Session Management", link: "/app/session"}, {text: "The App", link: "/app/app"},
{text: "Relay Selection", link: "/app/relay-selection"}, {text: "User & Sessions", link: "/app/user"},
{text: "Making Requests", link: "/app/making-requests"}, {text: "Plugin Architecture", link: "/app/plugins"},
{text: "Publishing Events", link: "/app/publishing-events"}, {text: "Data Plugins", link: "/app/data"},
{text: "Tag utilities", link: "/app/tags"}, {text: "Publishing Events", link: "/app/publishing"},
{text: "Making Requests", link: "/app/requests"},
{text: "Routing & Tags", link: "/app/routing"},
{text: "Web of Trust", link: "/app/wot"}, {text: "Web of Trust", link: "/app/wot"},
{text: "Storage", link: "/app/storage"}, {text: "Feeds & Search", link: "/app/feeds-and-search"},
{text: "Context", link: "/app/context"}, ],
{text: "Commands", link: "/app/commands"}, },
{text: "User", link: "/app/user"}, {
text: "@welshman/domain",
link: "/domain/",
items: [
{text: "The App Reader/Builder model", link: "/domain/readers-and-builders"},
{text: "Profile", link: "/domain/profile"},
{text: "Lists", link: "/domain/lists"},
{text: "Rooms", link: "/domain/rooms"},
{text: "Relay Membership", link: "/domain/relay-membership"},
{text: "Handlers", link: "/domain/handlers"},
{text: "Zaps", link: "/domain/zaps"},
{text: "Content", link: "/domain/content"},
], ],
}, },
{ {
@@ -46,11 +59,7 @@ export default defineConfig({
{text: "Events", link: "/util/events"}, {text: "Events", link: "/util/events"},
{text: "Filters", link: "/util/filters"}, {text: "Filters", link: "/util/filters"},
{text: "Tags", link: "/util/tags"}, {text: "Tags", link: "/util/tags"},
{text: "Encryptable", link: "/util/encryptable"},
{text: "Relays", link: "/util/relay"}, {text: "Relays", link: "/util/relay"},
{text: "Profiles", link: "/util/profile"},
{text: "Handlers", link: "/util/handlers"},
{text: "Lists", link: "/util/list"},
{text: "Zaps", link: "/util/zaps"}, {text: "Zaps", link: "/util/zaps"},
{text: "Relay Auth", link: "/util/nip42"}, {text: "Relay Auth", link: "/util/nip42"},
{text: "HTTP Auth", link: "/util/nip98"}, {text: "HTTP Auth", link: "/util/nip98"},
+173
View File
@@ -0,0 +1,173 @@
# The App
An `App` is an application instance. It owns every piece of per-identity state and is the entry point to all features. You will usually create one with `createApp` and access everything else through `app.use(...)`.
## Creating an app
### `createApp(options?)`
The batteries-included factory. It returns an `App` wired with the [default policies](#policies) (event ingestion, relay-stats collection, gift-wrap unwrapping, and NIP-42 auth) unless you pass your own `policies`.
```typescript
import {createApp} from "@welshman/app"
const app = createApp({
user, // optional signed-in User
config: {
dufflepudUrl: "https://dufflepud.example",
getDefaultRelays: () => ["wss://relay.example"],
getIndexerRelays: () => ["wss://purplepag.es"],
getSearchRelays: () => ["wss://relay.nostr.band"],
},
})
```
### `new App(options?)`
Use the constructor directly when you want a bare app with **no** side effects (for example in tests, or when you install policies yourself).
```typescript
import {App} from "@welshman/app"
const app = new App() // no policies installed
```
## `AppOptions`
```typescript
type AppOptions = {
user?: User // the signed-in identity (at most one)
config?: AppConfig
getAdapter?: AdapterFactory // net-layer adapter factory
policies?: AppPolicy[] // side effects to install at construction
}
```
## `AppConfig`
App-level configuration. All fields are optional; the three relay getters return `string[]` and feed the [Router](./routing).
```typescript
type AppConfig = {
dufflepudUrl?: string // optional dufflepud service (batches NIP-05 / zapper lookups)
getDefaultRelays?: () => string[]
getIndexerRelays?: () => string[] // relays used to discover relay lists / profiles
getSearchRelays?: () => string[] // NIP-50 search relays
}
```
## `IApp`
Plugins and policies never depend on the concrete `App` class — they take the `IApp` contract:
```typescript
interface IApp {
user?: User
config: AppConfig
use: <T>(Ctor: new (app: IApp) => T) => T
netContext: NetContext // {pool, repository, getAdapter} for the net layer
pool: Pool // connection pool
tracker: Tracker // tracks which relays have seen each event
repository: Repository // the local event store / single source of truth
wrapManager: WrapManager // NIP-59 gift-wrap bookkeeping
}
```
Every primitive (`pool`, `tracker`, `repository`, `wrapManager`) is constructed fresh per instance, so data never bleeds across identities or sessions.
## Resolving features: `use`
```typescript
use: <T>(Ctor: new (app: IApp) => T) => T
```
`use` is a per-app singleton resolver. The first time you pass a plugin class, the app constructs `new Ctor(this)` and caches it; subsequent calls return the same instance.
```typescript
const profiles = app.use(Profiles)
const sameInstance = app.use(Profiles) // identical reference
```
This is dependency resolution by demand. Plugins reach their own dependencies the same way (`this.app.use(Network)`, `this.app.use(Router)`), which means dependency cycles resolve lazily and there is no constructor wiring to maintain.
## Teardown: `cleanup`
```typescript
app.cleanup()
```
`cleanup()` runs every policy's unsubscribe function, then clears the `pool`, `tracker`, `repository`, and `wrapManager`. Call it when you discard an app (e.g. switching identities) to release connections and free memory.
## Policies
A **policy** is the unit of side effects. It runs once at construction and returns an `Unsubscriber` that `cleanup()` will later call. Keeping side effects in policies leaves the data plugins pure and centralizes teardown.
```typescript
type AppPolicy = (app: IApp) => Unsubscriber
```
### Default policies
`createApp` installs `defaultAppPolicies`:
| Policy | What it does |
|---|---|
| `appPolicyIngest` | Subscribes to the pool; verifies inbound relay events (skipping DVM/ephemeral kinds) and writes them to the `repository` and `tracker`. This is how every repository-backed store gets populated. |
| `appPolicyRelayStats` | Pipes socket activity into the [`RelayStats`](./routing#relay-quality) store. |
| `appPolicyWraps` | Enqueues existing and newly-arriving gift-wrap events for unwrapping. |
| `appPolicyAuthUnlessBlocked` | Answers NIP-42 AUTH challenges, except for relays in the user's blocked-relay list. |
### Auth policy builders
```typescript
makeAppPolicyAuth(shouldAuth: (socket: Socket, app: IApp) => boolean): AppPolicy
appPolicyAuthNever // never answer AUTH
appPolicyAuthAlways // always answer AUTH
appPolicyAuthUnlessBlocked // answer unless the relay is blocked by the user
```
Auth policies are no-ops when there is no signed-in user.
### Customizing policies
Pass your own `policies` array to opt out of, or extend, the defaults:
```typescript
import {App, defaultAppPolicies, makeAppPolicyLogger} from "@welshman/app"
const app = new App({
user,
policies: [
...defaultAppPolicies,
makeAppPolicyLogger(msg => console.log(msg)), // see Logging
],
})
```
## Logging
`@welshman/app` can make a user's signer observable. `User.fromSigner`/`User.fromSession` wrap the underlying signer in a `LoggingSigner`, which emits a structured `LogMessage` for every signer operation (pending → success/failure).
```typescript
type LogMessage =
| {type: "signer"; id: string; method: string; status: "pending" | "success" | "failure"; error?: unknown; at: number}
| {type: string; at: number; [key: string]: unknown}
```
Forward those messages by installing `makeAppPolicyLogger`:
```typescript
import {makeAppPolicyLogger} from "@welshman/app"
const app = new App({
user,
policies: [...defaultAppPolicies, makeAppPolicyLogger(msg => {
if (msg.type === "signer" && msg.status === "failure") {
console.error("signing failed", msg.method, msg.error)
}
})],
})
```
The logger policy is a no-op unless the user's signer is a `LoggingSigner` (which it is when the user was created via `User.fromSigner`/`User.fromSession`).
-76
View File
@@ -1,76 +0,0 @@
# Commands
Commands are functions which pull from app state to publish events on behalf of the user. Most are async and return a thunk
## Relay Management (NIP 65)
```typescript
removeRelay(url: string, mode: RelayMode): Promise<Thunk>
addRelay(url: string, mode: RelayMode): Promise<Thunk>
```
## Messaging Relay Management (NIP 17)
```typescript
removeMessagingRelay(url: string): Promise<Thunk>
addMessagingRelay(url: string): Promise<Thunk>
```
## Profile Management (NIP 01)
```typescript
setProfile(profile: Profile): Thunk
```
## Follow Management (NIP 02)
```typescript
unfollow(value: string): Promise<Thunk>
follow(tag: string[]): Promise<Thunk>
```
## Mute Management
```typescript
unmute(value: string): Promise<Thunk>
mutePublicly(tag: string[]): Promise<Thunk>
mutePrivately(tag: string[]): Promise<Thunk>
setMutes(options: {
publicTags?: string[][]
privateTags?: string[][]
}): Promise<Thunk>
```
## Pin Management
```typescript
unpin(value: string): Promise<Thunk>
pin(tag: string[]): Promise<Thunk>
```
## Wrapped Messages (NIP 59)
```typescript
type SendWrappedOptions = Omit<ThunkOptions, "event" | "relays"> & {
event: EventTemplate
recipients: string[]
}
sendWrapped(options: SendWrappedOptions): Promise<MergedThunk>
```
## Relay Management (NIP 86)
```typescript
manageRelay(url: string, request: ManagementRequest): Promise<Response>
```
## Room Management (NIP 29)
```typescript
createRoom(url: string, room: RoomMeta): Thunk
deleteRoom(url: string, room: RoomMeta): Thunk
editRoom(url: string, room: RoomMeta): Thunk
joinRoom(url: string, room: RoomMeta): Thunk
leaveRoom(url: string, room: RoomMeta): Thunk
```
-13
View File
@@ -1,13 +0,0 @@
# Application Context
The `@welshman/app` package uses a global context system to configure a few core behaviors.
## Dufflepud
[Dufflepud](https://github.com/coracle-social/dufflepud) is a utility server that can retrieve NIP 05 profiles, zappers, relay metadata, link previews, etc. It's not necessary for using welshman, but can improve things by bypassing CORS.
```typescript
import {appContext} from '@welshman/app'
appContext.dufflepudUrl = 'https://my-dufflepud-instance.com'
```
+184
View File
@@ -0,0 +1,184 @@
# Data Plugins
These plugins expose reactive collections of nostr data. They all follow the [plugin patterns](./plugins): read synchronously with `get(key)`, reactively with `one(key)` (which lazily loads), and use the convenience accessors that return a [`Projection`](./plugins#projection-t). Resolve each with `app.use(...)`.
Most event-backed plugins load via the **outbox model**: they first resolve the author's NIP-65 write relays (from [`RelayLists`](#relay-lists)), then query those relays. This is why nearly every data plugin depends on relay lists.
## Profiles
Kind-0 profiles keyed by pubkey.
```typescript
const profiles = app.use(Profiles)
profiles.one(pubkey) // Readable<Maybe<Profile>> — lazily loads
profiles.get(pubkey) // Maybe<Profile> — sync snapshot, no load
await profiles.load(pubkey) // explicit load (cached)
profiles.display(pubkey) // Projection<string> — display name (falls back to npub)
await profiles.publish(values) // merge a partial values record over the current profile and publish (kind 0)
```
`profiles.display(pubkey).$` is the right thing to bind in a component for a user's name.
## Follows
Kind-3 follow lists keyed by pubkey.
```typescript
const follows = app.use(FollowLists)
follows.one(pubkey) // Readable<Maybe<FollowList>>
await follows.follow(["p", otherPubkey]) // add a tag and publish to outbox
await follows.unfollow(otherPubkey) // remove and publish
```
## Mutes
Kind-10000 mute lists keyed by pubkey. Private entries are NIP-44 encrypted, so decoding is asynchronous.
```typescript
const mutes = app.use(MuteLists)
mutes.one(pubkey) // Readable<Maybe<MuteList>>
await mutes.mutePublicly(["p", pubkey]) // public mute
await mutes.mutePrivately(["p", pubkey]) // encrypted mute
await mutes.unmute(pubkey)
await mutes.setMutes({publicTags, privateTags})
```
## Pins
Kind-10001 pin lists keyed by pubkey.
```typescript
const pins = app.use(PinLists)
pins.one(pubkey)
await pins.pin(["e", eventId])
await pins.unpin(eventId)
```
## Relay lists
The NIP-65 relay list (kind 10002) is the routing substrate the whole outbox model depends on.
```typescript
const relayLists = app.use(RelayLists)
relayLists.urls(pubkey) // Projection<string[]> — all relays
relayLists.readUrls(pubkey) // Projection<string[]> — read relays
relayLists.writeUrls(pubkey) // Projection<string[]> — write relays
// Mutations for the current user
await relayLists.addRelay(url, RelayMode.Write)
await relayLists.removeRelay(url, RelayMode.Read) // also notifies the removed relay
await relayLists.setReadRelays(urls)
await relayLists.setWriteRelays(urls)
await relayLists.setRelays(tags)
```
### Specialized relay lists
Each of these is a separate kind with the same shape (`urls(pubkey)`, `addUrl`, `removeUrl`, `setUrls`):
| Plugin | Kind | Purpose |
|---|---|---|
| `BlockedRelayLists` | 10006 | Relays the user refuses to connect to (also gates [auth](./apppolicies) and [relay quality](./routing#relay-quality)) |
| `MessagingRelayLists` | 10050 | NIP-17 DM inbox relays (used by [gift-wrapped publishing](./publishing#gift-wrapped-messages)) |
| `SearchRelayLists` | 10007 | NIP-50 search relays |
```typescript
app.use(BlockedRelayLists).urls(pubkey) // Projection<string[]>
app.use(MessagingRelayLists).urls(pubkey)
app.use(SearchRelayLists).urls(pubkey)
```
## Relays (NIP-11)
Relay metadata fetched over **HTTP**, keyed by relay URL.
```typescript
const relays = app.use(Relays)
relays.one(url) // Readable<Maybe<RelayProfile>> — lazily fetches NIP-11
relays.display(url) // Projection<string>
await relays.hasNip(url, 50) // boolean — does the relay support a NIP?
await relays.hasNegentropy(url) // boolean — NIP-77 / negentropy support
```
## Relay management (NIP-86)
```typescript
await app.use(RelayManagement).post(url, managementRequest)
```
Builds a NIP-98 HTTP-auth event signed by the current user and sends a NIP-86 management request to the relay.
## Handles (NIP-05)
NIP-05 identifiers verified over HTTP, keyed by `name@domain`. Lookups are batched (and use `dufflepudUrl` if configured).
```typescript
const handles = app.use(Handles)
handles.forPubkey(pubkey) // Projection<Maybe<Handle>> — resolves via the profile's nip05
handles.display(nip05) // string — displayable nip05
await handles.loadForPubkey(pubkey)
```
## Zappers (Lightning)
LNURL zapper info keyed by lnurl, fetched over HTTP.
```typescript
const zappers = app.use(Zappers)
zappers.forPubkey(pubkey) // Projection<Maybe<Zapper>>
await zappers.validateZapReceipt(zapReceipt, parentEvent) // Promise<Maybe<Zap>>
zappers.validZapReceipts(zapReceipts, parentEvent) // Projection<Zap[]>
```
## Blossom servers
Blossom media-server lists (kind 10063) keyed by pubkey.
```typescript
const list = await app.use(BlossomServerLists).load(pubkey)
app.use(BlossomServerLists).one(pubkey) // Readable<Maybe<List>>
```
## Topics
Hashtags with usage counts, derived from the repository's tag index.
```typescript
const topics = app.use(Topics)
topics.all // Readable<Topic[]> ({name, count})
topics.byName // Readable<Map<string, Topic>>
```
## Rooms (NIP-29)
Relay-based group management. Each method builds the relevant room event and publishes it to a single relay as the current user.
```typescript
const rooms = app.use(Rooms)
rooms.create(relayUrl, roomMeta)
rooms.edit(relayUrl, roomMeta)
rooms.delete(relayUrl, roomMeta)
rooms.join(relayUrl, roomMeta)
rooms.leave(relayUrl, roomMeta)
rooms.addMember(relayUrl, roomMeta, pubkey)
rooms.removeMember(relayUrl, roomMeta, pubkey)
```
## Plaintext
A cache of decrypted content, keyed by event id. Only decrypts events authored by the current user (e.g. your own private list entries or DMs).
```typescript
const text = await app.use(Plaintext).ensure(event) // decrypts & caches
const cached = app.use(Plaintext).get(event.id) // sync read of the cache
```
+77
View File
@@ -0,0 +1,77 @@
# Feeds & Search
## Feeds
`app.use(Feeds)` builds `@welshman/feeds` `FeedController`s wired to this app — its router, web-of-trust graph, signer, and net context are all injected for you, so the controller can resolve scopes (`Self`, `Follows`, `Network`, `Followers`) and WoT ranges to real pubkeys and fetch through the app's repository and pool.
```typescript
import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds"
const controller = app.use(Feeds).makeFeedController({
feed: makeIntersectionFeed(
makeScopeFeed(Scope.Follows),
makeKindFeed(1),
),
onEvent: event => {
// render the event
},
})
await controller.load(50) // load a page of 50
```
### `MakeFeedControllerOptions`
```typescript
type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
```
You provide the `feed` (and typically `onEvent`); the app injects `router`, `signer`, `context`, and the scope/WoT-range resolvers. The scope resolvers map `@welshman/feeds` `Scope` values to pubkeys via [`Wot`](./wot):
- `Scope.Self` → the current user
- `Scope.Follows``Wot.follows(pubkey)`
- `Scope.Network``Wot.network(pubkey)`
- `Scope.Followers``Wot.followers(pubkey)`
WoT-range feeds resolve to the pubkeys whose trust score falls within a fraction of the maximum score in the graph.
## Search
`app.use(Searches)` provides fuzzy ([Fuse.js](https://fusejs.io)) search over profiles, topics, and relays. Profile search additionally triggers a NIP-50 network search and ranks results by web of trust.
```typescript
import {get} from "svelte/store"
const searches = app.use(Searches)
// Each of these is a Readable<Search<...>> that stays up to date
const profileSearch = get(searches.profileSearch)
const topicSearch = get(searches.topicSearch)
const relaySearch = get(searches.relaySearch)
// A Search exposes both option objects and their values
profileSearch.searchValues("alice") // string[] — pubkeys; also fires a NIP-50 network search
profileSearch.searchOptions("alice") // Profile[]
profileSearch.getOption(pubkey) // Profile | undefined
```
Profile results are ranked by blending the Fuse score with the WoT score, so well-trusted matches surface first. An empty search term returns all options.
### Building your own search
The generic `createSearch` helper underlies the built-in searches and is exported for custom indexes:
```typescript
import {createSearch} from "@welshman/app"
const search = createSearch(items, {
getValue: item => item.id, // map an item to its identifier
fuseOptions: {keys: ["name", "about"], threshold: 0.3},
onSearch: term => {/* e.g. trigger a network fetch */},
sortFn: results => results, // optional custom result ordering
})
search.searchOptions("query") // T[]
search.searchValues("query") // V[]
search.getOption(value) // T | undefined
```
+71 -60
View File
@@ -2,82 +2,93 @@
[![version](https://badgen.net/npm/v/@welshman/app)](https://npmjs.com/package/@welshman/app) [![version](https://badgen.net/npm/v/@welshman/app)](https://npmjs.com/package/@welshman/app)
A comprehensive framework for building nostr clients, powering production applications like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). It provides a complete toolkit for managing events, subscriptions, user data, and relay connections. An instance-based, composable client for building nostr applications. It powers production clients like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social), and ties together the rest of the welshman packages (`util`, `net`, `store`, `router`, `signer`, `feeds`) into a single, cohesive app layer.
## What's Included ## The core idea: an app is an `App` instance
- **Repository** - Event storage and query capabilities Everything in `@welshman/app` hangs off a single `App` instance. An `App` owns the per-identity primitives — an event `Repository`, a connection `Pool`, a `Tracker`, and a `WrapManager` — plus a `config` and (optionally) a signed-in `User`. Because all state lives on the instance, two apps never share data: you can run multiple identities side-by-side, and tearing one down with `cleanup()` releases everything it allocated.
- **Router** - Intelligent relay selection for optimal network access
- **Feed Controller** - Manages feed loading
- **Session Management** - User identity and key management
- **Event Actions** - High-level operations like reacting, replying, etc.
- **Profile Management** - User profile handling and metadata
- **Relay Directory** - Discovery and management of relays
- **Web of Trust** - Utilities for building webs of trust
## Quick Example
```typescript ```typescript
import {getNip07} from '@welshman/signer' import {createApp} from "@welshman/app"
import {load, request, RequestEvent, defaultSocketPolicies, makeSocketPolicyAuth, Socket} from '@welshman/net'
import {StampedEvent, TrustedEvent, makeEvent, NOTE} from '@welshman/util'
import {pubkey, signer, publishThunk} from '@welshman/app'
// Log in via NIP 07 // A batteries-included app (event ingestion, relay stats, gift-wrap
addSession({method: 'nip07', pubkey: await getNip07().getPubkey()}) // unwrapping, and NIP-42 auth are all wired up by default policies)
const app = createApp()
```
// Enable automatic authentication to relays Features are exposed as **plugins** — lazily-constructed singletons resolved through `app.use(...)`:
defaultSocketPolicies.push(
makeSocketPolicyAuth({
sign: (event: StampedEvent) => signer.get()?.sign(event),
shouldAuth: (socket: Socket) => true,
}),
)
// This will fetch the user's profile automatically, and return a store that updates ```typescript
// automatically. Several different stores exist that are ready to go, including handles, import {createApp, Profiles, RelayLists, Thunks} from "@welshman/app"
// zappers, relayLists, relays, follows, mutes.
const profile = deriveProfile(pubkey.get())
// Publish is done using thunks, which optimistically publish to the local database, deferring const app = createApp()
// signing and publishing for instant user feedback. Progress is reported as relays accept/reject the event
// Events are automatically signed using the current session // Each plugin is constructed once per app and memoized
const thunk = publishThunk({ const profiles = app.use(Profiles)
relays: Router.get().FromUser().getUrls(), const relayLists = app.use(RelayLists)
```
This replaces the previous global-singleton design (`pubkey`, `deriveProfile`, `publishThunk`, `Router.get()`). There are no module-level globals anymore — you create an app and reach everything through it.
## Architecture at a glance
| Layer | What it is | Where |
|---|---|---|
| **`App`** | The app instance; owns repository/pool/tracker/wrapManager and the `use()` registry | [App](./app) |
| **`User` & sessions** | The signed-in identity and serializable login descriptors | [User & Sessions](./user) |
| **Policies** | Side effects installed at construction (ingest, auth, stats, wraps) | [App](./apppolicies) |
| **Plugins** | Lazily-resolved feature modules built on a small set of base classes | [Plugin architecture](./plugins) |
| **Data plugins** | Reactive collections of profiles, lists, relays, handles, zappers… | [Data](./data) |
| **Publishing** | Optimistic publishing via thunks | [Publishing](./publishing) |
| **Requests** | Loading & negentropy sync | [Requests](./requests) |
| **Routing** | Outbox-model relay selection and tag builders | [Routing](./routing) |
| **Web of Trust** | Follow/mute graph scoring | [Web of Trust](./wot) |
| **Feeds & Search** | Feed controllers and fuzzy search | [Feeds & Search](./feeds-and-search) |
## Quick example
```typescript
import {createApp, User, toSession, nip07, Profiles, Thunks, Router} from "@welshman/app"
import {getNip07} from "@welshman/signer"
import {makeEvent, NOTE} from "@welshman/util"
import {addMinimalFallbacks} from "@welshman/router"
// 1. Log in. A session is a serializable {method, data} descriptor; User
// turns it back into a live, signing identity.
const pubkey = await getNip07().getPubkey()
const session = toSession(nip07, {})
const user = await User.fromSession(session)
// 2. Create the app around that user.
const app = createApp({user})
// 3. Read data reactively. Stores lazily fetch over the network using the
// outbox model and update as events arrive.
const profile = app.use(Profiles).one(pubkey) // Readable<Maybe<Profile>>
profile.subscribe($profile => console.log($profile?.name))
// 4. Publish optimistically. The event is written to the local repository
// immediately, signed lazily, and progress is reported per-relay.
const thunk = app.use(Thunks).publishToOutbox({
event: makeEvent(NOTE, {content: "hi"}), event: makeEvent(NOTE, {content: "hi"}),
delay: 3000, delay: 3000, // soft-undo window
}) })
// Thunks can be aborted until after `delay`, allowing for soft-undo // Abort before `delay` elapses to undo
thunk.controller.abort() // thunk.abort()
await thunk.waitForCompletion()
// Some commands are included // 5. Tear it all down
const thunk = follow(['p', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322']) app.cleanup()
// Load events as a promise
const events = await load({
relays: Router.get().ForUser().getUrls(),
filters: [{kinds: [NOTE],
}])
// Or use `request` for more fine-grained subscription control
const abortController = new AbortController()
request({
signal: abortController.signal,
relays: Router.get().ForUser().getUrls(),
filters: [{kinds: [NOTE],
onEvent: (event: TrustedEvent) => {
console.log(event)
},
}])
// Close the request
abortController.abort()
``` ```
## Installation ## Installation
```bash ```bash
npm install @welshman/app npm install @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
``` ```
`@welshman/app` has peer dependencies on `svelte` (4 or 5) and the other welshman workspace packages (`@welshman/feeds`, `@welshman/lib`, `@welshman/net`, `@welshman/router`, `@welshman/signer`, `@welshman/store`, `@welshman/util`), plus `@pomade/core` for the optional Pomade signer.
-147
View File
@@ -1,147 +0,0 @@
# Making Requests
Welshman extends Nostr's base subscription model with intelligent caching, repository integration, and configurable behaviors.
## Key Concepts
- **Local Repository**: Events are automatically cached and tracked
- **Cache Intelligence**: Smart decisions about when to use cached data
- **Relay Integration**: Works with the router for optimal relay selection
- **Configurable Behavior**: Control caching and timeouts
## Request and Load
The base functionality for subscription management is implemented in `@welshman/net`. Please refer to [the documentation](/net) for that module for details.
## Indexed Collections and Loaders
Create indexed stores with automatic loading using repository derivations and loader utilities:
```typescript
import {deriveItemsByKey, deriveItems, makeDeriveItem, makeLoadItem, getter} from "@welshman/store"
// Create indexed map from repository
const itemsByKey = deriveItemsByKey({
repository,
filters: [{kinds: [SOME_KIND]}],
eventToItem: event => transformEvent(event),
getKey: item => item.id
})
// Create array view
const items = deriveItems(itemsByKey)
// Create getter for accessing map
const getItemsByKey = getter(itemsByKey)
// Create loader
const loadItem = makeLoadItem(fetchItem, key => getItemsByKey().get(key))
// Create deriver with automatic loading
const deriveItem = makeDeriveItem(itemsByKey, loadItem)
```
### Deriving Events
Query events from the repository using `deriveEventsById` and `deriveEvents`:
```typescript
import {deriveEventsById, deriveEvents} from "@welshman/store"
const noteEventsById = deriveEventsById({repository, filters: [{kinds: [NOTE]}]})
export const notes = deriveEvents(noteEventsById)
```
### Available Collections
Several common collections are built-in and ready for use:
```typescript
// Profiles
profiles profilesByPubkey deriveProfile loadProfile
// Lists
followLists followListsByPubkey deriveFollowList loadFollowList
muteLists muteListsByPubkey deriveMuteList loadMuteList
pinLists pinListsByPubkey derivePinList loadPinList
// Relays
relays relaysByUrl deriveRelay loadRelay
relayLists relayListsByPubkey deriveRelayList loadRelayList
messagingRelayLists messagingRelayListsByPubkey deriveMessagingRelayList loadMessagingRelayList
// Identity
handles handlesByNip05 deriveHandle loadHandle
zappers zappersByLnurl deriveZapper loadZapper
```
### Example - Loading and Displaying Profiles
```typescript
import {get} from 'svelte/store'
import {displayProfile} from '@welshman/util'
import {deriveProfile, deriveProfileDisplay} from '@welshman/app'
// Subscribe to profile changes - this will automatically load the profile in the background
const profile = deriveProfile(pubkey)
// Display with fallback
const name = displayProfile(get(profile), 'unknown')
// Better: use built-in deriveProfileDisplay utility
const name = deriveProfileDisplay(pubkey)
```
### User-Specific Collections
Several modules provide user-specific derived stores that automatically load data for the currently signed-in user:
```typescript
import { userProfile, userFollowList, userMuteList, userPinList } from '@welshman/app'
userProfile.subscribe(profile => {
// Current user's profile data
})
userFollowList.subscribe(follows => {
// Current user's follow list
})
```
### Repository Integration
Events from subscriptions are automatically tracked to their source relay and saved to the repository, unless they are DVM-kind or ephemeral events (which are discarded). WRAP (kind 1059) events are handled separately and only processed when `shouldUnwrap` is set to `true`.
The repository serves as an intelligent cache layer, making subsequent queries for the same data faster.
## Feeds
A high-level feed loader utility is also provided, which combines application state with utilities from `@welshman/net` and `@welshman/feeds`.
```typescript
import {NOTE} from '@welshman/util'
import {makeKindFeed} from '@welshman/feeds'
import {createFeedController} from '@welshman/app'
const abortController = new AbortController()
let done = false
const ctrl = createFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abortController.signal,
onEvent: e => {
console.log(e)
},
onExhausted: () => {
done = true
},
})
// Load some notes
ctrl.load(100)
// Cancel any pending requests
abortController.abort()
```
+157
View File
@@ -0,0 +1,157 @@
# Plugin Architecture
Every feature in `@welshman/app` is a **plugin** — a class constructed with a single `IApp` argument and resolved lazily via `app.use(...)`. All the data-bearing plugins are built on a small set of base classes defined in `plugins/base.ts`. Understanding these three bases and the `Projection` type is enough to read (and extend) the entire library.
```typescript
const profiles = app.use(Profiles) // new Profiles(app), memoized per app
```
## `Projection<T>`
Almost every accessor in the library returns a `Projection<T>` — a value you can read either synchronously or reactively.
```typescript
type Projection<T> = {
get: () => T // synchronous "hot" snapshot
$: Readable<T> // a Svelte readable for subscriptions / $-syntax
}
```
```typescript
const display = app.use(Profiles).display(pubkey)
display.get() // string, right now
display.$ // Readable<string>, for `$display` in a component
```
Helpers:
```typescript
// Wrap a Readable into a Projection (default getter is hot-path aware)
projection<T>($: Readable<T>, get?): Projection<T>
// Derive one Projection from another, preserving both access modes
projectFrom<S, U>(src: Projection<S>, read: ($: S) => U): Projection<U>
```
The default `get` is `getter($)` from `@welshman/store`, which automatically switches between `svelte.get` and a live subscription based on how often it is called — so `.get()` is safe in hot code paths.
## The three base classes
| Base class | Source of truth | Loads from network? | Used for |
|---|---|---|---|
| `MapPlugin<T>` | Its own `Map` | No | Local, non-event data (e.g. relay stats) |
| `LoadableMapPlugin<T>` | Its own `Map` | Yes (HTTP) | Data fetched over HTTP (relay NIP-11 info, NIP-05 handles, zappers) |
| `DerivedPlugin<T>` | The `repository` | Yes (events) | Anything derived from nostr events (profiles, lists, …) |
`DerivedPlugin` is the dominant pattern: it is a live view over the app's event repository, so cached events appear immediately and new ones stream in automatically.
### `MapPlugin<T>`
A reactive, keyed in-memory collection that owns its own `Map`.
```typescript
class MapPlugin<T> {
index: Projection<ItemsByKey<T>> // the whole Map
all: Projection<T[]> // values
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
get(key: string): Maybe<T> // sync read
project<U>(key: string, read: (item: Maybe<T>) => U): Projection<U>
set(key: string, value: T): void
delete(key: string): void
clear(): void
onItem(subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber
}
```
`set`/`delete`/`clear` fire `onItem` subscribers — handy for persisting the collection to storage.
### `LoadableMapPlugin<T>`
A `MapPlugin` that lazily fetches items. Subclasses implement `fetch`; the base adds caching and backoff.
```typescript
abstract class LoadableMapPlugin<T> extends MapPlugin<T> {
abstract fetch(key: string, ...args: any[]): Promise<unknown>
load(key: string, ...args: any[]): Promise<Maybe<T>> // cached + deduped + backoff
forceLoad(key: string, ...args: any[]): Promise<Maybe<T>> // bypass the cache
}
```
Subscribing to `one(key)` triggers a lazy `load`. Caching, in-flight de-duplication, and exponential backoff come from `makeLoadItem` in `@welshman/store` (default staleness window: one hour).
### `DerivedPlugin<T>`
A keyed collection derived from repository events. There is no duplicated map — the repository is the single source of truth.
```typescript
type DerivedPluginOptions<T> = {
filters: Filter[]
eventToItem: (event: TrustedEvent) => MaybeAsync<Maybe<T>>
getKey: (item: T) => string
loadOptions?: MakeLoadItemOptions
}
abstract class DerivedPlugin<T> {
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
load(key: string, ...args: any[]): Promise<Maybe<T>>
forceLoad(key: string, ...args: any[]): Promise<Maybe<T>>
get(key: string): Maybe<T>
project<U>(key: string, read: (item: Maybe<T>) => U): Projection<U>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
}
```
Internally it builds `index` from `app.use(Stores).itemsByKey({filters, eventToItem, getKey})`, a live readable derived over the repository. `eventToItem` may be async — useful when a list has encrypted entries that must be decrypted first.
## Lifecycle of a `DerivedPlugin` read
1. **Read (cached):** `get(key)` (sync) or `one(key)` (reactive) returns whatever already matches in the repository — instantly.
2. **Lazy load:** subscribing to `one(key)` (or calling `load(key)`) triggers `fetch(key)`. Caching skips recently-loaded keys; in-flight calls for the same key collapse; failures back off exponentially.
3. **Decode:** inbound events flow through `eventToItem`. Async decoders resolve and update the index when ready.
4. **Derive:** convenience accessors (`display(...)`, `urls(...)`, …) are `project(key, read)` calls returning a `Projection<U>`.
`forceLoad` bypasses the cache and resolves to the freshly-read item.
## The `Stores` plugin
`app.use(Stores)` is the repository/tracker-bound factory that `DerivedPlugin` builds on. It mostly forwards to `@welshman/store`, injecting the app's `repository` and `tracker`:
- `itemsByKey<T>(opts)` — the live keyed collection used by `DerivedPlugin`
- `events(opts)` / `eventsById(opts)` / `makeEvent(opts)` — derived event stores
- `eventsByIdByUrl(opts)` / `eventsByIdForUrl(opts)` — relay-scoped views (inject the tracker)
- `isDeleted(event)` — reactive deletion status
You rarely call `Stores` directly — the higher-level data plugins are usually what you want — but it is the seam to use when you need a custom repository-derived store wired to the app.
## Writing your own plugin
A plugin is any class with the shape `new (app: IApp) => T`. Extend one of the base classes for a data collection, or write a plain class for behavior:
```typescript
import {DerivedPlugin, Network, type IApp} from "@welshman/app"
import {SOME_KIND, readSomething} from "@welshman/util"
export class Somethings extends DerivedPlugin<ReturnType<typeof readSomething>> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [SOME_KIND]}],
eventToItem: event => readSomething(event),
getKey: item => item.event.pubkey,
})
}
fetch = (pubkey: string, relayHints: string[] = []) =>
this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SOME_KIND]}, relayHints)
}
// usage
const things = app.use(Somethings)
const thing$ = things.one(pubkey) // lazily loads via the outbox model
```
-76
View File
@@ -1,76 +0,0 @@
# Thunks
Thunks provide optimistic updates for event publishing. They immediately update the local repository while handling the actual signing and publishing asynchronously, making the UI feel more responsive.
## Overview
A thunk:
- Updates local state immediately
- Handles event signing in the background using the current session
- Tracks publish status per relay
- Supports soft-undo via abort
- Can be delayed/cancelled
- Tracks successful publishes
## Basic Usage
```typescript
import {publishThunk} from '@welshman/app'
import {createEvent, NOTE} from '@welshman/util'
const publish = async (content: string) => {
// Get optimal relays for publishing
const relays = ctx.app.router
.FromUser()
.getUrls()
// Create and publish thunk
const thunk = await publishThunk({
event: createEvent(NOTE, {content}),
relays,
delay: 3000, // 3s window for abort
})
// Track publish results
thunk.subscribe($thunk => {
for (const [url, result] of Object.entries($thunk.results)) {
console.log(`${url}: ${result.status} - ${result.detail}`)
}
})
// Can abort within delay window
setTimeout(() => {
if (userWantsToCancel) {
thunk.controller.abort()
}
}, 1000)
// Wait for completion
await thunk.complete
}
```
## Built in commands
Several thunk factories are provided for common or more complicated scenarios like updating lists:
- `removeRelay(url: string, mode: RelayMode)`
- `addRelay(url: string, mode: RelayMode)`
- `removeMessagingRelay(url: string)`
- `addMessagingRelay(url: string)`
- `setProfile(profile: Profile)`
- `unfollow(value: string)`
- `follow(tag: string[])`
- `unmute(value: string)`
- `mutePublicly(tag: string[])`
- `mutePrivately(tag: string[])`
- `unpin(value: string)`
- `pin(tag: string[])`
- `sendWrapped({event, recipients, ...options}: SendWrappedOptions)`
- `manageRelay(url: string, request: ManagementRequest)`
- `createRoom(url: string, room: RoomMeta)`
- `deleteRoom(url: string, room: RoomMeta)`
- `editRoom(url: string, room: RoomMeta)`
- `joinRoom(url: string, room: RoomMeta)`
- `leaveRoom(url: string, room: RoomMeta)`
+117
View File
@@ -0,0 +1,117 @@
# Publishing Events
Publishing in `@welshman/app` is **optimistic** and built around *thunks*. A thunk writes the event to the local repository immediately (so the UI updates instantly), signs lazily, optionally gift-wraps (NIP-59) and computes proof-of-work (NIP-13), and reports acceptance/rejection per relay. The signing/publishing can be delayed, giving you a soft-undo window.
Publishing is managed by the `Thunks` plugin: `app.use(Thunks)`.
## Publishing to specific relays
```typescript
import {makeEvent, NOTE} from "@welshman/util"
const thunk = app.use(Thunks).publish({
event: makeEvent(NOTE, {content: "hi"}),
relays: ["wss://relay.example"],
})
```
## Publishing to the outbox
`publishToOutbox` resolves the current user's write relays (via the [Router](./routing)) for you — the usual way to publish your own notes.
```typescript
const thunk = app.use(Thunks).publishToOutbox({
event: makeEvent(NOTE, {content: "hi"}),
delay: 3000, // wait 3s before signing/sending — abortable until then
})
```
## `ThunkOptions`
```typescript
type ThunkOptions = Override<PublishOptions, {
app: IApp // injected for you by Thunks.publish
event: EventTemplate
recipient?: string // present → NIP-59 gift-wrap to this pubkey
delay?: number // ms to wait before signing/sending (soft-undo)
pow?: number // NIP-13 proof-of-work difficulty
}>
```
`publish`/`publishToOutbox` accept these options minus `app` (and minus `relays` for `publishToOutbox`).
## Working with a thunk
A thunk is a Svelte store; subscribe to watch per-relay progress.
```typescript
const thunk = app.use(Thunks).publish({event, relays})
thunk.subscribe(t => console.log(t.results)) // PublishResultsByRelay
// Soft-undo: only effective before `delay` elapses
thunk.abort()
// Inspect status
thunk.getCompleteUrls()
thunk.getIncompleteUrls()
thunk.getFailedUrls()
thunk.isComplete()
thunk.getError() // string | undefined
// Await outcomes
await thunk.waitForCompletion() // resolves when no relay is still pending
await thunk.waitForError() // resolves with the first error string
```
## Optimistic-publish history
The `Thunks` manager keeps a log of all thunks and supports retrying:
```typescript
const thunks = app.use(Thunks)
thunks.history // writable<Thunk[]> — the optimistic publish log
thunks.retry(thunk) // re-publish a (possibly merged) thunk
```
Each thunk is queued (batched) and its event is written to the repository and tracker the moment it is enqueued, so derived stores reflect it before any relay has responded. If a thunk is aborted before sending, its event and wrap are removed from the repository and its history entry is dropped.
## Gift-wrapped messages
There are two ways to publish encrypted, NIP-59 gift-wrapped events.
### A single thunk with a `recipient`
Set `recipient` on a normal thunk. The thunk wraps the rumor with an ephemeral key, registers it with the app's `WrapManager`, and publishes the wrap:
```typescript
app.use(Thunks).publish({
event: rumorTemplate,
relays: theirMessagingRelays,
recipient: theirPubkey,
})
```
### Many recipients via `Wraps`
The `Wraps` plugin publishes one wrap per recipient, resolving each recipient's NIP-17 messaging relays automatically:
```typescript
const merged = await app.use(Wraps).publish({
event: rumorTemplate,
recipients: [pubkeyA, pubkeyB],
})
await merged.waitForCompletion()
```
`Wraps.publish` returns a `MergedThunk` aggregating the per-recipient thunks. Incoming wraps addressed to the current user are unwrapped automatically by the [`appPolicyWraps`](./apppolicies) default policy; wraps that fail to unwrap (or are duplicates) are skipped.
## Proof of work
Set `pow` to a target difficulty (number of leading zero bits). The thunk mines the PoW before signing; for wrapped events the wrap itself is mined.
```typescript
app.use(Thunks).publish({event, relays, pow: 20})
```
-56
View File
@@ -1,56 +0,0 @@
# Router
The Welshman router can be used to enable the `outbox model` in your Nostr application. It handles relay selection for reading, writing, and discovering events while considering relay quality, user preferences, and network conditions.
## Overview
The router provides scenarios for common **Nostr** operations:
- Reading user profiles
- Publishing events
- Following threads
- Handling DMs
- Searching content
Each scenario considers:
- User's relay preferences (NIP-65)
- Event hints in tags
- Relay quality scores
- Fallback policies
- Connection status
## Basic Usage
```typescript
import {routerContext, addMaximalFallbacks, Router} from '@welshman/app'
// Set up global router options
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
// Router can be used directly with options, or via a singleton with global options
const router = Router.get()
// Get relays for reading a profile
const readRelays = router.ForPubkey(pubkey).getUrls()
// Get relays for broadcasting events by the current user
const writeRelays = router.FromUser().getUrls()
// Get relays for a quote
const quoteRelays = Router.get()
.Quote(parentEvent, idOrAddress, relayHints)
.policy(addMaximalFallbacks)
.getUrls()
```
## Router Features
- Smart relay selection based on relay monitoring
- Quality scoring of relays
- Fallback strategies
- Handling of special relay types (.onion, local)
- NIP-65 support
The router is central to efficient nostr operations, ensuring events reach their intended audience while minimizing unnecessary network traffic.
+69
View File
@@ -0,0 +1,69 @@
# Making Requests
The `Network` plugin (`app.use(Network)`) wraps the `@welshman/net` request/publish/negentropy functions, injecting the app's net context (its pool and repository) so you don't have to pass it every time. The `Sync` plugin (`app.use(Sync)`) builds on top for negentropy-aware reconciliation.
## Loading and requesting
```typescript
const net = app.use(Network)
// One-shot load — resolves with matching events
const events = await net.load({
filters: [{kinds: [1], authors: [pubkey]}],
relays: ["wss://relay.example"],
})
// Open a subscription
await net.request({
filters: [{kinds: [1]}],
relays: ["wss://relay.example"],
autoClose: true,
})
```
`net.load` is a shared, batched loader (created with a 50ms delay / 3s timeout). Use `net.makeLoader(options)` if you need a loader with different batching characteristics.
```typescript
publish(options) // publish an event (prefer the Thunks plugin for app publishing)
makeLoader(options) // build a custom batched Loader
```
## The outbox model: `loadUsingOutbox`
`loadUsingOutbox` is the workhorse most data plugins use. Given an author's pubkey, it resolves that author's NIP-65 **write** relays, routes them (with minimal fallbacks, capped at 8), queries them a couple at a time, and resolves with the most recent matching event as soon as any relay responds.
```typescript
const latestProfile = await net.loadUsingOutbox(pubkey, {kinds: [0]})
// With relay hints to try first
const note = await net.loadUsingOutbox(pubkey, {kinds: [1], limit: 1}, ["wss://hint.example"])
```
The filter is always constrained to `authors: [pubkey]`. This is the mechanism behind the lazy loading you get from `app.use(Profiles).one(pubkey)`, `FollowLists`, `MuteLists`, and friends.
## Negentropy sync
`Sync` reconciles the local repository with relays using NIP-77 (negentropy) where available, and falls back to plain request/publish where it isn't (detected via `app.use(Relays).hasNegentropy(url)`).
```typescript
type AppSyncOpts = {relays: string[]; filters: Filter[]}
const sync = app.use(Sync)
// Pull missing events from relays into the local repository
await sync.pull({relays, filters: [{kinds: [3], authors: [pubkey]}]})
// Push local events up to relays
await sync.push({relays, filters: [{authors: [pubkey]}]})
// Query the local repository (sorts unless any filter has a limit)
const local = sync.query([{kinds: [1]}])
```
`pull` and `push` operate per relay: if the relay supports negentropy they use efficient set-reconciliation (`net.pull`/`net.push`); otherwise they fall back to a normal request (pull) or publishing each event individually (push). Low-level negentropy primitives are also exposed directly on `Network`:
```typescript
net.diff(options) // compute a NIP-77 set difference
net.pull(options) // negentropy pull
net.push(options) // negentropy push
```
+79
View File
@@ -0,0 +1,79 @@
# Routing & Tags
## The Router
`app.use(Router)` is a per-app `Router` (from `@welshman/router`) wired to this app's data. It is the single source for relay selection — there is no global `Router.get()` anymore; one router belongs to each app.
The app wires it up with:
- **user pubkey** from `app.user`
- **read/write relays** per pubkey from [`RelayLists`](./data#relay-lists)
- **relay quality** from [`RelayStats`](#relay-quality)
- **default / indexer / search relays** from [`AppConfig`](./appappconfig)
### Relay-selection scenes
The router exposes composable "scenes" (inherited from the base router) that resolve to a relay set:
```typescript
const router = app.use(Router)
router.FromUser() // the current user's relays
router.FromPubkey(pubkey) // another user's relays
router.FromRelays(urls) // explicit relays
router.Event(event) // relays where an event is likely found
router.EventRoots(event) // relays for an event's thread roots
router.Search() // search relays
```
Scenes are chainable and terminate in `getUrls()` / `getUrl()`:
```typescript
import {addMinimalFallbacks} from "@welshman/router"
const relays = router.FromUser().policy(addMinimalFallbacks).limit(8).getUrls()
const hint = router.Event(event).getUrl()
```
## Relay quality
`app.use(RelayStats)` collects per-relay connection statistics (open/close/publish/request/event counts, timestamps, recent errors) and exposes a quality score the router uses to rank relays.
```typescript
const stats = app.use(RelayStats)
stats.one(url) // Readable<Maybe<RelayStatsItem>>
stats.getQuality(url) // number in [0, 1] — 0 for blocked/error-prone relays
```
Stats are populated automatically by the [`appPolicyRelayStats`](./apppolicies) default policy. `getQuality` returns `0` for non-relay URLs, relays in the user's [blocked list](./data#specialized-relay-lists), or error-prone relays, and higher scores for relays that are connected or have been seen before.
## Tag utilities
`app.use(Tags)` builds nostr tags using the router for relay hints, `Profiles` for display names, and the current user to avoid self-tagging.
```typescript
const tags = app.use(Tags)
tags.tagPubkey(pubkey) // ["p", pubkey, hint, name]
tags.tagEvent(event, url?, mark?) // [["e", id, hint, mark, pubkey], ("a", ...)? ]
tags.tagEventPubkeys(event) // de-duped p-tags (author + mentions, minus self)
tags.tagZapSplit(pubkey, split?) // ["zap", pubkey, hint, split]
tags.tagEventForReply(event, relay?) // reply tag set (root/reply e/a + p tags)
tags.tagEventForComment(event, relay?) // NIP-22 comment tags (K/E/A/I/P + k/p/e)
tags.tagEventForQuote(event, relay?) // ["q", id, hint, pubkey]
tags.tagEventForReaction(event, relay?) // p, ["k", kind], ["e", id, hint], ("a", ...)?
```
A typical reply:
```typescript
import {makeEvent, NOTE} from "@welshman/util"
const replyTags = app.use(Tags).tagEventForReply(parentEvent)
app.use(Thunks).publishToOutbox({
event: makeEvent(NOTE, {content: "well said", tags: replyTags}),
})
```
-210
View File
@@ -1,210 +0,0 @@
# Session Management
The session system provides a unified way to handle different authentication methods:
- NIP-01 via Secret Key
- NIP-07 via Browser Extension
- NIP-46 via Bunker URL or Nostrconnect
- NIP-55 via Android Signer Application
- Read-only pubkey login
## Overview
Sessions are stored in local storage and can be:
- Persisted across page reloads
- Used with multiple accounts
- Switched dynamically
- Backed by different signing methods
## NIP 01 Example
The simplest type of login is NIP 01, although it's generally a bad idea to be handling user keys. NIP 46, 44, or 07 login are preferable. However, NIP 01 can be useful for supporting signup, local profiles, or ephemeral keys.
```typescript
import {makeSecret} from '@welshman/util'
import {loginWithNip01} from '@welshman/app'
loginWithNip01(makeSecret())
```
## NIP 07 Example
A simple way to sign in for desktop browser users is using [NIP 07](https://github.com/nostr-protocol/nips/blob/master/07.md). This method is easy to implement, but should be used sparingly, since not all users will be using a browser with a nostr signing extension installed.
```typescript
import {Nip07Signer} from '@welshman/signer'
import {loginWithNip07} from '@welshman/app'
const signer = new Nip07Signer()
signer.getPubkey().then(pubkey => {
if (pubkey) {
loginWithNip07(pubkey)
} else {
// User extension does not exist or did not respond
}
})
```
## NIP-46 Authentication
The best default signing scheme is [NIP 46](https://github.com/nostr-protocol/nips/blob/master/46.md), AKA "Nostr Connect". This supports multiple handshakes depending on desired UX, and can support advanced use cases like secure enclaves, self-hosted keys, and FROST multisig.
The simpler `bunker://` handshake is done by asking the user to provide a bunker URL, either by QR code, or by pasting it manually into your application.
```typescript
import {makeSecret} from "@welshman/util"
import {Nip46Broker} from "@welshman/signer"
import {loginWithNip46, nip46Perms} from "@welshman/app"
import {isKeyValid} from "src/util/nostr"
// Make a client secret - this is distinct from the user's private key, and is used
// for communicating securely with the remote signer
const clientSecret = makeSecret()
// Ask the user to input their bunker URL
const bunkerUrl = prompt("Please enter your bunker url")
// Pase the bunker url
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunkerUrl)
if (!isKeyValid(signerPubkey)) {
alert("Sorry, but that's an invalid public key.")
} else if (relays.length === 0) {
alert("That connection string doesn't have any relays.")
} else {
// Open up a connection with the signer
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
// Send a connect request with the default permissions
const result = await broker.connect(connectSecret, nip46Perms)
// Make sure to check the connect secret to prevent hijacking
if (result === connectSecret) {
// Get the user's public key
const pubkey = await broker.getPublicKey()
if (!pubkey) {
alert("Failed to initialize session")
} else {
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
}
}
}
```
Alternatively, you can provide the user with a `nostrconnect://` URL which they can copy or scan with their signer. This is a better UX for users using a signer on their mobile phone.
```typescript
import {makeSecret} from "@welshman/util"
import {Nip46Broker} from "@welshman/signer"
import {loginWithNip46, nip46Perms} from "@welshman/app"
// Create a client secret
const clientSecret = makeSecret()
// Stop listening if the user cancels login
const abortController = new AbortController()
// Customize to use relays the signer can send responses to
const relays = ['wss://relay.nsec.app/']
// Create a broker
const broker = Nip46Broker.get({clientSecret, relays})
// Create a nostrconnect:// url
const nostrconnect = await broker.makeNostrconnectUrl({
name: "My App",
url: window.origin,
image: window.origin + '/logo.png',
perms: nip46Perms,
})
// Share it with the user. Displaying a QR code is particularly helpful
alert("To connect, paste this URL into your signer: " + nostrconnect)
// Listen for the response
let response
try {
response = await broker.waitForNostrconnect(nostrconnect, abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
alert(`Received error from signer: ${errorResponse.error}`)
} else if (errorResponse) {
console.error(errorResponse)
}
}
// If we got a response, the broker is already connected and we can log in
if (response) {
const pubkey = await broker.getPublicKey()
if (!pubkey) {
alert("Failed to initialize session")
} else {
loginWithNip46(pubkey, clientSecret, response.event.pubkey, relays)
}
}
```
## NIP-55 Authentication
For the best UX on Android, use [NIP 55](https://github.com/nostr-protocol/nips/blob/master/55.md). Note that this only works for web applications that have been compiled to native Android applications using [CapacitorJS](https://capacitorjs.com/) and [nostr-signer-capacitor-plugin](https://github.com/chebizarro/nostr-signer-capacitor-plugin).
```typescript
import {getNip55, Nip55Signer, loginWithNip55} from "@welshman/signer"
// Query for installed apps that implement nip 55 signing
getNip55().then(signerApps => {
// We'll choose the first one and auto-login, but in most cases you'll want to offer a choice
if (signerApps.length > 0) {
const signer = new Nip55Signer(signerApps[0].packageName)
const pubkey = await signer.getPubkey()
if (pubkey) {
loginWithNip55(pubkey, app.packageName)
}
}
})
```
## Read-only session
A fun feature of nostr is that you can log in as other people, and see what nostr is like from their perspective (minus encrypted data or course).
```typescript
import {loginWithPubkey} from "@welshman/app"
// Log in as hodlbod
loginWithPubkey("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
```
## Using the current session
```typescript
import {signer, session} from '@welshman/app'
import {createEvent, NOTE} from '@welshman/util'
// Print the current session - be aware the private key is stored in memory, be very
// careful about how you handle session objects!
console.log(session.get())
// Current session's signer is always ready to use
const event = await signer.get().sign(
createEvent(NOTE, {content: "Hello Nostr!"})
)
// hodlbod's pubkey
const otherPubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
// Encrypt content for private notes
const ciphertext = await signer.get().nip44.encrypt(otherPubkey, "Secret message")
// Decrypt automatically detects encryption version
const plaintext = await decrypt(signer, otherPubkey, ciphertext)
```
## Multiple sessions
It's possible to support multiple concurrent sessions by simply calling `addSession` multiple times. This will update `sessions`, and set `pubkey` to the most recently added session. You can then switch between sessions by calling `pubkey.set` with a valid session pubkey, and delete sessions using `dropSession(pubkey)`.
-46
View File
@@ -1,46 +0,0 @@
# Tag Utilities
The tag utilities provide helper functions for creating properly formatted Nostr event tags with correct relay hints and metadata.
These are especially useful when creating events that reference other events or users.
## Tag Creators
### Pubkey Tags
```typescript
import {tagPubkey} from '@welshman/app'
// Create a p-tag with relay hint and profile name
const tag = tagPubkey(authorPubkey)
// => ["p", pubkey, "wss://relay.example.com", "username"]
```
### Event Reference Tags
```typescript
import {
tagEvent, // Basic event reference
tagEventForQuote, // For quoting events
tagEventForReply, // For reply threads
tagEventForComment, // For NIP-22 comments
tagEventForReaction // For reactions
} from '@welshman/app'
// Real world example: Creating a reply
const createReply = async (parent: TrustedEvent, content: string) => {
// Get proper tags for a reply, including:
// - All referenced pubkeys
// - Root/reply markers
// - Inherited mentions
// - Relay hints
const tags = tagEventForReply(parent)
return publishThunk({
// Use relay hints from tags
relays: Router.get().PublishEvent(event).getUrls()
event: await signer.get().sign(createEvent(NOTE, {content, tags})),
})
}
```
+105 -72
View File
@@ -1,100 +1,133 @@
# User Data Loading # User & Sessions
The User Data module provides utilities for loading and managing user-specific data like profiles, follows, mutes, pins, and relay selections. It includes both reactive stores and manual loading functions. An `App` is centered on at most one identity, represented by a `User`. Login state that needs to be persisted is represented separately as a serializable `Session`. The two are connected by session handlers, which know how to turn a serialized session back into a live signer.
## User Data Stores ## `User`
These reactive stores automatically load and cache user data: A `User` is a single identity: a `pubkey` plus the `signer` that proves ownership of it.
```typescript ```typescript
// User profile class User {
export const userProfile: Store<Profile | undefined> constructor(readonly pubkey: string, readonly signer: ISigner)
// User follows list static fromSigner(signer: ISigner): Promise<User>
export const userFollowList: Store<List | undefined> static fromSession(session: Session): Promise<User | undefined>
static require(app: IApp): User
// User mutes list sign(event: StampedEvent): Promise<SignedEvent>
export const userMuteList: Store<List | undefined> nip44EncryptToSelf(payload: string): Promise<string>
}
// User pins list
export const userPinList: Store<List | undefined>
// User relay selections
export const userRelayList: Store<List | undefined>
// User messaging relay selections
export const userMessagingRelayList: Store<List | undefined>
// User blossom servers
export const userBlossomServerList: Store<List | undefined>
``` ```
## Manual Loading Functions ### Constructing a user
These functions load user data for the currently signed-in user with optional relay hints: - **`User.fromSigner(signer)`** — wraps `signer` in a [`LoggingSigner`](./applogging) (unless it already is one), derives the pubkey via `signer.getPubkey()`, and returns the `User`.
- **`User.fromSession(session)`** — resolves a signer from a serialized session (via the [handler registry](#session-handlers)) and returns the `User`, or `undefined` if no handler is registered for the session's method.
```typescript ```typescript
// Load user profile import {User} from "@welshman/app"
function loadUserProfile(relays?: string[]): Promise<void> import {getNip07} from "@welshman/signer"
// Load user follows const user = await User.fromSigner(getNip07())
function loadUserFollowList(relays?: string[]): Promise<void>
// Load user mutes
function loadUserMuteList(relays?: string[]): Promise<void>
// Load user pins
function loadUserPinList(relays?: string[]): Promise<void>
// Load user relay selections
function loadUserRelayList(relays?: string[]): Promise<void>
// Load user messaging relay selections
function loadUserMessagingRelayList(relays?: string[]): Promise<void>
// Load user blossom servers
function loadUserBlossomServerList(relays?: string[]): Promise<void>
``` ```
Force-reload variants bypass the cache and always fetch fresh data: ### Gating user-only actions
`User.require(app)` returns `app.user`, throwing `"This action requires a signed-in user"` if there is none. Plugins use this internally before signing or encrypting; you can use it the same way.
```typescript ```typescript
function forceLoadUserProfile(relays?: string[]): Promise<void> const user = User.require(app)
function forceLoadUserFollowList(relays?: string[]): Promise<void> const signed = await user.sign(stampedEvent)
function forceLoadUserMuteList(relays?: string[]): Promise<void>
function forceLoadUserPinList(relays?: string[]): Promise<void>
function forceLoadUserRelayList(relays?: string[]): Promise<void>
function forceLoadUserMessagingRelayList(relays?: string[]): Promise<void>
function forceLoadUserBlossomServerList(relays?: string[]): Promise<void>
``` ```
## Usage Examples ### Signing & self-encryption
- **`sign(event)`** delegates to the signer.
- **`nip44EncryptToSelf(payload)`** encrypts a payload to your own pubkey via NIP-44 — used for private list entries (mutes, follows) that only you should read.
## `Session`
A `Session` is a serializable login descriptor. It contains only data — never a live signer object — so it can be stored in `localStorage`, IndexedDB, or anywhere else, and rehydrated later.
### Using Reactive Stores
```typescript ```typescript
import { userProfile, userFollowList } from '@welshman/app' type Session<M extends string = string, D = unknown> = {method: M; data: D}
```
// Subscribe to user profile changes ### Building sessions
userProfile.subscribe(profile => {
if (profile) { Build a typed session from a handler with `toSession`:
console.log('User profile:', profile)
} ```typescript
toSession<M, D>(handler: SessionHandler<M, D>, data: D): Session<M, D>
```
```typescript
import {toSession, nip01, nip07, nip46} from "@welshman/app"
const a = toSession(nip01, {secret: "<hex secret>"})
const b = toSession(nip07, {})
const c = toSession(nip46, {clientSecret, signerPubkey, relays})
```
## Session handlers
A `SessionHandler` maps a session's `data` back to an `ISigner`:
```typescript
type SessionHandler<M extends string, D> = {
method: M
getSigner: (data: D) => MaybeAsync<ISigner>
}
```
### Built-in handlers
These are registered automatically when the package loads:
| Handler | `method` | `data` shape | Signer |
|---|---|---|---|
| `nip01` | `"nip01"` | `{secret: string}` | `Nip01Signer` |
| `nip07` | `"nip07"` | `{}` | `Nip07Signer` (browser extension) |
| `nip46` | `"nip46"` | `{clientSecret, signerPubkey, relays}` | `Nip46Signer` (remote signer / bunker) |
| `nip55` | `"nip55"` | `{pubkey, signer}` | `Nip55Signer` (Android signer app) |
| `pomade` | `"pomade"` | `{clientOptions, email}` | `PomadeSigner` |
### Registering custom handlers
Define a handler with `defineSessionHandler` (it infers `M`/`D` so `getSigner` is type-checked against the data shape), then register it:
```typescript
import {defineSessionHandler, registerSessionHandler, unregisterSessionHandler} from "@welshman/app"
const myHandler = defineSessionHandler({
method: "my-method",
getSigner: (data: {token: string}) => new MyCustomSigner(data.token),
}) })
// Get current follows list registerSessionHandler(myHandler)
const follows = userFollowList.get() // later: unregisterSessionHandler(myHandler)
``` ```
### Manual Loading ### Resolving signers directly
```typescript ```typescript
import { loadUserMuteList, forceLoadUserRelayList } from '@welshman/app' getSignerFromSession(session: Session): MaybeAsync<ISigner> | undefined
```
// Load user mutes from specific relays
await loadUserMuteList(['wss://relay1.com', 'wss://relay2.com']) Returns the signer for a session, or `undefined` if no handler is registered for its method. `User.fromSession` is a thin wrapper over this.
// Force refresh user relay selections ## A complete login flow
await forceLoadUserRelayList([])
```typescript
// Load from default relays import {createApp, User, toSession, nip07} from "@welshman/app"
await loadUserProfile() import {getNip07} from "@welshman/signer"
// On login: build a serializable session and persist it
const session = toSession(nip07, {})
localStorage.setItem("session", JSON.stringify(session))
// On startup: rehydrate the user and create the app
const stored = JSON.parse(localStorage.getItem("session"))
const user = await User.fromSession(stored) // User | undefined
const app = createApp({user})
``` ```
+35 -43
View File
@@ -1,63 +1,55 @@
# Web of Trust (WOT) # Web of Trust
Welshman provides utilities for implementing a Web of Trust system within Nostr applications. This system analyzes social connections (follows and mutes) to build a reputation graph that can be used for content filtering, user scoring, and discovery. `app.use(Wot)` computes a web-of-trust graph from follow ([`FollowLists`](./data#follows)) and mute ([`MuteLists`](./data#mutes)) lists, rooted at the current user. When there is no signed-in user, the graph is built from the union of all known follow lists. All computations are throttled (1s) to stay cheap under churn.
## Core Concepts The score for a pubkey is the number of roots that follow it minus the number that mute it.
- **Follow Trust**: Users gain positive reputation when followed by those in your network ## Aggregate projections
- **Mute Distrust**: Users lose reputation when muted by those in your network
- **WOT Graph**: A reactive weighted directed graph representing trust relationships
- **Contextual Scoring**: Reputation scores that adapt based on user's social graph
## API Reference Each returns a [`Projection`](./plugins#projection-t) (`.get()` / `.$`):
### Social Graph Navigation
```typescript ```typescript
// Get users followed by a specific pubkey const wot = app.use(Wot)
getFollows(pubkey: string): string[]
// Get users who have muted a specific pubkey wot.graph // Projection<Map<string, number>> — score per pubkey
getMutes(pubkey: string): string[] wot.max // Projection<number | undefined> — highest score in the graph
wot.followersByPubkey // Projection<Map<string, Set<string>>>
// Get followers of a specific pubkey wot.mutersByPubkey // Projection<Map<string, Set<string>>>
getFollowers(pubkey: string): string[]
// Get users who have muted a specific pubkey
getMuters(pubkey: string): string[]
// Get the extended network (follows-of-follows) for a pubkey
getNetwork(pubkey: string): string[]
``` ```
### Trust Analysis ## Per-pubkey queries
```typescript ```typescript
// Get follows of a user who also follow a target wot.follows(pubkey) // Projection<string[]> — who pubkey follows
getFollowsWhoFollow(pubkey: string, target: string): string[] wot.mutes(pubkey) // Projection<string[]> — who pubkey mutes
wot.followers(pubkey) // Projection<string[]> — who follows pubkey
wot.muters(pubkey) // Projection<string[]> — who mutes pubkey
wot.network(pubkey) // Projection<string[]> — follows-of-follows (minus direct follows)
// Get follows of a user who have muted a target wot.followsWhoFollow(pubkey, target) // Projection<string[]>
getFollowsWhoMute(pubkey: string, target: string): string[] wot.followsWhoMute(pubkey, target) // Projection<string[]>
wot.wotScore(pubkey, target) // Projection<number>
// Calculate trust score between users
getWotScore(pubkey: string, target: string): number
``` ```
### Reactive Stores `wotScore(pubkey, target)`:
- With a `pubkey`: `(pubkey's follows who follow target) (pubkey's follows who mute target)`.
- Without a `pubkey`: `followers(target).length muters(target).length`.
## Examples
```typescript ```typescript
// Map of follower lists by pubkey const wot = app.use(Wot)
followersByPubkey: Readable<Map<string, Set<string>>>
// Map of muter lists by pubkey // Sort a list of pubkeys by trust, descending
mutersByPubkey: Readable<Map<string, Set<string>>> const graph = wot.graph.get()
const sorted = [...pubkeys].sort((a, b) => (graph.get(b) ?? 0) - (graph.get(a) ?? 0))
// The full WOT graph with scores (pubkey → score) // Reactive trust score between me and someone else
wotGraph: Writable<Map<string, number>> const score$ = wot.wotScore(myPubkey, theirPubkey).$
// The maximum WOT score in the graph // Discover the extended network for a "follows of follows" feed
maxWot: Readable<number> const network = wot.network(myPubkey).get()
// Derive the WOT score for a specific user
deriveUserWotScore(targetPubkey: string): Readable<number>
``` ```
The WoT graph also feeds [profile search](./feeds-and-search#search) ranking and the `Scope`/WoT-range pubkey resolution used by [feeds](./feeds-and-search#feeds).
+178
View File
@@ -0,0 +1,178 @@
# Content
A grab-bag of content kinds: NIP-22 comments, NIP-7D forum threads, NIP-99 classifieds, NIP-52 calendar events, NIP-88 polls, and NIP-56 reports. Each is a plain `EventReader` / `EventBuilder` pair — see [Readers & Builders](./readers-and-builders) for the base pattern. The parameterized-replaceable kinds (`Classified`, `TimeEvent`) need a `d` tag (`setIdentifier()`).
## Comment (kind 1111)
NIP-22 comments distinguish the **thread root** (uppercase `E`/`A`/`K`/`P` tags) from the **immediate parent** (lowercase `e`/`a`/`k`/`p`). Both are read as a `CommentRef` (`{id?, address?, kind?, pubkey?}`).
```typescript
import {Comment, CommentBuilder} from "@welshman/domain"
import type {CommentRef} from "@welshman/domain"
const comment = await Comment.fromEvent(event)
comment.root() // CommentRef — uppercase tags (thread root)
comment.parent() // CommentRef — lowercase tags (immediate parent)
// Set refs explicitly...
const template = await new CommentBuilder()
.setContent("nice thread")
.setRoot(rootKind, rootId, rootPubkey)
.setParent(parentKind, parentId, parentPubkey)
.toTemplate()
// ...or derive them from events (uses the event's d tag as identifier)
await new CommentBuilder()
.setContent("reply")
.setRootFromEvent(rootEvent)
.setParentFromEvent(parentEvent)
.toEvent(signer)
```
::: warning Identifier quirk
When you pass an `identifier` to `setRoot`/`setParent`, the builder pushes an `A`/`a` tag whose value is the `id` you passed — not a constructed `kind:pubkey:identifier` address. Be aware of this if you read those tags back expecting a full address.
:::
## Thread (kind 11)
A NIP-7D forum thread root. Just a title plus the body content.
```typescript
import {Thread, ThreadBuilder} from "@welshman/domain"
const thread = await Thread.fromEvent(event)
thread.title() // "title" tag value
await new ThreadBuilder()
.setTitle("Welcome")
.setContent("Read the rules first.")
.toEvent(signer)
```
## Classified (kind 30402)
A NIP-99 marketplace listing. The price parses into a `ClassifiedPrice` (`{amount, currency, frequency}`), defaulting currency to `SAT`.
```typescript
import {Classified, ClassifiedBuilder} from "@welshman/domain"
import type {ClassifiedPrice} from "@welshman/domain"
const listing = await Classified.fromEvent(event)
listing.title() // "title" tag value
listing.summary() // "summary" tag value
listing.price() // ClassifiedPrice | undefined
listing.status() // "status" tag value
listing.images() // "image" tag values
listing.topics() // t-tag values
await new ClassifiedBuilder()
.setIdentifier() // required d tag for kind 30402
.setTitle("Bike for sale")
.setSummary("lightly used")
.setPrice(150, "USD", "") // amount, currency = "SAT", frequency = ""
.setStatus("active")
.setImages(["https://example.com/bike.jpg"])
.setTopics(["bikes", "forsale"])
.toEvent(signer)
```
## TimeEvent (kind 31923)
A NIP-52 time-based calendar event.
```typescript
import {TimeEvent, TimeEventBuilder} from "@welshman/domain"
const evt = await TimeEvent.fromEvent(event)
evt.title() // "title" tag value
evt.location() // "location" tag value
evt.start() // unix seconds as int, or undefined
evt.end() // unix seconds as int, or undefined
await new TimeEventBuilder()
.setIdentifier() // required d tag for kind 31923
.setTitle("Nostrica")
.setLocation("Costa Rica")
.setStart(startTs)
.setEnd(endTs)
.toEvent(signer)
```
When both `start` and `end` are set, `buildTags` auto-generates one `["D", dayIndex]` tag per day in `[start, end)` (day-bucket index tags), so the event is discoverable by day.
## Poll (kind 1068) and PollResponse (kind 1018)
NIP-88 polls. A `Poll` has a title (content), options, a type, and an optional close time. Types are exported as `PollType` (`"singlechoice" | "multiplechoice"`), options as `PollOption`, and tallies as `PollResult`.
```typescript
import {Poll, PollBuilder} from "@welshman/domain"
import type {PollType, PollOption, PollResult} from "@welshman/domain"
const poll = await Poll.fromEvent(event)
poll.title() // event.content (or "")
poll.options() // PollOption[] — {id, label}
poll.pollType() // PollType, default "singlechoice"
poll.endsAt() // unix seconds | undefined
poll.isClosed() // boolean (endsAt <= now)
poll.urls() // "relay" tag values
await new PollBuilder()
.setTitle("Favorite client?")
.addOption("Coracle") // id defaults to a random id
.addOption("Flotilla")
.setPollType("singlechoice")
.setEndsAt(closeTs)
.toEvent(signer)
```
`validate()` requires at least one option. To tally votes, pass the response events to `results`:
```typescript
const result: PollResult = poll.results(responseEvents)
result.options // [{id, label, votes}, …]
result.voters // number of distinct voters
```
`results` keeps only each pubkey's latest response, takes the first selection for single-choice polls, and the unique selections for multiple-choice.
A `PollResponse` is one voter's answer:
```typescript
import {PollResponse, PollResponseBuilder} from "@welshman/domain"
const response = await PollResponse.fromEvent(event)
response.pollId() // e-tag value
response.selections() // unique "response" tag values
await new PollResponseBuilder()
.setPollId(pollId)
.addSelection(optionId) // deduped
.toEvent(signer)
```
`PollResponse.validate()` requires a pollId.
## Report (kind 1984)
A NIP-56 report flags a pubkey and/or an event with a reason.
```typescript
import {Report, ReportBuilder} from "@welshman/domain"
const report = await Report.fromEvent(event)
report.reportedPubkey() // p-tag value
report.eventId() // e-tag value (tag[1])
report.reason() // e-tag reason (tag[2])
await new ReportBuilder()
.setReportedPubkey(pubkey)
.setEventId(noteId)
.setReason("spam")
.toEvent(signer)
```
`buildTags` emits `["p", pubkey]` and `["e", id, reason?]`.
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern, including `d`-tag validation for `Classified` and `TimeEvent`.
+59
View File
@@ -0,0 +1,59 @@
# Handlers
NIP-89 lets clients advertise which event kinds they can handle, and lets users recommend handlers to each other. `@welshman/domain` models both sides: `Handler` (the handler's own information) and `HandlerRecommendation` (a user pointing at a handler). Both are parameterized-replaceable, so their builders need a `d` tag (`setIdentifier()`). See [Readers & Builders](./readers-and-builders) for the base pattern.
## Handler information (kind 31990)
`Handler` carries a JSON metadata blob (name, about, picture, …) in its content plus the list of kinds it handles as `k` tags. The metadata shape is exported as `HandlerMeta`.
```typescript
import {Handler, HandlerBuilder} from "@welshman/domain"
import type {HandlerMeta} from "@welshman/domain"
const handler = await Handler.fromEvent(event)
handler.name() // string | undefined
handler.about() // string | undefined
handler.picture() // string | undefined
handler.website() // string | undefined
handler.lud16() // string | undefined
handler.nip05() // string | undefined
handler.kinds() // number[] — the k tags, as numbers
handler.values // the raw decoded HandlerMeta object
```
The builder seeds metadata from the reader and lifts the `k` tags into its own field. Setters mirror the getters; `setKinds` takes an array of kind numbers.
```typescript
const template = await new HandlerBuilder()
.setIdentifier() // required d tag for kind 31990
.setName("My Client")
.setAbout("a great nostr app")
.setKinds([1, 30023]) // writes ["k", "1"], ["k", "30023"]
.toTemplate()
```
Available setters: `setName`, `setAbout`, `setPicture`, `setWebsite`, `setLud16`, `setNip05`, `setKinds(kinds)`. `buildContent` re-serializes `values` to JSON; `buildTags` emits the kind tags.
## Handler recommendation (kind 31989)
`HandlerRecommendation` is a list of `a` tags pointing at handler events, optionally annotated with a relay hint and a platform marker (e.g. `"web"`).
```typescript
import {HandlerRecommendation, HandlerRecommendationBuilder} from "@welshman/domain"
const rec = await HandlerRecommendation.fromEvent(event)
rec.addressTags() // raw a-tags, e.g. [["a", "31990:pk:d", "wss://…", "web"]]
rec.addresses() // just the address values
rec.handlerAddress() // prefers the a-tag whose last element is "web", else the first → tag[1]
const template = await new HandlerRecommendationBuilder()
.setIdentifier() // required d tag for kind 31989
.addRecommendation("31990:pubkey:d", "wss://relay.example", "web")
.toTemplate()
```
`addRecommendation(address, relay?, platform?)` writes `["a", address, relay || "", platform || ""]` and is deduped by address. `removeRecommendation(address)` filters the address tags.
## See also
- [Readers & Builders](./readers-and-builders) — the base pattern, including `d`-tag validation for these parameterized-replaceable kinds.
+88
View File
@@ -0,0 +1,88 @@
# @welshman/domain
[![version](https://badgen.net/npm/v/@welshman/domain)](https://npmjs.com/package/@welshman/domain)
Stateless utilities for translating nostr events to and from domain objects. Where `@welshman/util` gives you the raw building blocks — events, tags, kind constants, tag getters — `@welshman/domain` gives you a typed, ergonomic object per kind: a `Profile` you can ask `.name()`, a `FollowList` you can ask `.pubkeys()`, a `ZapReceipt` you can `.verify()`. Each of those comes with a matching builder that turns edits back into a signable event template.
## The core idea: Readers and Builders
Every supported kind is modeled by a pair of classes:
- A **Reader** — a read-only view over a single `TrustedEvent`. You construct it from an event, and it decodes the content/tags into convenient getters (`profile.name()`, `list.pubkeys()`, `zap.amount()`). Readers are stateless: they hold the event and answer questions about it.
- A **Builder** — a mutable, chainable producer of an `EventTemplate`. You construct it empty (to author a new event) or from a Reader (to edit an existing one), apply setters, and finish with `toTemplate()` / `toRumor()` / `toEvent()`.
```typescript
import {Profile, ProfileBuilder} from "@welshman/domain"
// Read an event into a domain object
const profile = await Profile.fromEvent(event)
profile.name() // string | undefined
profile.display() // best-effort display name, falls back to a short npub
// Build a new event template
const template = await new ProfileBuilder()
.setName("alice")
.setAbout("hello nostr")
.toTemplate() // EventTemplate {kind, content, tags}
```
Readers and Builders are two halves of a round-trip. `reader.builder()` returns the matching builder pre-populated from the reader, so editing is just "read, mutate, rebuild":
```typescript
const next = await profile.builder().setName("alice2").toTemplate()
```
## Where it sits
`@welshman/domain` lives between `@welshman/util` and `@welshman/app`.
- It depends on `@welshman/util` for the primitives it wraps — kind constants (`PROFILE`, `FOLLOWS`, `RELAYS`, …), tag getters (`getTagValue`, `getPubkeyTagValues`, …), `Address`, `stamp`/`prep`, and the `TrustedEvent`/`EventTemplate` types. It depends on `@welshman/signer` for the `ISigner` interface (used to sign, and to decrypt/encrypt private list tags).
- `@welshman/app` is built on top of it. The reactive data plugins (`Profiles`, `FollowLists`, `MuteLists`, `RelayLists`, …) decode repository events into exactly these Reader objects and expose the Builders' setters as collection methods. If you have used `app.use(Profiles).one(pk)` and gotten a `Profile` back, that `Profile` is this package's `Profile`.
This means `@welshman/domain` is the right layer to reach for when you are working with events directly — parsing or constructing them — without the app's reactivity, networking, or repository. It has no module-level state and no side effects.
## Installation
```bash
npm install @welshman/domain
# or
pnpm add @welshman/domain
yarn add @welshman/domain
```
Peer dependencies: the welshman workspace packages it builds on (`@welshman/lib`, `@welshman/util`, `@welshman/signer`, and `@welshman/feeds` for the saved-feed kind), plus `nostr-tools`.
## A larger example
```typescript
import {FollowList, FollowListBuilder} from "@welshman/domain"
// Read — pass a signer to unlock private (encrypted) tags on lists.
// Without the author's own signer, only public tags are visible.
const list = await FollowList.fromEvent(event, signer)
list.pubkeys() // string[] of followed pubkeys
list.includes(somePubkey) // boolean
// Edit and sign in one chain. buildContent encrypts private tags
// (NIP-44, self-encrypted to the author) when there are any.
const signed = await list.builder()
.addFollow(["p", newPubkey])
.toEvent(signer) // SignedEvent
// Or start fresh
const template = await new FollowListBuilder()
.addFollow(["p", pubkeyA])
.addFollow(["p", pubkeyB])
.toTemplate()
```
## Pages
- [Readers & Builders](./readers-and-builders) — the `EventReader`/`EventBuilder` and `ListReader`/`ListBuilder` base classes in depth: construction, async parsing, getters/setters, the build pipeline, validation, extra-tag passthrough, and how list encryption works.
- [Profile](./profile) — kind-0 metadata (`Profile` / `ProfileBuilder`).
- [Lists](./lists) — NIP-51 public/private lists: follows, mutes, pins, bookmarks, relay sets, and friends.
- [Rooms](./rooms) — NIP-29 group rooms: metadata, membership, and the join/leave/create/delete ops.
- [Relay membership](./relay-membership) — Flotilla relay/space membership ops and snapshots.
- [Handlers](./handlers) — NIP-89 handler information and recommendations.
- [Zaps](./zaps) — NIP-57/NIP-75 zap requests, receipts, and goals.
- [Content](./content) — comments, threads, classifieds, calendar events, polls, and reports.
+115
View File
@@ -0,0 +1,115 @@
# Lists
NIP-51 lists — follows, mutes, pins, bookmarks, relay lists, and friends — all share one shape: a set of **public** tags and an optional set of **private** tags that are NIP-44-encrypted into the event's content, self-encrypted to the author. Every list kind is a thin subclass of `ListReader` / `ListBuilder`, so once you know the shared pattern you know all of them. The base classes are documented in depth in [Readers & Builders](./readers-and-builders); this page covers the per-kind getters and setters.
## The shared pattern
A `ListReader` exposes its tags as a merged view (`publicTags` + `privateTags`), and every getter reads through that merged view. **Private tags only decrypt when you pass the list author's own signer** to `fromEvent`/`factory`; reading someone else's list, or your own without a signer, yields public tags only.
```typescript
import {MuteList, MuteListBuilder} from "@welshman/domain"
// Read. Pass the author's signer to surface private (encrypted) entries.
const mutes = await MuteList.fromEvent(event, signer)
mutes.pubkeys() // both public and private mutes, when decrypted
mutes.includes(somePk) // boolean
// Build. Public vs private goes to different setters; encryption happens
// in buildContent when there are private tags.
const signed = await new MuteListBuilder()
.mutePublicly(pubkeyA)
.mutePrivately(pubkeyB)
.toEvent(signer) // toEvent/toRumor always pass the signer through
```
Every list builder inherits the base tag mutators from `ListBuilder`:
```typescript
builder
.addPublic(...tags) // append to the public set
.addPrivate(...tags) // append to the private (encrypted) set
.keepPublic(pred) // filter; also keepPrivate, keep (both sets)
.dropPublic(pred) // filter out; also dropPrivate, drop (both sets)
.clearPublic() // empty; also clearPrivate, clear (both sets)
```
The per-kind methods below (`addFollow`, `mutePrivately`, `bookmarkPublicly`, …) are just named wrappers over these. Anything ending in `*Privately` is `addPrivate`; everything else is `addPublic`; `remove*`/`un*` is a `drop`.
::: tip toTemplate and the signer
Because encryption lives in `ListBuilder.buildContent`, `toTemplate(signer)` only needs a signer when you have actually written private tags. With private mutes/follows you must pass one (`A signer is required to encrypt private tags`); a purely public edit needs none. `toEvent`/`toRumor` always pass the signer through for you.
:::
## The kinds
| Class | Kind | NIP | Reader getters | Builder methods |
|---|---|---|---|---|
| `FollowList` | 3 | NIP-02 | `pubkeys()`, `includes(pk)` | `addFollow(tag)`, `removeFollow(value)` |
| `MuteList` | 10000 | NIP-51 | `pubkeys()`, `includes(pk)` | `mutePublicly(pk)`, `mutePrivately(pk)`, `unmute(pk)` |
| `PinList` | 10001 | NIP-51 | `ids()`, `addresses()` | `pinPublicly(tag)`, `pinPrivately(tag)`, `unpin(value)` |
| `RelayList` | 10002 | NIP-65 | `urls()`, `readUrls()`, `writeUrls()` | `addUrl(url, mode)`, `removeUrl(url, mode)`, `setReadUrls(urls)`, `setWriteUrls(urls)`, `setTags(tags)` |
| `BookmarkList` | 10003 | NIP-51 | `ids()`, `addresses()`, `topics()`, `urls()` | `bookmarkPublicly(tag)`, `bookmarkPrivately(tag)`, `removeBookmark(value)` |
| `GroupList` | 10004 | NIP-51 | `addresses()` | `addGroup(address, relayHint?)`, `removeGroup(address)` |
| `BlockedRelayList` | 10006 | NIP-51 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `SearchRelayList` | 10007 | NIP-51 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `RoomList` | 10009 | NIP-51 | `groups()`, `groupTags()` | `join(groupId, url)`, `leave(groupId)` |
| `FeedList` | 10014 | NIP-51 | `addresses()`, `includes(address)` | `addFeed(address, relayHint?)`, `addFeedPrivately(address, relayHint?)`, `removeFeed(address)` |
| `TopicList` | 10015 | NIP-51 | `topics()`, `addresses()`, `includes(topic)` | `followPublicly(topic)`, `followPrivately(topic)`, `follow(topic)`, `unfollow(topic)` |
| `EmojiList` | 10030 | NIP-51 | `emojis()`, `emojiSets()` | `addEmoji(shortcode, url)`, `removeEmoji(value)`, `addEmojiSet(address)`, `removeEmojiSet(value)` |
| `MessagingRelayList` | 10050 | NIP-17 | `urls()` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `BlossomServerList` | 10063 | Blossom BUD-03 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `RelaySet` | 30002 | NIP-51 | `title()`, `description()`, `image()`, `urls()` | `setTitle`, `setDescription`, `setImage`, `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
Each comes with a matching `*Builder` (`FollowListBuilder`, `MuteListBuilder`, …).
## Public vs private (encrypted) tags
A few of these kinds expose a deliberate public/private choice, mapping to the encrypted-content split:
```typescript
import {FollowListBuilder, BookmarkListBuilder, TopicListBuilder} from "@welshman/domain"
// Follows are public-only in practice (addFollow → addPublic)
new FollowListBuilder().addFollow(["p", pubkey])
// Bookmarks, pins, mutes, feeds, and topics offer both:
new BookmarkListBuilder().bookmarkPublicly(["e", noteId]) // visible to everyone
new BookmarkListBuilder().bookmarkPrivately(["e", noteId]) // encrypted, author-only
new TopicListBuilder().followPrivately("nostr") // encrypted interest
```
The relay-config lists (`RelayList`, `BlockedRelayList`, `SearchRelayList`, `MessagingRelayList`, `BlossomServerList`, `RelaySet`) keep everything in public tags — there is no private variant of `addUrl`.
## Notes per family
**`RelayList` (NIP-65).** Read/write are encoded by the `r`-tag's third element; a bare `["r", url]` counts as both. `urls()` returns all, `readUrls()`/`writeUrls()` filter by `RelayMode`. On the builder, `addUrl(url, mode)`/`removeUrl(url, mode)` preserve the *other* mode if it was present, so flipping write on a read-only relay produces a bare both-mode tag rather than clobbering it. URLs are normalized via `normalizeRelayUrl`.
```typescript
import {RelayListBuilder} from "@welshman/domain"
import {RelayMode} from "@welshman/util"
await new RelayListBuilder()
.addUrl("wss://relay.example", RelayMode.Write)
.addUrl("wss://read.example", RelayMode.Read)
.toEvent(signer)
```
**Relay/server set lists.** `BlockedRelayList`, `SearchRelayList`, and `MessagingRelayList` store URLs under the `relay` tag key; `BlossomServerList` uses the `server` key instead. All four share `addUrl`/`removeUrl`/`setUrls` (where `setUrls` clears then re-adds), with `normalizeRelayUrl` applied.
**`RelaySet` (kind 30002).** A named, addressable relay set — it is parameterized-replaceable, so the builder needs a `d` tag (`setIdentifier()`). Its constructor lifts `title`/`description`/`image` out of the tags into dedicated fields, and `buildTags` prepends them ahead of the `relay` tags.
```typescript
import {RelaySetBuilder} from "@welshman/domain"
await new RelaySetBuilder()
.setIdentifier() // required d tag for kind 30002
.setTitle("My relays")
.addUrl("wss://relay.example")
.toEvent(signer)
```
**`RoomList` (kind 10009).** A simple-groups membership list. `join(groupId, url)` writes `["group", groupId, url]`; `leave(groupId)` drops the matching tag. (NIP-29 room *operations* themselves live in [Rooms](./rooms).)
## See also
- [Readers & Builders](./readers-and-builders) — the `ListReader`/`ListBuilder` base, including exactly how private-tag encryption and decryption work.
- [Rooms](./rooms) — NIP-29 room ops, referenced by `RoomList`.
+76
View File
@@ -0,0 +1,76 @@
# Profile
`Profile` / `ProfileBuilder` model NIP-01 kind-0 metadata — the JSON blob that carries a user's name, picture, NIP-05, lightning address, and so on. Like every kind in `@welshman/domain`, it is a thin pair of classes over the [base Reader/Builder machinery](./readers-and-builders): a read-only view plus a chainable producer of an event template.
The content of a kind-0 event is a JSON object, so `Profile.parse` decodes it into a `values` record and the getters read fields off that.
## Reading
```typescript
import {Profile} from "@welshman/domain"
const profile = await Profile.fromEvent(event) // no signer needed — kind 0 is not encrypted
profile.name() // string | undefined
profile.about() // string | undefined
profile.picture() // string | undefined
profile.banner() // string | undefined
profile.website() // string | undefined
profile.nip05() // string | undefined
profile.lnurl() // lud16/lud06 → lnurl, via parseLnUrl
profile.values // the raw decoded JSON object
```
`display(fallback = "")` is the best-effort label you usually want in UI. It prefers `name` (truncated to 60 chars via `ellipsize`), and otherwise falls back to a shortened npub:
```typescript
profile.display() // "alice" · "npub1abc…wxyz" · fallback
profile.display("anonymous") // fallback used only when there is nothing else
```
## Building
Construct empty to author a new profile, or from a reader to edit one. Setters are chainable; finish with `toTemplate()` / `toEvent(signer)`.
```typescript
import {ProfileBuilder} from "@welshman/domain"
const template = await new ProfileBuilder()
.setName("alice")
.setAbout("hello nostr")
.setPicture("https://example.com/avatar.png")
.setNip05("alice@example.com")
.toTemplate() // EventTemplate {kind: 0, content, tags: []}
```
Editing round-trips through the reader. `buildContent` re-serializes `values` to JSON, so unknown profile fields you never touched are preserved:
```typescript
const signed = await profile.builder()
.setAbout("updated bio")
.toEvent(signer) // SignedEvent
```
Available setters: `setName`, `setNip05`, `setAbout`, `setBanner`, `setPicture`, `setWebsite`, plus `update(values)` to merge an arbitrary object into `values`.
```typescript
new ProfileBuilder().update({name: "alice", lud16: "alice@walletofsatoshi.com"})
```
## Free functions
`Profile.ts` also exports two standalone helpers, used internally by the getters above but available on their own:
```typescript
import {parseLnUrl, displayPubkey} from "@welshman/domain"
// Resolve an lnurl from a metadata object: checks lud06 then lud16.
parseLnUrl({lud16: "alice@example.com"}) // string | undefined
// A short, human-readable npub: first 8 chars + "…" + last 5.
displayPubkey(pubkey) // "npub1abc…wxyz"
```
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern every kind shares.
+270
View File
@@ -0,0 +1,270 @@
# Readers & Builders
Every kind in `@welshman/domain` is a thin subclass of four base classes:
- `EventReader` — read-only view over one event.
- `EventBuilder` — mutable producer of an event template.
- `ListReader``EventReader` with a public/private (encrypted) tag split.
- `ListBuilder``EventBuilder` with the same split, and NIP-44 encryption baked into its build step.
Understanding these four classes means you understand every kind: the per-kind files (`Profile`, `FollowList`, `ZapReceipt`, …) only add getters and setters on top of the machinery described here.
## EventReader
A `Reader` wraps a single `TrustedEvent` and answers questions about it. It is abstract — each kind pins its `kind` and provides a `builder()` — but the construction, parsing, and base getters all live here.
### Construction: factory and fromEvent
You never call the constructor directly to get a parsed reader, because parsing is async. Use one of the two static entry points instead. Both validate the event's kind (throwing `Expected a kind X event, got kind Y` on mismatch) and then `await reader.parse(signer)` before handing the reader back.
```typescript
import {Profile} from "@welshman/domain"
// One-shot: the usual entry point.
const profile = await Profile.fromEvent(event)
const profile2 = await Profile.fromEvent(event, signer) // signer optional
// Reusable, class-bound factory over a fixed signer. Returns an
// async (event) => Promise<Reader>, safe to use point-free.
const toProfile = Profile.factory(signer)
const profile3 = await toProfile(event)
```
`factory(signer?)` is what `@welshman/app`'s data plugins use as their `eventToItem`: a single bound function that turns each incoming event into a parsed reader. Both `fromEvent` and the function `factory` returns are **async**.
The signer is always optional. A reader that does not need it (every non-list kind) simply ignores it, so callers can pass a signer unconditionally without knowing which kinds carry encrypted content.
### Async parse
```typescript
protected async parse(signer?: ISigner): Promise<void> {}
```
`parse` is the one async hook. The base implementation is a no-op; subclasses override it to decode whatever they need:
- `Profile.parse` JSON-parses `event.content` into a `values` object.
- `ZapReceipt.parse` decodes the embedded zap-request JSON out of the `description` tag.
- `ListReader.parse` decrypts the private tags (see below).
Because `parse` is the only async step, everything downstream — the getters — is synchronous.
### Getters
All base getters are synchronous reads over the wrapped event:
| Getter | Returns |
|---|---|
| `id()` | `event.id` |
| `author()` | `event.pubkey` |
| `content()` | `event.content` |
| `tags()` | `event.tags` (overridden by `ListReader` to merge public + private) |
| `createdAt()` | `event.created_at` |
| `identifier()` | the `d` tag value |
| `address()` | the replaceable address `kind:pubkey:d` (via `getAddress`) |
| `group()` | the NIP-29 `h` tag value |
| `protect()` | `true` if a `["-"]` tag is present |
| `expiration()` | the parsed `expiration` tag as a number, or `undefined` |
```typescript
const reader = await SomeKind.fromEvent(event)
reader.author() // pubkey
reader.identifier() // d tag, if any
reader.expiration() // number | undefined
```
Each subclass adds its own getters on top — `profile.name()`, `followList.pubkeys()`, `zapGoal.amount()`, and so on.
### builder()
```typescript
abstract builder(): EventBuilder<EventReader>
```
Every reader returns its matching builder, pre-populated from itself — internally just `new XBuilder(this)`. This is the read-edit-rebuild bridge:
```typescript
const edited = await profile.builder().setName("alice").toTemplate()
```
## EventBuilder
A `Builder` is a mutable, chainable producer of an `EventTemplate`. Construct it empty to author a new event, or from a reader to edit an existing one. Every setter returns `this`.
### Construction and extra-tag passthrough
```typescript
constructor(readonly reader?: Reader)
```
When you pass a reader, the builder seeds `content` from `reader.event.content` and copies **all** of `event.tags` into `extraTags`. It then *consumes* the tags it manages — `h`, `-`, `expiration`, and `d` — lifting each out of `extraTags` into a dedicated field (`groupTag`, `protectTag`, `expirationTag`, `identifierTag`).
Whatever remains in `extraTags` is **passed through verbatim** when the event is rebuilt. This is the extra-tag passthrough guarantee: tags the package does not model (or a subclass does not claim) survive an edit round-trip instead of being silently dropped.
```typescript
protected consumeTags(key: string): string[][]
```
Subclasses call `consumeTags` in their own constructors to lift the tags they understand out of the passthrough set. For example `RelaySetBuilder` consumes `title`/`description`/`image`, `CommentBuilder` consumes its root/parent ref tags, and `ListBuilder` takes over everything left as `publicTags`.
### Setters
The base behavior setters, all chainable:
```typescript
new SomeBuilder()
.setContent("…")
.setGroup(groupId) // h tag / clearGroup()
.setProtected(true) // ["-"] tag
.setExpiration(timestamp) // / clearExpiration()
.setIdentifier() // d tag (defaults to a random id) / clearIdentifier()
```
`setIdentifier(identifier = randomId())` defaults to a freshly generated id, which is what you want for new parameterized-replaceable events. Each subclass adds its own setters (`setName`, `addFollow`, `setAmount`, …) on top of these.
### The build pipeline
Subclasses customize the output by overriding two protected hooks (both may be async, both receive the optional signer) and `validate`:
```typescript
protected buildTags(signer?): MaybeAsync<string[][]> // default: [] — kind-specific tags
protected buildContent(signer?): MaybeAsync<string> // default: this.content
protected validate(): void // default: enforce d tag for replaceable kinds
```
`validate` by default throws `A d tag is required for kind X` for parameterized-replaceable kinds with no identifier. Subclasses call `super.validate()` and add their own checks — `ZapGoal requires a title`, `RoomDelete` requires an `h` group, and so on.
The three output methods assemble these into a result. All are async:
```typescript
const template = await builder.toTemplate(signer?) // EventTemplate {kind, content, tags}
const rumor = await builder.toRumor(signer) // HashedEvent (needs signer for pubkey)
const signed = await builder.toEvent(signer) // SignedEvent (signs + stamps)
```
`toTemplate` is the heart of it. It runs `validate()`, then awaits `buildContent`, `buildTags`, and the internal behavior tags in parallel, and concatenates the final tag list as:
```
[...implTags, ...behaviorTags, ...extraTags]
```
That ordering is the passthrough in action: kind-specific tags first, then the group/protect/expiration/identifier tags, then the untouched leftovers.
The signer rules:
- `toTemplate`'s signer is **optional** and only consulted by kinds whose `buildContent`/`buildTags` need it — in practice, the encrypting list kinds.
- `toRumor` and `toEvent` always **require** a signer (they need the author's pubkey, and `toEvent` signs).
```typescript
// Plain kind: no signer needed for a template
const template = await new ProfileBuilder().setName("alice").toTemplate()
// Any kind, signed
const event = await new ProfileBuilder().setName("alice").toEvent(signer)
```
## ListReader
NIP-51-style lists split their tags into a public set and a private (encrypted) set. `ListReader` extends `EventReader` and handles the decryption.
```typescript
decrypted = false
publicTags: string[][] = []
privateTags: string[][] = []
```
Its `parse` override:
1. Sets `publicTags = event.tags`.
2. If `event.content` is empty, there is nothing to decrypt → `decrypted = true`.
3. Otherwise, if a signer is supplied **and it belongs to the event's author** (`signer.getPubkey() === event.pubkey`), it decrypts the content, marks `decrypted = true`, parses the JSON array, and keeps only well-formed string-tuple tags into `privateTags`. A decryption failure is swallowed — `decrypted` simply stays `false`.
The practical consequence: **private tags only appear when you pass the author's own signer** to `fromEvent`/`factory`. Reading someone else's list, or your own list without a signer, gives you the public tags only.
```typescript
const list = await MuteList.fromEvent(event, signer) // signer = the list author's
list.pubkeys() // includes both public and private mutes, when decrypted
```
`tags()` is overridden to return `[...publicTags, ...privateTags]`, so every inherited getter that reads `this.tags()` transparently sees the merged view.
## ListBuilder
`ListBuilder` extends `EventBuilder` with the same public/private split and a chainable set of tag mutators. Its constructor takes over the leftover `extraTags` as `publicTags` (`this.publicTags = this.extraTags.splice(0)`) and copies `privateTags` from the reader.
### Tag mutators
All chainable (return `this`):
```typescript
builder
.addPublic(...tags) // append to public set
.addPrivate(...tags) // append to private (encrypted) set
.keepPublic(pred) // filter public to matches; also keepPrivate, keep (both)
.dropPublic(pred) // filter out matches; also dropPrivate, drop (both)
.clearPublic() // empty the set; also clearPrivate, clear (both)
```
Subclasses build their domain methods on these. For instance `FollowListBuilder.addFollow(tag)` is `addPublic(tag)`, and `MuteListBuilder` exposes `mutePublicly` (public) vs `mutePrivately` (private).
### validate
```typescript
protected validate()
```
`ListBuilder.validate` throws `Unable to modify list when decryption was not performed` if the source event had encrypted content that was never decrypted (because you did not pass the author's signer) yet you are trying to write private tags. This guards against clobbering private data you could not read.
### buildContent: where encryption lives
This is the important part. In the old `@welshman/util` design, encryption was a separate `Encryptable` wrapper you composed around an event. In `@welshman/domain` it is folded directly into the list builder's `buildContent`:
```typescript
protected async buildContent(signer?: ISigner): Promise<string> {
// Preserve the original ciphertext when we never decrypted it.
if (this.reader?.decrypted === false) return this.reader.event.content
// No need to encrypt an empty array
if (this.privateTags.length === 0) return ""
if (!signer) {
throw new Error("A signer is required to encrypt private tags")
}
const pubkey = await signer.getPubkey()
return signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags))
}
```
Three branches:
1. **Never decrypted** — return the original ciphertext untouched. You can edit public tags on a list you could not decrypt without destroying its private contents.
2. **No private tags** — return `""`. Nothing to encrypt.
3. **Has private tags** — require a signer (else throw `A signer is required to encrypt private tags`), then `signer.nip44.encrypt(pubkey, JSON.stringify(privateTags))`. The encryption is **NIP-44, self-encrypted to the author's own pubkey**.
`buildTags` simply returns `publicTags`. Because encryption happens here and needs the signer, list kinds are the case where `toTemplate(signer)` actually uses its optional argument:
```typescript
import {MuteListBuilder} from "@welshman/domain"
// Private mute → buildContent encrypts, so toTemplate needs the signer
const template = await new MuteListBuilder()
.mutePrivately(targetPubkey)
.toTemplate(signer)
// toEvent/toRumor always pass the signer through for you
const signed = await new MuteListBuilder()
.mutePublicly(targetPubkey)
.toEvent(signer)
```
## Old API → new API
| Old (`@welshman/util` helpers) | New (`@welshman/domain`) |
|---|---|
| `readProfile(event)` / free `read*` functions | `await Profile.fromEvent(event)` (Reader per kind) |
| `editProfile(...)` / free `create*`/`edit*` functions | `new ProfileBuilder(reader?)``toTemplate()/toEvent()` |
| `Encryptable` wrapper around an event | `ListBuilder.buildContent` (NIP-44, self-encrypted) — no separate wrapper |
| manual `signer.nip44.encrypt(...)` for private list tags | `addPrivate(...)` then `toTemplate(signer)` |
| manual `decrypt(...)` to read private tags | pass the author's signer to `fromEvent`/`factory` |
| hand-built `{kind, content, tags}` templates | `builder.toTemplate(signer?)` |
+78
View File
@@ -0,0 +1,78 @@
# Relay membership
These kinds model **relay-level** membership — Flotilla's notion of joining a relay/space, distinct from NIP-29 room membership (which is scoped to a room by an `h` tag). Where [Rooms](./rooms) deal with groups hosted *on* a relay, these events deal with belonging to the relay itself. They are all plain `EventReader` / `EventBuilder` subclasses; see [Readers & Builders](./readers-and-builders) for the base pattern.
## Ops and snapshots
| Class | Kind | Purpose | Reader | Builder |
|---|---|---|---|---|
| `RelayJoin` | 28934 | join request (ephemeral) | `claim()`, `reason()` | `setClaim(claim)`, `setReason(reason)` |
| `RelayInvite` | 28935 | invite (NIP-29) | `claim()` | `setClaim(claim)` |
| `RelayLeave` | 28936 | leave marker (ephemeral) | — | — |
| `RelayAddMember` | 8000 | add a member | `pubkeys()` | `addPubkey(pk)` |
| `RelayRemoveMember` | 8001 | remove a member | `pubkeys()` | `addPubkey(pk)` |
| `RelayMembers` | 13534 | member-list snapshot | `pubkeys()`, `isMember(pk)` | `addPubkey(pk)`, `removePubkey(pk)` |
Each has a matching `*Builder` (`RelayJoinBuilder`, `RelayMembersBuilder`, …).
## Joining and leaving
A `RelayJoin` carries an optional claim code (the `claim` tag) and a free-text reason (the event content):
```typescript
import {RelayJoin, RelayJoinBuilder, RelayLeaveBuilder} from "@welshman/domain"
const join = await RelayJoin.fromEvent(event)
join.claim() // string | undefined — the "claim" tag value
join.reason() // event.content, or undefined when empty
await new RelayJoinBuilder()
.setClaim(inviteCode) // ["claim", inviteCode]
.setReason("hello") // becomes the content
.toEvent(signer)
// Leaving is a bare marker — no fields.
await new RelayLeaveBuilder().toEvent(signer)
```
`RelayInvite` (kind 28935) is the invite counterpart, carrying just a `claim`:
```typescript
import {RelayInviteBuilder} from "@welshman/domain"
await new RelayInviteBuilder().setClaim(inviteCode).toEvent(signer)
```
## Member management
`RelayAddMember` (8000) and `RelayRemoveMember` (8001) are admin ops that list affected pubkeys via `addPubkey` (deduped by pubkey):
```typescript
import {RelayAddMemberBuilder, RelayRemoveMemberBuilder} from "@welshman/domain"
await new RelayAddMemberBuilder()
.addPubkey(pubkeyA)
.addPubkey(pubkeyB)
.toEvent(signer)
await new RelayRemoveMemberBuilder().addPubkey(pubkeyA).toEvent(signer)
```
`RelayMembers` (13534) is the resulting member-list snapshot. Its reader answers membership questions; its builder supports both add and remove:
```typescript
import {RelayMembers, RelayMembersBuilder} from "@welshman/domain"
const members = await RelayMembers.fromEvent(event)
members.pubkeys() // string[]
members.isMember(pubkey) // boolean
await new RelayMembersBuilder()
.addPubkey(pubkeyA) // also removePubkey(pk)
.toEvent(signer)
```
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern.
- [Rooms](./rooms) — NIP-29 room-level (not relay-level) membership and metadata ops.
+118
View File
@@ -0,0 +1,118 @@
# Rooms
NIP-29 rooms are relay-hosted groups. `@welshman/domain` models the room metadata kinds plus the full set of moderation/membership operations. There are two flavors of event here:
- **Addressable metadata** (kinds 3900039002) — replaceable, identified by a `d` tag, written by the relay. These are the canonical state of a room.
- **Action ops** (kinds 90009022, 19004) — regular events scoped to a target room by the NIP-29 **`h` group tag** (set via the base `setGroup` / `clearGroup`, read via `group()`).
All of these are plain `EventReader` / `EventBuilder` subclasses — none of them are encrypted lists. See [Readers & Builders](./readers-and-builders) for the base pattern. The reactive `app.use(Rooms)` plugin in `@welshman/app` builds on these classes.
## Room metadata
`RoomMeta` (kind 39000) is the addressable metadata record. Because it is parameterized-replaceable, the builder needs a `d` tag (`setIdentifier()`).
```typescript
import {RoomMeta, RoomMetaBuilder} from "@welshman/domain"
const meta = await RoomMeta.fromEvent(event)
meta.name() // string | undefined
meta.about() // string | undefined
meta.picture() // tag[1]
meta.pictureMeta() // tag.slice(2) — imeta-style extras, or undefined
meta.isClosed() // boolean (presence of a ["closed"] tag)
meta.isHidden() // boolean
meta.isPrivate() // boolean
meta.isRestricted() // boolean
meta.hasLivekit() // boolean
const template = await new RoomMetaBuilder()
.setIdentifier("my-room")
.setName("General")
.setAbout("anything goes")
.setPicture("https://example.com/room.png")
.setClosed() // ["closed"]; pass false to clear
.toTemplate()
```
Flag setters (`setClosed`, `setHidden`, `setPrivate`, `setRestricted`, `setLivekit`) each take an optional boolean (default `true`) — passing `false` clears the tag. `setPicture(picture, meta = [])` appends the extra imeta elements.
`RoomEdit` (kind 9002) carries the **same metadata fields** but as an *action* event scoped via the `h` group tag rather than a `d` identifier — it is how a client requests a metadata change. Its reader/builder are identical to `RoomMeta`, with one naming difference: the livekit getter is `livekit()` here (vs `hasLivekit()` on `RoomMeta`).
```typescript
import {RoomEditBuilder} from "@welshman/domain"
await new RoomEditBuilder()
.setGroup(roomId) // h tag — which room to edit
.setName("Renamed")
.toEvent(signer)
```
## Admins and members (addressable)
`RoomAdmins` (39001) and `RoomMembers` (39002) are addressable p-tag lists.
```typescript
import {RoomAdmins, RoomMembers, RoomMembersBuilder} from "@welshman/domain"
const admins = await RoomAdmins.fromEvent(event)
admins.pubkeys() // string[]
const members = await RoomMembers.fromEvent(event)
members.members() // string[]
members.isMember(pubkey) // boolean
await new RoomMembersBuilder()
.setIdentifier(roomId)
.addMember(pubkeyA) // also removeMember(pk)
.toEvent(signer)
```
`RoomAdminsBuilder` exposes `addAdmin(pk)` / `removeAdmin(pk)`; `RoomMembersBuilder` exposes `addMember(pk)` / `removeMember(pk)`. Both dedupe by pubkey.
## Membership and lifecycle ops
These are scoped to a room by the `h` group tag. Several enforce its presence in `validate()`.
| Class | Kind | Purpose | Reader | Builder |
|---|---|---|---|---|
| `RoomCreate` | 9007 | create a room | — | — |
| `RoomDelete` | 9008 | delete a room | — | requires `h` group |
| `RoomJoin` | 9021 | request to join | `code()`, `reason()` | `setCode(code)`, `setReason(reason)`; requires `h` group |
| `RoomLeave` | 9022 | leave a room | — | requires `h` group |
| `RoomAddMember` | 9000 | add a member | `pubkeys()` | `addPubkey(pk)` |
| `RoomRemoveMember` | 9001 | remove a member | `pubkeys()` | `addPubkey(pk)` |
| `RoomCreatePermission` | 19004 | grant room-creation rights | `pubkeys()`, `canCreate(pk)` | `setPubkeys(pks)` |
`RoomCreate` and `RoomLeave`/`RoomDelete` have no extra fields beyond the base builder — they are marker events. The members ops (`RoomAddMember`, `RoomRemoveMember`) use `addPubkey` to accumulate the affected pubkeys (deduped); `RoomRemoveMember`'s p-tags list the pubkeys to remove.
```typescript
import {RoomJoinBuilder, RoomAddMemberBuilder, RoomCreateBuilder} from "@welshman/domain"
// Join request — code is the "claim" tag, reason is the content.
await new RoomJoinBuilder()
.setGroup(roomId)
.setCode(inviteCode) // ["claim", inviteCode]
.setReason("please let me in")
.toEvent(signer)
// Add a member (relay/admin op)
await new RoomAddMemberBuilder()
.setGroup(roomId)
.addPubkey(newMemberPubkey)
.toEvent(signer)
// Create a room
await new RoomCreateBuilder().setGroup(roomId).toEvent(signer)
```
::: tip Naming quirks
- `RoomJoin` stores the invite under the `claim` tag, but the accessor is `code()` (and the setter is `setCode`).
- `RoomCreatePermission` uses `setPubkeys(pks)` (replace-all), not an `add*` method.
- `RoomDelete`, `RoomLeave`, and `RoomJoin` builders throw from `validate()` if no `h` group is set — always `setGroup(roomId)` first.
:::
## See also
- [Readers & Builders](./readers-and-builders) — the base pattern, the `h`/group tag handling (`setGroup`/`clearGroup`/`group()`), and `d`-tag validation for the addressable kinds.
- [Lists](./lists) — `RoomList` (kind 10009), the NIP-51 membership list that tracks which rooms a user belongs to.
- [Relay membership](./relay-membership) — the Flotilla relay-level (non-NIP-29) membership ops.
+85
View File
@@ -0,0 +1,85 @@
# Zaps
The lightning-zap flow (NIP-57) and zap goals (NIP-75) are modeled by three kinds: `ZapRequest` (what a sender publishes to ask for a zap), `ZapReceipt` (what the recipient's LN service publishes as proof of payment), and `ZapGoal` (a fundraising target). All three are plain `EventReader` / `EventBuilder` subclasses — see [Readers & Builders](./readers-and-builders) for the base pattern.
## Zap request (kind 9734)
The zap request you send to a recipient's LNURL callback. The comment is the event content; everything else is tags.
```typescript
import {ZapRequest, ZapRequestBuilder} from "@welshman/domain"
const req = await ZapRequest.fromEvent(event)
req.amount() // millisats as an int, or undefined ("amount" tag)
req.lnurl() // string | undefined
req.recipient() // p-tag value
req.eventId() // e-tag value (the zapped event)
req.urls() // the "relays" tag, sliced past the key
req.comment() // event.content
const template = await new ZapRequestBuilder()
.setAmount(21000) // millisats
.setRecipient(recipientPubkey)
.setLnurl(lnurl)
.setEventId(zappedNoteId)
.setUrls(["wss://relay.example"]) // ["relays", ...urls]
.setComment("great post")
.toTemplate()
```
`buildTags` always emits a `relays` tag (bare if you never called `setUrls`).
## Zap receipt (kind 9735)
The receipt is generated by the recipient's lightning service, so it is effectively **read-only** in practice. `parse` decodes the embedded zap request out of the `description` tag into `plain`, which the getters read through.
```typescript
import {ZapReceipt} from "@welshman/domain"
const receipt = await ZapReceipt.fromEvent(event)
receipt.bolt11() // the invoice
receipt.invoiceAmount() // amount parsed from bolt11, or undefined on parse failure
receipt.request() // the embedded zap-request event (TrustedEvent | undefined)
receipt.sender() // request.pubkey
receipt.recipient() // p-tag value
receipt.eventId() // e-tag value
receipt.comment() // the embedded request's content
receipt.preimage() // string | undefined
```
The important method is `verify(zapper)`, which validates the receipt against a `Zapper` (the recipient's LNURL zapper info from `@welshman/util`). It checks that the request is present, that the invoice amount matches the requested amount, that the sender is not the zapper itself, and that the recipient / lnurl / nostr pubkey are consistent. It returns a boolean.
```typescript
import type {Zapper} from "@welshman/util"
const ok: boolean = receipt.verify(zapper)
```
A `ZapReceiptBuilder` exists (`setBolt11`, `setDescription`, `setRecipient`, `setEventId`, `setPreimage`) for completeness — e.g. tests or a service generating receipts — but you rarely construct these by hand.
## Zap goal (kind 9041)
A fundraising target. The title is the content; the goal amount and relays are tags.
```typescript
import {ZapGoal, ZapGoalBuilder} from "@welshman/domain"
const goal = await ZapGoal.fromEvent(event)
goal.title() // event.content (or "")
goal.summary() // "summary" tag value
goal.amount() // millisats as an int, default 0
goal.urls() // "relays" tag values
const template = await new ZapGoalBuilder()
.setTitle("Fund the relay")
.setSummary("keeps the lights on")
.setAmount(1000000) // millisats
.setUrls(["wss://relay.example"]) // one ["relays", url] per url
.toTemplate()
```
`validate()` requires a title (throws `ZapGoal requires a title`), and `buildTags` always emits an `amount` tag (defaulting to `["amount", "0"]`).
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern.
+3
View File
@@ -24,6 +24,9 @@ features:
- title: "@welshman/util" - title: "@welshman/util"
details: Core Nostr utilities for events, filters, and data structures. details: Core Nostr utilities for events, filters, and data structures.
link: "/util" link: "/util"
- title: "@welshman/domain"
details: Nostr event kinds modeled as Reader/Builder classes for profiles, lists, rooms, handlers, and zaps.
link: "/domain"
- title: "@welshman/net" - title: "@welshman/net"
details: Networking layer for Nostr with relay connection management and message status handling. details: Networking layer for Nostr with relay connection management and message status handling.
link: "/net" link: "/net"
-91
View File
@@ -1,91 +0,0 @@
# Encryptable
The Encryptable module provides utilities for handling encrypted Nostr events, allowing you to merge plaintext updates into events and encrypt them before publishing.
## API
```typescript
// Encryption function type
export type Encrypt = (x: string) => Promise<string>;
// Partial event content for updates
export type EncryptableUpdates = Partial<EventContent>;
// Event with attached plaintext data
export type DecryptedEvent = TrustedEvent & {
plaintext: EncryptableUpdates;
};
// Creates a DecryptedEvent by attaching plaintext to an event
export declare const asDecryptedEvent: (
event: TrustedEvent,
plaintext?: EncryptableUpdates
) => DecryptedEvent;
// Encryptable class for handling encrypted events
export declare class Encryptable<T extends EventTemplate> {
constructor(
event: Partial<T>,
updates: EncryptableUpdates
);
// Encrypts updates and merges them into the event
reconcile(encrypt: Encrypt): Promise<T>;
}
```
## Examples
### Basic Usage
```typescript
import { Encryptable } from '@welshman/util';
// Create encryptable with plaintext updates
const encryptable = new Encryptable(
{ kind: 10000 }, // Base event template
{ content: "secret mute list data" } // Plaintext content to encrypt
);
// Encrypt and get final event
const encryptFn = async (text: string) => {
// Your encryption logic here
return await encrypt(text);
};
const event = await encryptable.reconcile(encryptFn);
// event.content is now encrypted
```
### Encrypting Tags
```typescript
import { Encryptable } from '@welshman/util';
// Encrypt both content and tag values
const encryptable = new Encryptable(
{ kind: 10000, tags: [] },
{
content: JSON.stringify(['pubkey1', 'pubkey2']),
tags: [['p', 'sensitive-pubkey'], ['e', 'sensitive-event-id']]
}
);
// The reconcile method encrypts tag values at index 1
const event = await encryptable.reconcile(encryptFn);
// event.tags[0] = ['p', 'encrypted-pubkey']
// event.tags[1] = ['e', 'encrypted-event-id']
```
### Working with Decrypted Events
```typescript
import { asDecryptedEvent } from '@welshman/util';
// Add plaintext data to an event for reference
const event = { kind: 10000, content: "encrypted...", tags: [] };
const plaintext = { content: "original content", tags: [['p', 'pubkey']] };
const decryptedEvent = asDecryptedEvent(event, plaintext);
console.log(decryptedEvent.plaintext.content); // "original content"
```
-134
View File
@@ -1,134 +0,0 @@
# Handlers (NIP-89)
The Handlers module provides functionality for working with handler recommendations and information (NIP-89).
Handlers are events that describe which kinds a given application can display.
This module provides utilities for transforming these events into structured handler objects that applications can easily process.
## Types
### Handler Definition
```typescript
type Handler = {
kind: number // Event kind this handler can process
name: string // Display name of the handler
about: string // Description
image: string // Icon or image URL
identifier: string // Unique identifier (d-tag)
event: TrustedEvent // Original handler event
website?: string // Optional website URL
lud16?: string // Optional Lightning address
nip05?: string // Optional NIP-05 identifier
}
```
## Core Functions
### Reading Handlers
```typescript
function readHandlers(event: TrustedEvent): Handler[]
// Example
const handlers = readHandlers(handlerEvent)
handlers.forEach(handler => {
console.log(`Handler for kind ${handler.kind}: ${handler.name}`)
})
```
### Handler Identification
```typescript
function getHandlerKey(handler: Handler): string
// Returns "kind:address" format
function getHandlerAddress(event: TrustedEvent): string | undefined
// Gets handler address from event tags
```
### Display Formatting
```typescript
function displayHandler(
handler?: Handler,
fallback = ""
): string
```
## Usage Examples
### Reading Handler Information
```typescript
const event = {
kind: 31990, // Handler Information kind
content: JSON.stringify({
name: "Note Viewer",
about: "Displays text notes with formatting",
image: "https://example.com/icon.png"
}),
tags: [
['k', '1'], // Handles kind 1 (text notes)
['d', 'note-viewer']
]
}
const handlers = readHandlers(event)
// Returns array of handlers defined in the event
```
### Working with Handlers
```typescript
// Get unique handler identifier
const key = getHandlerKey(handler)
// => "1:31990:pubkey:identifier" (handler-kind:address)
// where address is the "kind:pubkey:identifier" of the handler event
// Display handler name
const name = displayHandler(handler, "Unknown Handler")
// => "Note Viewer" or fallback if handler undefined
// Get handler address
const address = getHandlerAddress(event)
// Returns address from tags with 'web' marker or first address
```
## Complete Example
```typescript
// Process handler information event
function processHandlerEvent(event: TrustedEvent) {
// Read all handlers from event
const handlers = readHandlers(event)
// Process each handler
handlers.forEach(handler => {
// Generate unique key
const key = getHandlerKey(handler)
// Store handler information
handlerRegistry.set(key, {
name: handler.name,
kind: handler.kind,
about: handler.about,
image: handler.image,
website: handler.website,
address: getHandlerAddress(handler.event)
})
})
}
// Find handler for event kind
function findHandler(kind: number): Handler | undefined {
return Array.from(handlerRegistry.values())
.find(h => h.kind === kind)
}
// Display handler information
function renderHandler(handler: Handler) {
return {
title: displayHandler(handler, "Unknown"),
description: handler.about,
icon: handler.image,
website: handler.website || null
}
}
```
+3 -3
View File
@@ -2,20 +2,20 @@
[![version](https://badgen.net/npm/v/@welshman/util)](https://npmjs.com/package/@welshman/util) [![version](https://badgen.net/npm/v/@welshman/util)](https://npmjs.com/package/@welshman/util)
A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, profiles, and more. A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, filters, tags, and more.
## What's Included ## What's Included
- **Event Management**: Create, validate, and process Nostr events - **Event Management**: Create, validate, and process Nostr events
- **Repository**: In-memory event storage with querying and indexing - **Repository**: In-memory event storage with querying and indexing
- **Filters**: Advanced event filtering and subscription management - **Filters**: Advanced event filtering and subscription management
- **Profiles**: User profile handling and formatting
- **Lists**: Public and private list management
- **Zaps**: Lightning Network payment integration - **Zaps**: Lightning Network payment integration
- **Tags**: Comprehensive tag parsing and manipulation - **Tags**: Comprehensive tag parsing and manipulation
- **Addresses**: NIP-19 address handling - **Addresses**: NIP-19 address handling
- **Relays**: Relay URL handling, event dispatching and in-memory storage - **Relays**: Relay URL handling, event dispatching and in-memory storage
> Note: profiles, lists, handlers, rooms, and event Reader/Builder helpers now live in [@welshman/domain](/domain/).
## Installation ## Installation
``` ```
-148
View File
@@ -1,148 +0,0 @@
# Lists
The Lists module provides utilities for working with Nostr lists, including both public and private lists (like bookmarks, mute lists, etc.). It handles list creation, encryption, and manipulation.
## Core Types
### List Parameters
```typescript
interface ListParams {
kind: number // List kind (e.g., 10000 for mutes)
}
```
### List Structure
```typescript
interface List extends ListParams {
publicTags: string[][] // Publicly visible tags
privateTags: string[][] // Encrypted tags
event?: DecryptedEvent // Original event if list exists
}
```
### Published List
```typescript
interface PublishedList extends List {
event: DecryptedEvent // Required event for published lists
}
```
## List Creation
### Create New List
```typescript
function makeList(list: ListParams & Partial<List>): List
// Example
const muteList = makeList({
kind: 10000,
publicTags: [['d', 'mutes']],
privateTags: [['p', 'pubkey1'], ['p', 'pubkey2']]
})
```
### Read Existing List
```typescript
function readList(event: DecryptedEvent): PublishedList
// Example
const list = readList(decryptedEvent)
```
## List Operations
### Get All Tags
```typescript
function getListTags(list: List | undefined): string[][]
// Example
const allTags = getListTags(list) // Combines public and private tags
```
### Remove Items
```typescript
// Remove by predicate
function removeFromListByPredicate(
list: List,
pred: (t: string[]) => boolean
): Encryptable
// Remove by value
function removeFromList(
list: List,
value: string
): Encryptable
```
### Add Items
```typescript
// Add public items
function addToListPublicly(
list: List,
...tags: string[][]
): Encryptable
// Add private items
function addToListPrivately(
list: List,
...tags: string[][]
): Encryptable
// Update list with new tags
function updateList(
list: List,
options: {publicTags?: string[][], privateTags?: string[][]}
): Encryptable
```
## Usage Examples
### Creating a Private List
```typescript
// Create new mute list
const muteList = makeList({
kind: 10000,
publicTags: [
['d', 'mutes'],
['name', 'My Mute List']
]
})
// Add items privately
const updated = addToListPrivately(
muteList,
['p', 'pubkey1'],
['p', 'pubkey2']
)
// Add new items publicly
const addItems = addToListPublicly(
list,
['p', 'pubkey3'],
['p', 'pubkey4']
)
// Encrypt and publish
const encrypted = await updated.reconcile(encrypt)
```
### Reading and Updating Lists
```typescript
// Read existing list
const list = readList(decryptedEvent)
// Remove item
const removeItem = removeFromList(list, 'pubkey1')
// Remove by predicate
const noMentions = removeFromListByPredicate(
list,
tag => tag[0] === 'p'
)
```
### Working with Tags
```typescript
// Get all list tags
const tags = getListTags(list)
```
-115
View File
@@ -1,115 +0,0 @@
# Profile
The Profile module provides utilities for handling Nostr user profiles (kind 0 events), including profile creation, reading, and display formatting.
## Core Types
### Profile Structure
```typescript
interface Profile {
name?: string // Display name
nip05?: string // NIP-05 verification
lud06?: string // Legacy Lightning address
lud16?: string // Lightning address
lnurl?: string // Lightning URL
about?: string // Bio/description
banner?: string // Banner image URL
picture?: string // Profile picture URL
website?: string // Website URL
display_name?: string // Alternative display name
event?: TrustedEvent // Original profile event
}
```
### Published Profile
```typescript
interface PublishedProfile extends Omit<Profile, "event"> {
event: TrustedEvent // Required event for published profiles
}
```
## Core Functions
### Profile Creation & Reading
```typescript
// Create new profile
function makeProfile(profile: Partial<Profile>): Profile
// Read profile from event
function readProfile(event: TrustedEvent): PublishedProfile
// Create profile event
function createProfile(profile: Profile): EventTemplate
// Edit existing profile
function editProfile(profile: PublishedProfile): EventTemplate
```
### Display Formatting
```typescript
// Format pubkey for display
function displayPubkey(pubkey: string): string
// Format profile name for display
function displayProfile(
profile?: Profile,
fallback = ""
): string
// Check if profile has name
function profileHasName(profile?: Profile): boolean
```
## Usage Examples
### Creating New Profile
```typescript
// Create basic profile
const profile = makeProfile({
name: "Alice",
about: "Nostr user",
picture: "https://example.com/avatar.jpg",
lud16: "alice@getalby.com"
})
// Create profile event
const profileEvent = createProfile(profile)
```
### Reading Profile
```typescript
// Read profile from event
const profile = readProfile(profileEvent)
// Access profile data
console.log(profile.name)
console.log(profile.about)
console.log(profile.lnurl) // Auto-generated from lud16/lud06
```
### Displaying Profile
```typescript
// Display profile name
const name = displayProfile(profile, "Anonymous")
// Display pubkey
const shortPubkey = displayPubkey(profile.event.pubkey)
// => "npub1abc...xyz"
// Check for name
if (profileHasName(profile)) {
showName(profile)
} else {
showPubkey(profile)
}
```
### Updating Profile
```typescript
// Edit existing profile
const profileEvent = editProfile({
...existingProfile,
name: "New Name",
about: "Updated bio"
})
```
-271
View File
@@ -1,271 +0,0 @@
import {COMMENT, getAddress, MUTES, NOTE} from "@welshman/util"
import {beforeEach, describe, expect, it, vi} from "vitest"
import {
tagEvent,
tagEventForComment,
tagEventForQuote,
tagEventForReaction,
tagEventForReply,
tagEventPubkeys,
tagPubkey,
tagZapSplit,
} from "../src/tags"
describe("tags", () => {
const id = "00".repeat(32)
const id1 = "11".repeat(32)
const id2 = "22".repeat(32)
const pubkey = "aa".repeat(32)
const pubkey1 = "bb".repeat(32)
const pubkey2 = "cc".repeat(32)
const mockEvent: any = {
id,
pubkey,
kind: 1,
tags: [],
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("tagZapSplit", () => {
it("should create zap split tag with default split", () => {
const result = tagZapSplit(pubkey1)
expect(result).toEqual(["zap", pubkey1, expect.any(String), "1"])
})
it("should create zap split tag with custom split", () => {
const result = tagZapSplit(pubkey1, 0.5)
expect(result).toEqual(["zap", pubkey1, expect.any(String), "0.5"])
})
})
describe("tagPubkey", () => {
it("should create pubkey tag with relay hint and display name", () => {
const result = tagPubkey(pubkey1)
expect(result).toEqual(["p", pubkey1, expect.any(String), expect.any(String)])
})
})
describe("tagEvent", () => {
it("should create basic event tag", () => {
const result = tagEvent(mockEvent)
expect(result).toHaveLength(1)
expect(result[0]).toEqual(["e", mockEvent.id, expect.any(String), "", mockEvent.pubkey])
})
it("should include address tag for replaceable events", () => {
const replaceableEvent = {...mockEvent, kind: MUTES}
const result = tagEvent(replaceableEvent)
expect(result).toHaveLength(2)
expect(result[1][0]).toBe("a")
})
})
describe("tagEventPubkeys", () => {
it("should extract and tag unique pubkeys from event", () => {
const event = {
...mockEvent,
tags: [
["p", pubkey1],
["p", pubkey2],
],
}
const result = tagEventPubkeys(event)
expect(result).toHaveLength(3) // event.pubkey + 2 tagged pubkeys
expect(result.every(tag => tag[0] === "p")).toBe(true)
})
})
describe("tagEventForQuote", () => {
it("should create quote tag", () => {
const result = tagEventForQuote(mockEvent)
expect(result).toEqual(["q", mockEvent.id, expect.any(String), mockEvent.pubkey])
})
})
describe("tagEventForReply", () => {
it("should handle reply to event with no existing tags", () => {
const result = tagEventForReply(mockEvent)
expect(result.some(tag => tag[0] === "e")).toBe(true)
expect(result.some(tag => tag[3] === "root")).toBe(true)
})
it("should handle reply to event with root", () => {
const eventWithRoot = {
...mockEvent,
tags: [
["e", id1, "", "root"],
["p", pubkey1],
],
}
const result = tagEventForReply(eventWithRoot)
const p = result.filter(tag => tag[0] === "p")
const e = result.filter(tag => tag[0] === "e")
// p[0] should be the author of the event
expect(p[0][1]).toBe(pubkey)
// p[1] should be the pubkey mentioned in the event
expect(p[1][1]).toBe(pubkey1)
// e[0] the "e" root tag should have been propagated
expect(e[0][1]).toBe(id1)
// e[1] should be the event id
expect(e[1][1]).toBe(id)
})
it("should handle replaceable events", () => {
const replaceableEvent = {
...mockEvent,
kind: MUTES,
tags: [
["e", id1, "relay-url", "root"],
["e", id2, "relay-url", "mention"],
],
}
const result = tagEventForReply(replaceableEvent)
const p = result.filter(tag => tag[0] === "p")
const e = result.filter(tag => tag[0] === "e")
const a = result.filter(tag => tag[0] === "a")
// p[0] should be the author of the event
expect(p[0][1]).toBe(pubkey)
// e[0] should be the root propagated
expect(e[0][1]).toBe(id1)
expect(e[0][3]).toBe("root")
// e[1] should be the event id and marked as a reply
expect(e[1][1]).toBe(id)
expect(e[1][3]).toBe("reply")
// a[0] should be the address of the replaceable event
expect(a[0][1]).toBe(getAddress(replaceableEvent))
})
})
describe("tagEventForComment", () => {
it("should create comment tags for basic event", () => {
const result = tagEventForComment(mockEvent)
expect(result.some(tag => tag[0] === "K")).toBe(true)
expect(result.some(tag => tag[0] === "P")).toBe(true)
expect(result.some(tag => tag[0] === "E")).toBe(true)
})
it("should handle replaceable events", () => {
const replaceableEvent = {...mockEvent, kind: MUTES}
const result = tagEventForComment(replaceableEvent)
expect(result.some(tag => tag[0] === "A")).toBe(true)
expect(result.some(tag => tag[0] === "a")).toBe(true)
})
it("should preserve root tags and point to the direct parent", () => {
const eventWithTags = {
...mockEvent,
kind: COMMENT,
tags: [
["e", id2, "relay-url", "root"],
["p", pubkey2, "relay-url"],
["k", NOTE.toString()],
["E", id1, "relay-url", "root"],
["P", pubkey1, "relay-url"],
["K", NOTE.toString()],
],
}
const result = tagEventForComment(eventWithTags)
// Should preserve uppercase variants of existing tags
// expect(result.some(tag => tag[0] === "E" && tag[1] === id1)).toBe(true)
// expect(result.some(tag => tag[0] === "P" && tag[1] === pubkey1)).toBe(true)
// expect(result.some(tag => tag[0] === "K" && tag[1] === NOTE.toString())).toBe(true)
// Should also add lowercase variants
expect(result.some(tag => tag[0] === "e" && tag[1] === eventWithTags.id)).toBe(true)
expect(result.some(tag => tag[0] === "p" && tag[1] === eventWithTags.pubkey)).toBe(true)
expect(result.some(tag => tag[0] === "k" && tag[1] === COMMENT.toString())).toBe(true)
})
it("should handle events with multiple root tags", () => {
const eventWithMultipleRoots = {
...mockEvent,
tags: [
["e", id1, "relay-url", "root"],
["e", id2, "relay-url", "root"],
],
}
const result = tagEventForComment(eventWithMultipleRoots)
expect(result).toEqual([
["K", String(NOTE)],
["P", pubkey, expect.any(String)],
["E", id, expect.any(String), pubkey],
["k", String(NOTE)],
["p", pubkey, expect.any(String)],
["e", id, expect.any(String), pubkey],
])
})
it("should handle events with mixed tag types", () => {
const eventWithMixedTags = {
...mockEvent,
kind: MUTES,
tags: [
["e", id, "relay-url", "root"],
["p", pubkey1, "relay-url"],
["i", id1],
["a", "some-address", "relay-url"],
["custom", "value"],
],
}
const result = tagEventForComment(eventWithMixedTags)
expect(result).toEqual([
["K", String(MUTES)],
["P", pubkey, expect.any(String)],
["E", id, expect.any(String), pubkey],
["A", getAddress(eventWithMixedTags), expect.any(String), pubkey],
["k", String(MUTES)],
["p", pubkey, expect.any(String)],
["e", id, expect.any(String), pubkey],
["a", getAddress(eventWithMixedTags), expect.any(String), pubkey],
])
})
it("should add event metadata tags when no root tags exist", () => {
const eventWithoutRoots = {
...mockEvent,
tags: [["custom", "value"]],
}
const result = tagEventForComment(eventWithoutRoots)
// Should add uppercase metadata tags (roots)
expect(result.some(tag => tag[0] === "K" && tag[1] === String(mockEvent.kind))).toBe(true)
expect(result.some(tag => tag[0] === "P" && tag[1] === mockEvent.pubkey)).toBe(true)
expect(result.some(tag => tag[0] === "E" && tag[1] === mockEvent.id)).toBe(true)
// Should add lowercase variants (parents)
expect(result.some(tag => tag[0] === "k" && tag[1] === String(mockEvent.kind))).toBe(true)
expect(result.some(tag => tag[0] === "p" && tag[1] === mockEvent.pubkey)).toBe(true)
expect(result.some(tag => tag[0] === "e" && tag[1] === mockEvent.id)).toBe(true)
})
})
describe("tagEventForReaction", () => {
it("should create reaction tags", () => {
const result = tagEventForReaction(mockEvent)
expect(result.some(tag => tag[0] === "k")).toBe(true)
expect(result.some(tag => tag[0] === "e")).toBe(true)
})
it("should include author tag if different from current user", () => {
const result = tagEventForReaction(mockEvent)
expect(result.some(tag => tag[0] === "p")).toBe(true)
})
it("should handle replaceable events", () => {
const replaceableEvent = {...mockEvent, kind: MUTES}
const result = tagEventForReaction(replaceableEvent)
expect(result.some(tag => tag[0] === "a")).toBe(true)
})
})
})
-119
View File
@@ -1,119 +0,0 @@
import {PublishStatus, LOCAL_RELAY_URL} from "@welshman/net"
import {NOTE, DIRECT_MESSAGE, WRAP, makeEvent, getPubkey, makeSecret, prep} from "@welshman/util"
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"
import {repository, tracker} from "../src/core"
import {addSession, dropSession, makeNip01Session} from "../src/session"
import {abortThunk, MergedThunk, publishThunk, thunkQueue, flattenThunks} from "../src/thunk"
const secret = makeSecret()
const pubkey = getPubkey(secret)
const mockRequest = {
event: prep({...makeEvent(NOTE), pubkey}),
relays: [LOCAL_RELAY_URL],
}
describe("thunk", () => {
beforeEach(() => {
vi.useFakeTimers()
addSession(makeNip01Session(secret))
})
afterEach(async () => {
thunkQueue.stop()
thunkQueue.clear()
await vi.runAllTimersAsync()
vi.useRealTimers()
vi.clearAllMocks()
thunkQueue.start()
dropSession(pubkey)
})
describe("MergedThunk", () => {
it("should abort all thunks when merged controller aborts", () => {
const thunk1 = publishThunk(mockRequest)
const thunk2 = publishThunk(mockRequest)
const merged = new MergedThunk([thunk1, thunk2])
abortThunk(merged)
expect(thunk1.controller.signal.aborted).toBe(true)
expect(thunk2.controller.signal.aborted).toBe(true)
})
})
describe("flattenThunks", () => {
it("should iterate through nested thunks", () => {
const thunk1 = publishThunk(mockRequest)
const thunk2 = publishThunk(mockRequest)
const merged = new MergedThunk([thunk1, thunk2])
const thunks = Array.from(flattenThunks([merged, thunk1]))
expect(thunks).toHaveLength(3)
})
})
describe("publishThunk", () => {
it("should create and publish a thunk", async () => {
const publishSpy = vi.spyOn(repository, "publish")
const result = publishThunk(mockRequest)
expect(publishSpy).toHaveBeenCalled()
expect(result).toHaveProperty("event")
expect(result).toHaveProperty("options")
})
it("should handle abort", () => {
const removeEventSpy = vi.spyOn(repository, "removeEvent")
const thunk = publishThunk(mockRequest)
abortThunk(thunk)
expect(removeEventSpy).toHaveBeenCalledWith(thunk.event.id)
})
})
describe("abortThunk", () => {
it("should abort a thunk and clean up", () => {
const removeEventSpy = vi.spyOn(repository, "removeEvent")
const thunk = publishThunk(mockRequest)
abortThunk(thunk)
expect(removeEventSpy).toHaveBeenCalledWith(thunk.event.id)
})
})
it("should update status during publishing", async () => {
const track = vi.spyOn(tracker, "track")
const thunk = publishThunk(mockRequest)
// Wait for initial async operations
await vi.runAllTimersAsync()
expect(thunk.results[LOCAL_RELAY_URL].status).toEqual(PublishStatus.Success)
// Verify tracker was called on success
expect(track).toHaveBeenCalledWith(thunk.event.id, LOCAL_RELAY_URL)
await vi.runAllTimersAsync()
await thunk.complete
expect(thunk.results[LOCAL_RELAY_URL].status).toEqual(PublishStatus.Success)
})
describe("wrapped events", () => {
it("if recipient is included, the event should be wrapped", async () => {
const recipient = getPubkey(makeSecret())
const event = prep({...makeEvent(DIRECT_MESSAGE), pubkey})
const thunk = publishThunk({event, relays: [], recipient})
const publishSpy = vi.spyOn(thunk, "_publish")
await vi.runAllTimersAsync()
expect(publishSpy.mock.calls[0][0].kind).toBe(WRAP)
expect(publishSpy.mock.calls[0][0].id).not.toBe(thunk.event.id)
})
})
})
+4 -2
View File
@@ -1,9 +1,9 @@
{ {
"name": "@welshman/app", "name": "@welshman/app",
"version": "0.8.16", "version": "0.8.13",
"author": "hodlbod", "author": "hodlbod",
"license": "MIT", "license": "MIT",
"description": "A collection of svelte stores for use in building nostr client applications.", "description": "An instance-based, composable client for building nostr applications",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
@@ -25,6 +25,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@pomade/core": "^0.2.1", "@pomade/core": "^0.2.1",
"@welshman/domain": "workspace:*",
"@welshman/feeds": "workspace:*", "@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*", "@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*", "@welshman/net": "workspace:*",
@@ -39,6 +40,7 @@
"typescript": "~5.8.0", "typescript": "~5.8.0",
"@pomade/core": "^0.2.1", "@pomade/core": "^0.2.1",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@welshman/domain": "workspace:*",
"@welshman/feeds": "workspace:*", "@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*", "@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*", "@welshman/net": "workspace:*",
+92
View File
@@ -0,0 +1,92 @@
import type {Unsubscriber} from "svelte/store"
import {call} from "@welshman/lib"
import {Pool, Tracker, Repository, WrapManager} from "@welshman/net"
import type {NetContext, AdapterFactory} from "@welshman/net"
import type {User} from "./user.js"
import type {AppPolicy} from "./policy.js"
export type AppConfig = {
dufflepudUrl?: string
getDefaultRelays?: () => string[]
getIndexerRelays?: () => string[]
getSearchRelays?: () => string[]
}
export type AppOptions = {
user?: User
config?: AppConfig
getAdapter?: AdapterFactory
policies?: AppPolicy[]
}
export interface IApp {
user?: User
config: AppConfig
use: <T>(Ctor: new (app: IApp) => T) => T
netContext: NetContext
pool: Pool
tracker: Tracker
repository: Repository
wrapManager: WrapManager
}
/**
* The core of an application instance. Owns the primitives a single identity
* needs (so data never bleeds across sessions) — a private repository, a socket
* pool, a tracker, a wrap manager — and a `use` registry that resolves data
* modules (including net/store helpers) on demand.
*/
export class App implements IApp {
user?: User
config: AppConfig
netContext: NetContext
pool: Pool
tracker: Tracker
repository: Repository
wrapManager: WrapManager
private singletons = new Map<Function, unknown>()
private unsubscribers: Unsubscriber[] = []
constructor(options: AppOptions = {}) {
this.user = options.user
this.config = options.config ?? {}
this.pool = new Pool()
this.tracker = new Tracker()
this.repository = new Repository()
this.wrapManager = new WrapManager({
tracker: this.tracker,
repository: this.repository,
})
this.netContext = {
pool: this.pool,
repository: this.repository,
getAdapter: options.getAdapter,
}
for (const policy of options.policies ?? []) {
this.unsubscribers.push(policy(this))
}
}
// Resolve the per-app singleton of a data module, constructing it on first
// use. This is how modules reach their dependencies (e.g. app.use(RelayLists)),
// replacing constructor injection and letting cycles resolve lazily.
use = <T>(Ctor: new (app: IApp) => T): T => {
let instance = this.singletons.get(Ctor) as T | undefined
if (!instance) {
this.singletons.set(Ctor, (instance = new Ctor(this)))
}
return instance
}
cleanup() {
this.unsubscribers.forEach(call)
this.pool.clear()
this.tracker.clear()
this.repository.clear()
this.wrapManager.clear()
}
}
-42
View File
@@ -1,42 +0,0 @@
import {BLOCKED_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const blockedRelayListsByPubkey = deriveItemsByKey({
repository,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
filters: [{kinds: [BLOCKED_RELAYS]}],
getKey: blockedRelayLists => blockedRelayLists.event.pubkey,
})
export const blockedRelayLists = deriveItems(blockedRelayListsByPubkey)
export const getBlockedRelayListsByPubkey = getter(blockedRelayListsByPubkey)
export const getBlockedRelayLists = getter(blockedRelayLists)
export const getBlockedRelayList = (pubkey: string) => getBlockedRelayListsByPubkey().get(pubkey)
export const forceLoadBlockedRelayList = makeForceLoadItem(
makeOutboxLoader(BLOCKED_RELAYS),
getBlockedRelayList,
)
export const loadBlockedRelayList = makeLoadItem(
makeOutboxLoader(BLOCKED_RELAYS),
getBlockedRelayList,
)
export const deriveBlockedRelayList = makeDeriveItem(
blockedRelayListsByPubkey,
loadBlockedRelayList,
)
-40
View File
@@ -1,40 +0,0 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const blossomServerListsByPubkey = deriveItemsByKey({
repository,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
filters: [{kinds: [BLOSSOM_SERVERS]}],
getKey: blossomServerList => blossomServerList.event.pubkey,
})
export const blossomServerLists = deriveItems(blossomServerListsByPubkey)
export const getBlossomServerListsByPubkey = getter(blossomServerListsByPubkey)
export const getBlossomServerList = (pubkey: string) => getBlossomServerListsByPubkey().get(pubkey)
export const forceLoadBlossomServerList = makeForceLoadItem(
makeOutboxLoader(BLOSSOM_SERVERS),
getBlossomServerList,
)
export const loadBlossomServerList = makeLoadItem(
makeOutboxLoader(BLOSSOM_SERVERS),
getBlossomServerList,
)
export const deriveBlossomServerList = makeDeriveItem(
blossomServerListsByPubkey,
loadBlossomServerList,
)
-378
View File
@@ -1,378 +0,0 @@
import {get} from "svelte/store"
import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib"
import {
sendManagementRequest,
ManagementRequest,
addToListPublicly,
addToListPrivately,
updateList,
EventTemplate,
removeFromList,
makeHttpAuth,
getListTags,
getRelayTags,
getRelayTagValues,
getRelaysFromList,
makeList,
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
isPublishedProfile,
createProfile,
editProfile,
RelayMode,
makeEvent,
MESSAGING_RELAYS,
BLOCKED_RELAYS,
SEARCH_RELAYS,
FOLLOWS,
RELAYS,
MUTES,
PINS,
prep,
} from "@welshman/util"
import type {RoomMeta, Profile} from "@welshman/util"
import {Router, addMaximalFallbacks} from "@welshman/router"
import {
userRelayList,
forceLoadUserRelayList,
userMessagingRelayList,
forceLoadUserMessagingRelayList,
userBlockedRelayList,
forceLoadUserBlockedRelayList,
userSearchRelayList,
forceLoadUserSearchRelayList,
userFollowList,
forceLoadUserFollowList,
userMuteList,
forceLoadUserMuteList,
userPinList,
forceLoadUserPinList,
} from "./user.js"
import {nip44EncryptToSelf, signer, pubkey} from "./session.js"
import {ThunkOptions, MergedThunk, publishThunk} from "./thunk.js"
import {loadMessagingRelayList} from "./messagingRelayLists.js"
// NIP 65
export const removeRelay = async (url: string, mode: RelayMode) => {
await forceLoadUserRelayList([])
const list = get(userRelayList) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
const tags = list.publicTags.filter(nthNe(1, url))
// If we had a duplicate that was used as the alt mode, keep the alt
if (dup && (!dup[2] || dup[2] === alt)) {
tags.push(["r", url, alt])
}
const event = {kind: list.kind, content: list.event?.content || "", tags}
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
// Make sure to notify the old relay too
relays.push(url)
return publishThunk({event, relays})
}
export const addRelay = async (url: string, mode: RelayMode) => {
await forceLoadUserRelayList([])
const list = get(userRelayList) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
const event = {kind: list.kind, content: list.event?.content || "", tags}
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const setRelays = async (tags: string[][]) => {
const router = Router.get()
const event = makeEvent(RELAYS, {tags})
const relays = router
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return publishThunk({event, relays})
}
export const setReadRelays = async (urls: string[]) => {
await forceLoadUserRelayList([])
const list = get(userRelayList) || makeList({kind: RELAYS})
const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(nth(1))
const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
const readTags = urls.map(url => ["r", url, RelayMode.Read])
const tags = [...writeTags, ...readTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const setWriteRelays = async (urls: string[]) => {
await forceLoadUserRelayList([])
const list = get(userRelayList) || makeList({kind: RELAYS})
const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(nth(1))
const readTags = readRelays.map(url => ["r", url, RelayMode.Read])
const writeTags = urls.map(url => ["r", url, RelayMode.Write])
const tags = [...readTags, ...writeTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
// NIP 17
export const removeMessagingRelay = async (url: string) => {
await forceLoadUserMessagingRelayList([])
const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const addMessagingRelay = async (url: string) => {
await forceLoadUserMessagingRelayList([])
const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const setMessagingRelays = async (urls: string[]) => {
const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])})
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
// Blocked Relays
export const removeBlockedRelay = async (url: string) => {
await forceLoadUserBlockedRelayList([])
const list = get(userBlockedRelayList) || makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const addBlockedRelay = async (url: string) => {
await forceLoadUserBlockedRelayList([])
const list = get(userBlockedRelayList) || makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const setBlockedRelays = async (urls: string[]) => {
const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])})
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
// Search Relays
export const removeSearchRelay = async (url: string) => {
await forceLoadUserSearchRelayList([])
const list = get(userSearchRelayList) || makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const addSearchRelay = async (url: string) => {
await forceLoadUserSearchRelayList([])
const list = get(userSearchRelayList) || makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const setSearchRelays = async (urls: string[]) => {
const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])})
const relays = Router.get().FromUser().getUrls()
return publishThunk({event, relays})
}
// NIP 01
export const setProfile = (profile: Profile) => {
const router = Router.get()
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
return publishThunk({event, relays})
}
// NIP 02
export const unfollow = async (value: string) => {
await forceLoadUserFollowList([])
const list = get(userFollowList) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const follow = async (tag: string[]) => {
await forceLoadUserFollowList([])
const list = get(userFollowList) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const unmute = async (value: string) => {
await forceLoadUserMuteList([])
const list = get(userMuteList) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const mutePublicly = async (tag: string[]) => {
await forceLoadUserMuteList([])
const list = get(userMuteList) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const mutePrivately = async (tag: string[]) => {
await forceLoadUserMuteList([])
const list = get(userMuteList) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const setMutes = async ({
publicTags,
privateTags,
}: {
publicTags?: string[][]
privateTags?: string[][]
}) => {
await forceLoadUserMuteList([])
const list = get(userMuteList) || makeList({kind: MUTES})
const event = await updateList(list, {publicTags, privateTags}).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const unpin = async (value: string) => {
await forceLoadUserPinList([])
const list = get(userPinList) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
export const pin = async (tag: string[]) => {
await forceLoadUserPinList([])
const list = get(userPinList) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(nip44EncryptToSelf)
const relays = Router.get().FromUser().policy(addMaximalFallbacks).getUrls()
return publishThunk({event, relays})
}
// NIP 59
export type SendWrappedOptions = Omit<ThunkOptions, "event" | "relays"> & {
event: EventTemplate
recipients: string[]
}
export const sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => {
const $pubkey = pubkey.get()
// Stabilize the event id across different wraps
if ($pubkey) {
event = prep(event, $pubkey, now())
}
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await loadMessagingRelayList(recipient))
return publishThunk({event, relays, recipient, ...options})
}),
),
)
}
// NIP 86
export const manageRelay = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await signer.get()!.sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
// NIP 29
export const createRoom = (url: string, room: RoomMeta) =>
publishThunk({event: makeRoomCreateEvent(room), relays: [url]})
export const deleteRoom = (url: string, room: RoomMeta) =>
publishThunk({event: makeRoomDeleteEvent(room), relays: [url]})
export const editRoom = (url: string, room: RoomMeta) =>
publishThunk({event: makeRoomEditEvent(room), relays: [url]})
export const joinRoom = (url: string, room: RoomMeta) =>
publishThunk({event: makeRoomJoinEvent(room), relays: [url]})
export const leaveRoom = (url: string, room: RoomMeta) =>
publishThunk({event: makeRoomLeaveEvent(room), relays: [url]})
export const addRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
publishThunk({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]})
export const removeRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
publishThunk({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]})
-5
View File
@@ -1,5 +0,0 @@
export type AppContext = {
dufflepudUrl?: string
}
export const appContext: AppContext = {}
-5
View File
@@ -1,5 +0,0 @@
import {Repository, Tracker} from "@welshman/net"
export const tracker = new Tracker()
export const repository = Repository.get()
+14
View File
@@ -0,0 +1,14 @@
import {App} from "./app.js"
import type {AppOptions} from "./app.js"
import {defaultAppPolicies} from "./policy.js"
/**
* Creates a batteries-included app: an `App` wired with the default app
* policies (event ingestion, relay-stats collection, gift-wrap unwrapping).
* Reach data modules via `app.use(Profiles)`, `app.use(FollowLists)`, etc.
*
* For a bare app (no default side effects) construct `new App(...)`
* directly, or pass your own `policies`.
*/
export const createApp = (options: AppOptions = {}) =>
new App({...options, policies: options.policies ?? defaultAppPolicies})
-44
View File
@@ -1,44 +0,0 @@
import {Scope, FeedController, FeedControllerOptions, Feed} from "@welshman/feeds"
import {pubkey, signer} from "./session.js"
import {getWotGraph, getMaxWot, getFollows, getNetwork, getFollowers} from "./wot.js"
export const getPubkeysForScope = (scope: string) => {
const $pubkey = pubkey.get()
if (!$pubkey) {
return []
}
switch (scope) {
case Scope.Self:
return [$pubkey]
case Scope.Follows:
return getFollows($pubkey)
case Scope.Network:
return getNetwork($pubkey)
case Scope.Followers:
return getFollowers($pubkey)
default:
return []
}
}
export const getPubkeysForWOTRange = (min: number, max: number) => {
const pubkeys = []
const $maxWot = getMaxWot()
const thresholdMin = $maxWot * min
const thresholdMax = $maxWot * max
for (const [tpk, score] of getWotGraph().entries()) {
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(tpk)
}
}
return pubkeys
}
type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
export const makeFeedController = (options: MakeFeedControllerOptions) =>
new FeedController({getPubkeysForScope, getPubkeysForWOTRange, signer: signer.get(), ...options})
-33
View File
@@ -1,33 +0,0 @@
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const followListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [FOLLOWS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: followList => followList.event.pubkey,
})
export const followLists = deriveItems(followListsByPubkey)
export const getFollowListsByPubkey = getter(followListsByPubkey)
export const getFollowLists = getter(followLists)
export const getFollowList = (pubkey: string) => getFollowListsByPubkey().get(pubkey)
export const forceLoadFollowList = makeForceLoadItem(makeOutboxLoader(FOLLOWS), getFollowList)
export const loadFollowList = makeLoadItem(makeOutboxLoader(FOLLOWS), getFollowList)
export const deriveFollowList = makeDeriveItem(followListsByPubkey, loadFollowList)
-150
View File
@@ -1,150 +0,0 @@
import {writable, Subscriber} from "svelte/store"
import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib"
import {
getter,
deriveItems,
deriveDeduplicated,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
} from "@welshman/store"
import {deriveProfile, loadProfile} from "./profiles.js"
import {appContext} from "./context.js"
export type Handle = {
nip05: string
pubkey?: string
nip46?: string[]
relays?: string[]
}
export async function queryProfile(nip05: string) {
const parts = nip05.split("@")
const name = parts.length > 1 ? parts[0] : "_"
const domain = last(parts)
try {
const {
names,
relays = {},
nip46 = {},
} = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`)
const pubkey = names[name]
if (!pubkey) {
return undefined
}
return {
nip05,
pubkey,
nip46: nip46[pubkey],
relays: relays[pubkey],
}
} catch (_e) {
return undefined
}
}
export const handlesByNip05 = writable(new Map<string, Handle>())
export const handles = deriveItems(handlesByNip05)
export const getHandlesByNip05 = getter(handlesByNip05)
export const getHandles = getter(handles)
export const getHandle = (nip05: string) => getHandlesByNip05().get(nip05)
export const handleSubscribers: Subscriber<Handle>[] = []
export const notifyHandle = (handle: Handle) => handleSubscribers.forEach(sub => sub(handle))
export const onHandle = (sub: (handle: Handle) => void) => {
handleSubscribers.push(sub)
return () => {
const i = handleSubscribers.findIndex(s => s === sub)
if (i !== -1) handleSubscribers.splice(i, 1)
}
}
export const fetchHandle = batcher(800, async (nip05s: string[]) => {
const result = new Map<string, Handle>()
// Use dufflepud if we it's set up to protect user privacy, otherwise fetch directly
if (appContext.dufflepudUrl) {
const res: any = await tryCatch(
async () => await postJson(`${appContext.dufflepudUrl}/handle/info`, {handles: nip05s}),
)
for (const {handle: nip05, info} of res?.data || []) {
if (info) {
result.set(nip05, {...info, nip05})
}
}
} else {
const results = await Promise.all(
nip05s.map(async nip05 => ({
nip05,
info: await tryCatch(async () => await queryProfile(nip05)),
})),
)
for (const {nip05, info} of results) {
if (info) {
result.set(nip05, {...info, nip05})
}
}
}
handlesByNip05.update($handlesByNip05 => {
for (const [nip05, info] of result) {
$handlesByNip05.set(nip05, info)
}
return $handlesByNip05
})
for (const info of result.values()) {
notifyHandle(info)
}
return nip05s.map(nip05 => result.get(nip05))
})
export const forceLoadHandle = makeForceLoadItem(fetchHandle, getHandle)
export const loadHandle = makeLoadItem(fetchHandle, getHandle)
export const deriveHandle = makeDeriveItem(handlesByNip05, loadHandle)
export const loadHandleForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await loadProfile(pubkey, relays)
return $profile?.nip05 ? loadHandle($profile.nip05) : undefined
}
export const deriveHandleForPubkey = (pubkey: string, relays: string[] = []) => {
loadHandleForPubkey(pubkey, relays)
return deriveDeduplicated(
[handlesByNip05, deriveProfile(pubkey, relays)],
([$handlesByNip05, $profile]) => {
if (!$profile?.nip05) return undefined
const handle = $handlesByNip05.get($profile.nip05)
if (handle?.pubkey !== pubkey) return undefined
return handle
},
)
}
export const displayNip05 = (nip05: string) =>
nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05
export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
+33 -111
View File
@@ -1,112 +1,34 @@
export * from "./blossom.js" export * from "./app.js"
export * from "./context.js" export * from "./policy.js"
export * from "./core.js"
export * from "./commands.js"
export * from "./feeds.js"
export * from "./follows.js"
export * from "./handles.js"
export * from "./mutes.js"
export * from "./plaintext.js"
export * from "./profiles.js"
export * from "./pins.js"
export * from "./relays.js"
export * from "./relayStats.js"
export * from "./relayLists.js"
export * from "./blockedRelayLists.js"
export * from "./messagingRelayLists.js"
export * from "./search.js"
export * from "./session.js"
export * from "./sync.js"
export * from "./tags.js"
export * from "./thunk.js"
export * from "./topics.js"
export * from "./user.js" export * from "./user.js"
export * from "./wot.js" export * from "./session.js"
export * from "./zappers.js" export * from "./logging.js"
export * from "./createApp.js"
import {derived} from "svelte/store" export * from "./plugins/base.js"
import {sortBy, throttleWithValue} from "@welshman/lib" export * from "./plugins/network.js"
import { export * from "./plugins/stores.js"
isEphemeralKind, export * from "./plugins/router.js"
isDVMKind, export * from "./plugins/relays.js"
WRAP, export * from "./plugins/relayStats.js"
RelayMode, export * from "./plugins/relayLists.js"
RelayProfile, export * from "./plugins/blockedRelayLists.js"
getRelaysFromList, export * from "./plugins/plaintext.js"
} from "@welshman/util" export * from "./plugins/profiles.js"
import {routerContext} from "@welshman/router" export * from "./plugins/follows.js"
import {Pool, SocketEvent, isRelayEvent, netContext} from "@welshman/net" export * from "./plugins/mutes.js"
import {pubkey, unwrapAndStore} from "./session.js" export * from "./plugins/pins.js"
import {repository, tracker} from "./core.js" export * from "./plugins/blossom.js"
import {getRelays, loadRelay} from "./relays.js" export * from "./plugins/messagingRelayLists.js"
import {trackRelayStats, getRelayQuality} from "./relayStats.js" export * from "./plugins/searchRelayLists.js"
import {deriveRelayList, getRelayList} from "./relayLists.js" export * from "./plugins/handles.js"
import {deriveSearchRelayList, getSearchRelayList} from "./searchRelayLists.js" export * from "./plugins/zappers.js"
import {deriveBlockedRelayList, getBlockedRelayList} from "./blockedRelayLists.js" export * from "./plugins/topics.js"
import {deriveMessagingRelayList, getMessagingRelayList} from "./messagingRelayLists.js" export * from "./plugins/tags.js"
export * from "./plugins/wot.js"
// Sync relays with our database export * from "./plugins/feeds.js"
export * from "./plugins/search.js"
Pool.get().subscribe(socket => { export * from "./plugins/sync.js"
loadRelay(socket.url) export * from "./plugins/wraps.js"
trackRelayStats(socket) export * from "./plugins/rooms.js"
export * from "./plugins/relayManagement.js"
socket.on(SocketEvent.Receive, message => { export * from "./plugins/thunk.js"
if (isRelayEvent(message)) {
const event = message[2]
if (
!isDVMKind(event.kind) &&
!isEphemeralKind(event.kind) &&
netContext.isEventValid(event, socket.url)
) {
tracker.track(event.id, socket.url)
if (event.kind === WRAP) {
unwrapAndStore(event)
} else {
repository.publish(event)
}
}
}
})
})
// Configure the router and add a few other relay utils
const _relayGetter = (fn?: (relay: RelayProfile) => any) =>
throttleWithValue(200, () => {
let _relays = getRelays()
if (fn) {
_relays = _relays.filter(fn)
}
return sortBy(r => -getRelayQuality(r.url), _relays)
.slice(0, 5)
.map(r => r.url)
})
export const getPubkeyRelays = (pubkey: string, mode?: RelayMode) => {
if (mode === RelayMode.Search) return getRelaysFromList(getSearchRelayList(pubkey))
if (mode === RelayMode.Blocked) return getRelaysFromList(getBlockedRelayList(pubkey))
if (mode === RelayMode.Messaging) return getRelaysFromList(getMessagingRelayList(pubkey))
return getRelaysFromList(getRelayList(pubkey), mode)
}
export const derivePubkeyRelays = (pubkey: string, mode?: RelayMode) => {
if (mode === RelayMode.Search)
return derived(deriveSearchRelayList(pubkey), list => getRelaysFromList(list))
if (mode === RelayMode.Blocked)
return derived(deriveBlockedRelayList(pubkey), list => getRelaysFromList(list))
if (mode === RelayMode.Messaging)
return derived(deriveMessagingRelayList(pubkey), list => getRelaysFromList(list))
return derived(deriveRelayList(pubkey), list => getRelaysFromList(list, mode))
}
routerContext.getUserPubkey = () => pubkey.get()
routerContext.getPubkeyRelays = getPubkeyRelays
routerContext.getRelayQuality = getRelayQuality
routerContext.getDefaultRelays = _relayGetter()
routerContext.getIndexerRelays = _relayGetter()
routerContext.getSearchRelays = _relayGetter(r => r?.supported_nips?.includes?.("50"))
+46
View File
@@ -0,0 +1,46 @@
import {randomId} from "@welshman/lib"
import {WrappedSigner} from "@welshman/signer"
import type {ISigner} from "@welshman/signer"
/**
* A structured, extensible log event. The built-in `signer` variant tracks each
* signer operation (sign/encrypt/decrypt/getPubkey); the open variant lets
* callers emit their own event types — it's not just a string.
*/
export type LogMessage =
| {
type: "signer"
id: string
method: string
status: "pending" | "success" | "failure"
error?: unknown
at: number
}
| {type: string; at: number; [key: string]: unknown}
/**
* An `ISigner` wrapper that emits a structured `LogMessage` (as a "message"
* event on itself) for every operation it performs. `User.fromSigner` wraps
* signers in this so they're observable; subscribe via `makeAppPolicyLogger`.
*/
export class LoggingSigner extends WrappedSigner {
constructor(signer: ISigner) {
super(signer, async (method, thunk) => {
const id = randomId()
this.emit("message", {type: "signer", id, method, status: "pending", at: Date.now()})
try {
const result = await thunk()
this.emit("message", {type: "signer", id, method, status: "success", at: Date.now()})
return result
} catch (error) {
this.emit("message", {type: "signer", id, method, status: "failure", error, at: Date.now()})
throw error
}
})
}
}
-43
View File
@@ -1,43 +0,0 @@
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const messagingRelayListsByPubkey = deriveItemsByKey({
repository,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
filters: [{kinds: [MESSAGING_RELAYS]}],
getKey: messagingRelayLists => messagingRelayLists.event.pubkey,
})
export const messagingRelayLists = deriveItems(messagingRelayListsByPubkey)
export const getMessagingRelayListsByPubkey = getter(messagingRelayListsByPubkey)
export const getMessagingRelayLists = getter(messagingRelayLists)
export const getMessagingRelayList = (pubkey: string) =>
getMessagingRelayListsByPubkey().get(pubkey)
export const forceLoadMessagingRelayList = makeForceLoadItem(
makeOutboxLoader(MESSAGING_RELAYS),
getMessagingRelayList,
)
export const loadMessagingRelayList = makeLoadItem(
makeOutboxLoader(MESSAGING_RELAYS),
getMessagingRelayList,
)
export const deriveMessagingRelayList = makeDeriveItem(
messagingRelayListsByPubkey,
loadMessagingRelayList,
)
-49
View File
@@ -1,49 +0,0 @@
import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent, PublishedList} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {ensurePlaintext} from "./plaintext.js"
import {getSession} from "./session.js"
import {makeOutboxLoader} from "./relayLists.js"
export const muteListsByPubkey = deriveItemsByKey<PublishedList>({
repository,
eventToItem: async (event: TrustedEvent) => {
const content = await ensurePlaintext(event)
// If this is our own mute list (we have a session for it) but it couldn't be
// decrypted yet because no signer is available, don't cache a result with empty
// private tags — that would get stuck permanently since deriveItemsByKey won't
// re-process an already-seen event id. Returning undefined leaves it uncached so it's
// retried once a signer is available. For other pubkeys' lists (no session) we fall
// through and read just the public tags, as before.
if (event.content && content === undefined && getSession(event.pubkey)) {
return undefined
}
return readList(asDecryptedEvent(event, {content}))
},
filters: [{kinds: [MUTES]}],
getKey: mute => mute.event.pubkey,
})
export const muteLists = deriveItems(muteListsByPubkey)
export const getMuteListsByPubkey = getter(muteListsByPubkey)
export const getMuteLists = getter(muteLists)
export const getMuteList = (pubkey: string) => getMuteListsByPubkey().get(pubkey)
export const forceLoadMuteList = makeForceLoadItem(makeOutboxLoader(MUTES), getMuteList)
export const loadMuteList = makeLoadItem(makeOutboxLoader(MUTES), getMuteList)
export const deriveMuteList = makeDeriveItem(muteListsByPubkey, loadMuteList)
-33
View File
@@ -1,33 +0,0 @@
import {PINS, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const pinListsByPubkey = deriveItemsByKey({
repository,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
filters: [{kinds: [PINS]}],
getKey: pins => pins.event.pubkey,
})
export const pinLists = deriveItems(pinListsByPubkey)
export const getPinListsByPubkey = getter(pinListsByPubkey)
export const getPinLists = getter(pinLists)
export const getPinList = (pubkey: string) => getPinListsByPubkey().get(pubkey)
export const forceLoadPinList = makeForceLoadItem(makeOutboxLoader(PINS), getPinList)
export const loadPinList = makeLoadItem(makeOutboxLoader(PINS), getPinList)
export const derivePinList = makeDeriveItem(pinListsByPubkey, loadPinList)
-44
View File
@@ -1,44 +0,0 @@
import {writable} from "svelte/store"
import {assoc} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {decrypt} from "@welshman/signer"
import {getSigner, getSession} from "./session.js"
export const plaintext = withGetter(writable<Record<string, string>>({}))
export const getPlaintext = (e: TrustedEvent) => plaintext.get()[e.id]
export const setPlaintext = (e: TrustedEvent, content: string) =>
plaintext.update(assoc(e.id, content))
export const ensurePlaintext = async (e: TrustedEvent) => {
// Check for key presence rather than truthiness so a legitimately empty decrypted
// result ("") is treated as cached and we don't re-decrypt (and re-hit the signer) on
// every call.
if (e.content && plaintext.get()[e.id] === undefined) {
const $session = getSession(e.pubkey)
if (!$session) return
const $signer = getSigner($session)
if (!$signer) return
let result
try {
result = await decrypt($signer, e.pubkey, e.content)
} catch (e: any) {
if (!String(e).match(/invalid base64/)) {
throw e
}
}
if (result !== undefined) {
setPlaintext(e, result)
}
}
return getPlaintext(e)
}
+178
View File
@@ -0,0 +1,178 @@
import {writable, derived} from "svelte/store"
import type {Readable, Unsubscriber} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveItems, getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
import type {IApp} from "../app.js"
import {Stores} from "./stores.js"
/**
* Utility type which allows for using the same value both for hot gets and derived subscriptions
*/
export type Projection<T> = {
get: () => T
$: Readable<T>
}
export const projection = <T>($: Readable<T>, get = getter($)) => ({$, get})
/**
* Build a `Projection` derived from another `Projection`: re-read `src`
* reactively via `.$` or synchronously via `.get()`.
*/
export const projectFrom = <S, U>(src: Projection<S>, read: ($: S) => U): Projection<U> =>
projection(derived(src.$, read), () => read(src.get()))
/**
* Base class for a reactive, keyed collection of "local" (non-event) data —
* things like relay stats or NIP-11 profiles that aren't backed by the
* repository. The collection owns its own map.
*
* `index` (map) and `all` (values) are `Projection`s — subscribe via `.$`,
* snapshot via `.get()`. Per-key access is `one(key)`, a plain on-demand store
* (snapshot with svelte's `get(...)`, or read `get(key)` directly).
*/
export class MapPlugin<T> {
protected store = writable(new Map<string, T>())
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
subs: ((key: string, value: Maybe<T>) => void)[] = []
constructor(protected readonly app: IApp) {
this.index = projection(this.store)
this.all = projection(deriveItems(this.store))
this.one = makeDeriveItem(this.store)
}
get = (key: string) => this.index.get().get(key)
project = <U>(key: string, read: (item: Maybe<T>) => U): Projection<U> =>
projection(derived(this.one(key), read), () => read(this.get(key)))
set = (key: string, value: T) => {
this.store.update($items => {
$items.set(key, value)
return $items
})
this.emitItem(key, value)
}
delete = (key: string) => {
this.store.update($items => {
$items.delete(key)
return $items
})
this.emitItem(key, undefined)
}
clear = () => {
const keys = Array.from(this.index.get().keys())
this.store.set(new Map())
for (const key of keys) {
this.emitItem(key, undefined)
}
}
onItem = (subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber => {
this.subs.push(subscriber)
return () => {
const i = this.subs.indexOf(subscriber)
if (i !== -1) this.subs.splice(i, 1)
}
}
protected emitItem = (key: string, value: Maybe<T>) => {
for (const subscriber of this.subs) {
subscriber(key, value)
}
}
}
/**
* A `MapPlugin` collection that knows how to lazily load items by key from the
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`one` are derived
* from it (with per-key caching and backoff via `makeLoadItem`).
*/
export abstract class LoadableMapPlugin<T> extends MapPlugin<T> {
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(app: IApp, options: MakeLoadItemOptions = {}) {
super(app)
// Subclasses implement `fetch` as an arrow field, whose initializer runs
// *after* super() — so `this.fetch` is undefined here. makeLoadItem captures
// its loadItem eagerly, so we defer the lookup to call time via this wrapper.
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
const read = (key: string) => this.index.get().get(key)
this.load = makeLoadItem(fetch, read, options)
this.forceLoad = makeForceLoadItem(fetch, read)
this.one = makeDeriveItem(this.store, this.load)
}
}
export type DerivedPluginOptions<T> = {
filters: Filter[]
eventToItem: EventToItem<T>
getKey: (item: T) => string
loadOptions?: MakeLoadItemOptions
}
/**
* Base class for a reactive, keyed collection of data derived from nostr events.
* The repository is the single source of truth — the collection is a live view
* over `app.itemsByKey`, never a duplicated map. Subclasses implement `fetch`
* (how to load an item by key from the network) and pass the filters/decoder via
* `super`.
*
* `index` (map) and `all` (values) are `Projection`s — subscribe via `.$`,
* snapshot via `.get()`. Per-key access is `one(key)`, a plain on-demand store.
*/
export abstract class DerivedPlugin<T> {
index: Projection<ItemsByKey<T>>
all: Projection<T[]>
one: (key?: string, ...args: any[]) => Readable<Maybe<T>>
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(
protected readonly app: IApp,
options: DerivedPluginOptions<T>,
) {
const index = app.use(Stores).itemsByKey<T>({
filters: options.filters,
eventToItem: options.eventToItem,
getKey: options.getKey,
})
this.index = projection(index)
this.all = projection(deriveItems(index))
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
const read = (key: string) => this.index.get().get(key)
this.load = makeLoadItem(fetch, read, options.loadOptions)
this.forceLoad = makeForceLoadItem(fetch, read)
this.one = makeDeriveItem(index, this.load)
}
get = (key: string) => this.index.get().get(key)
project = <U>(key: string, read: (item: Maybe<T>) => U): Projection<U> =>
projection(derived(this.one(key), read), () => read(this.get(key)))
}
@@ -0,0 +1,47 @@
import {BLOCKED_RELAYS} from "@welshman/util"
import {BlockedRelayList, BlockedRelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
/**
* Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model,
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
* blocked relays are never selected.
*/
export class BlockedRelayLists extends DerivedPlugin<BlockedRelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [BLOCKED_RELAYS]}],
eventToItem: BlockedRelayList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOCKED_RELAYS]}, relayHints)
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => list?.urls() ?? [])
update = async (fn: (builder: BlockedRelayListBuilder) => void) => {
const user = User.require(this.app)
const builder = new BlockedRelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
}
+23
View File
@@ -0,0 +1,23 @@
import {BLOSSOM_SERVERS} from "@welshman/util"
import {BlossomServerList} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import type {IApp} from "../app.js"
/**
* Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox
* model (the author's write relays), so it depends on the relay-list collection.
*/
export class BlossomServerLists extends DerivedPlugin<BlossomServerList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [BLOSSOM_SERVERS]}],
eventToItem: BlossomServerList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [BLOSSOM_SERVERS]}, relayHints)
}
}
+69
View File
@@ -0,0 +1,69 @@
import {Scope, FeedController} from "@welshman/feeds"
import type {FeedControllerOptions, Feed} from "@welshman/feeds"
import type {AdapterContext} from "@welshman/net"
import type {IApp} from "../app.js"
import {Router} from "./router.js"
import {Wot} from "./wot.js"
export type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
/**
* Builds `FeedController`s wired to this app. Scope/WOT pubkey resolution is
* delegated to `Wot`, and feeds fetch through THIS app's net context (pool +
* repository) rather than the global one.
*/
export class Feeds {
constructor(readonly app: IApp) {}
getPubkeysForScope = (scope: Scope): string[] => {
const $pubkey = this.app.user?.pubkey
if (!$pubkey) {
return []
}
switch (scope) {
case Scope.Self:
return [$pubkey]
case Scope.Follows:
return this.app.use(Wot).follows($pubkey).get()
case Scope.Network:
return this.app.use(Wot).network($pubkey).get()
case Scope.Followers:
return this.app.use(Wot).followers($pubkey).get()
default:
return []
}
}
getPubkeysForWOTRange = (min: number, max: number): string[] => {
const pubkeys = []
const $maxWot = this.app.use(Wot).max.get() ?? 0
const thresholdMin = $maxWot * min
const thresholdMax = $maxWot * max
for (const [tpk, score] of this.app.use(Wot).graph.get().entries()) {
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(tpk)
}
}
return pubkeys
}
// The net seam: route feed requests through this app's pool/repository so
// feeds fetch through THIS app rather than the global net context.
get netContext(): AdapterContext {
return {pool: this.app.pool, repository: this.app.repository}
}
makeFeedController = (options: MakeFeedControllerOptions) =>
new FeedController({
router: this.app.use(Router),
getPubkeysForScope: this.getPubkeysForScope,
getPubkeysForWOTRange: this.getPubkeysForWOTRange,
signer: this.app.user?.signer,
context: this.netContext,
...options,
})
}
+40
View File
@@ -0,0 +1,40 @@
import {FOLLOWS} from "@welshman/util"
import {FollowList, FollowListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {User} from "../user.js"
import type {IApp} from "../app.js"
/**
* Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the
* author's write relays), so it depends on the relay-list collection.
*/
export class FollowLists extends DerivedPlugin<FollowList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [FOLLOWS]}],
eventToItem: FollowList.factory(app.user?.signer),
getKey: followList => followList.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [FOLLOWS]}, relayHints)
}
update = async (fn: (builder: FollowListBuilder) => void) => {
const user = User.require(this.app)
const builder = new FollowListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
follow = (tag: string[]) => this.update(builder => builder.addPublic(tag))
unfollow = (value: string) => this.update(builder => builder.removeFollow(value))
}
+91
View File
@@ -0,0 +1,91 @@
import {tryCatch, batcher, postJson} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {queryProfile, displayNip05} from "@welshman/util"
import type {Handle} from "@welshman/util"
import type {Profile} from "@welshman/domain"
import {deriveDeduplicated} from "@welshman/store"
import {LoadableMapPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import type {IApp} from "../app.js"
import {Profiles} from "./profiles.js"
/**
* NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
* items aren't nostr events, they're fetched over HTTP (either directly from
* each domain's `.well-known/nostr.json`, or via a dufflepud proxy to protect
* user privacy). Depends on the profiles collection to resolve a pubkey's
* handle.
*/
export class Handles extends LoadableMapPlugin<Handle> {
constructor(app: IApp) {
super(app)
}
fetch = batcher(800, async (nip05s: string[]) => {
const result = new Map<string, Handle>()
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
if (this.app.config.dufflepudUrl) {
const res: any = await tryCatch(
async () =>
await postJson(`${this.app.config.dufflepudUrl}/handle/info`, {handles: nip05s}),
)
for (const {handle: nip05, info} of res?.data || []) {
if (info) {
result.set(nip05, {...info, nip05})
}
}
} else {
const results = await Promise.all(
nip05s.map(async nip05 => ({
nip05,
info: await tryCatch(async () => await queryProfile(nip05)),
})),
)
for (const {nip05, info} of results) {
if (info) {
result.set(nip05, {...info, nip05})
}
}
}
for (const [nip05, info] of result) {
this.set(nip05, info)
}
return nip05s.map(nip05 => result.get(nip05))
})
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await this.app.use(Profiles).load(pubkey, relays)
const nip05 = $profile?.nip05()
return nip05 ? this.load(nip05) : undefined
}
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Handle>> => {
this.loadForPubkey(pubkey, relays)
const read = ([$handlesByNip05, $profile]: [ReadonlyMap<string, Handle>, Maybe<Profile>]) => {
const nip05 = $profile?.nip05()
if (!nip05) return undefined
const handle = $handlesByNip05.get(nip05)
if (handle?.pubkey !== pubkey) return undefined
return handle
}
return projection(
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
() => read([this.index.get(), this.app.use(Profiles).get(pubkey)]),
)
}
display = (nip05: string) => displayNip05(nip05)
}
@@ -0,0 +1,47 @@
import {MESSAGING_RELAYS} from "@welshman/util"
import {MessagingRelayList, MessagingRelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
/**
* Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class MessagingRelayLists extends DerivedPlugin<MessagingRelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [MESSAGING_RELAYS]}],
eventToItem: MessagingRelayList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MESSAGING_RELAYS]}, relayHints)
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => list?.urls() ?? [])
update = async (fn: (builder: MessagingRelayListBuilder) => void) => {
const user = User.require(this.app)
const builder = new MessagingRelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
}
+84
View File
@@ -0,0 +1,84 @@
import {nthEq} from "@welshman/lib"
import {MUTES} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {MuteList, MuteListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {Plaintext} from "./plaintext.js"
import {User} from "../user.js"
/**
* A signer that decrypts via the app's plaintext cache (keyed by event), falling
* back to the real signer. Lets `MuteList.fromEvent(event, signer)` reuse cached
* decryptions instead of re-decrypting. Returns undefined when there's no user,
* so the reader falls back to public-only.
*/
const makeCachedSigner = (app: IApp, event: TrustedEvent): ISigner | undefined => {
const user = app.user
if (!user) return undefined
const {signer} = user
const decryptVia =
(fallback: (pubkey: string, message: string) => Promise<string>) =>
async (pubkey: string, message: string) =>
(await app.use(Plaintext).ensure(event)) ?? fallback(pubkey, message)
return {
sign: (event, options) => signer.sign(event, options),
getPubkey: () => signer.getPubkey(),
nip04: {
encrypt: (pubkey, message) => signer.nip04.encrypt(pubkey, message),
decrypt: decryptVia((pubkey, message) => signer.nip04.decrypt(pubkey, message)),
},
nip44: {
encrypt: (pubkey, message) => signer.nip44.encrypt(pubkey, message),
decrypt: decryptVia((pubkey, message) => signer.nip44.decrypt(pubkey, message)),
},
}
}
/**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
* encrypted content, decoded through the plaintext cache (via a cache-backed
* signer passed to the reader).
*/
export class MuteLists extends DerivedPlugin<MuteList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [MUTES]}],
eventToItem: event => MuteList.fromEvent(event, makeCachedSigner(app, event)),
getKey: mute => mute.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [MUTES]}, relayHints)
}
update = async (fn: (builder: MuteListBuilder) => void) => {
const user = User.require(this.app)
const builder = new MuteListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
mutePublicly = (tag: string[]) => this.update(builder => builder.addPublic(tag))
mutePrivately = (tag: string[]) => this.update(builder => builder.addPrivate(tag))
unmute = (value: string) => this.update(builder => builder.drop(nthEq(1, value)))
setMutes = (updates: {publicTags?: string[][]; privateTags?: string[][]}) =>
this.update(builder => {
if (updates.publicTags) builder.clearPublic().addPublic(...updates.publicTags)
if (updates.privateTags) builder.clearPrivate().addPrivate(...updates.privateTags)
})
}
+63
View File
@@ -0,0 +1,63 @@
import {chunk, first} from "@welshman/lib"
import {sortEventsDesc} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
import type {
Loader,
LoaderOptions,
RequestOptions,
PublishOptions,
DiffOptions,
PullOptions,
PushOptions,
} from "@welshman/net"
import {addMinimalFallbacks} from "@welshman/router"
import {Router} from "./router.js"
import {RelayLists} from "./relayLists.js"
import type {IApp} from "../app.js"
/**
* Net utilities bound to the app's net context (its pool + repository). Reach
* it via `app.use(Network)`; `load` is a shared, batched loader.
*/
export class Network {
load: Loader
constructor(readonly app: IApp) {
this.load = this.makeLoader({delay: 50, timeout: 3000, threshold: 0.5})
}
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.app.netContext})
request = (options: Omit<RequestOptions, "context">) =>
request({...options, context: this.app.netContext})
publish = (options: Omit<PublishOptions, "context">) =>
publish({...options, context: this.app.netContext})
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.app.netContext})
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.app.netContext})
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.app.netContext})
loadUsingOutbox = async (pubkey: string, filter: Filter = {}, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, authors: [pubkey]}]
const writeRelays = (await this.app.use(RelayLists).load(pubkey))?.writeUrls() ?? []
const allRelays = this.app
.use(Router)
.FromRelays([...relayHints, ...writeRelays])
.policy(addMinimalFallbacks)
.limit(8)
.getUrls()
for (const relays of chunk(2, allRelays)) {
const events = await this.load({filters, relays})
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import {PINS} from "@welshman/util"
import {PinList, PinListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {User} from "../user.js"
import type {IApp} from "../app.js"
/**
* NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model
* (the author's write relays), so it depends on the relay-list collection.
*/
export class PinLists extends DerivedPlugin<PinList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [PINS]}],
eventToItem: PinList.factory(app.user?.signer),
getKey: pins => pins.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PINS]}, relayHints)
}
update = async (fn: (builder: PinListBuilder) => void) => {
const user = User.require(this.app)
const builder = new PinListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
pin = (tag: string[]) => this.update(builder => builder.pinPublicly(tag))
unpin = (value: string) => this.update(builder => builder.unpin(value))
}
+27
View File
@@ -0,0 +1,27 @@
import {decrypt} from "@welshman/signer"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MapPlugin} from "./base.js"
/**
* A cache of decrypted event content, keyed by event id.
*/
export class Plaintext extends MapPlugin<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
if (this.app.user?.pubkey !== event.pubkey) return
let result = this.get(event.id)
if (event.content && result === undefined) {
try {
result = await decrypt(this.app.user.signer, event.pubkey, event.content)
this.set(event.id, result)
} catch (e: any) {
if (!String(e).match(/invalid base64/)) {
throw e
}
}
}
return result
}
}
+51
View File
@@ -0,0 +1,51 @@
import {derived, readable} from "svelte/store"
import {PROFILE} from "@welshman/util"
import type {Maybe} from "@welshman/lib"
import {Profile, ProfileBuilder, displayPubkey} from "@welshman/domain"
import {DerivedPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Thunks} from "./thunk.js"
import {User} from "../user.js"
import type {IApp} from "../app.js"
/**
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
* write relays), resolved through the relay-list collection at fetch time.
*/
export class Profiles extends DerivedPlugin<Profile> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [PROFILE]}],
eventToItem: Profile.factory(app.user?.signer),
getKey: profile => profile.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [PROFILE]}, relayHints)
}
// Publish the app user's kind-0, merging `values` over their current profile
// (preserving any unknown metadata fields and source tags).
publish = async (values: Record<string, any>) => {
const user = User.require(this.app)
const router = this.app.use(Router)
const relays = router.merge([router.Index(), router.FromUser()]).getUrls()
const builder = new ProfileBuilder(this.get(user.pubkey)).update(values)
const event = await builder.toTemplate()
return this.app.use(Thunks).publish({event, relays})
}
display = (pubkey: string | undefined, ...args: any[]): Projection<string> => {
const read = ($profile: Maybe<Profile>) =>
pubkey ? ($profile?.display() ?? displayPubkey(pubkey)) : ""
return projection(
pubkey ? derived(this.one(pubkey, ...args), read) : readable(""),
() => read(pubkey ? this.get(pubkey) : undefined),
)
}
}
+88
View File
@@ -0,0 +1,88 @@
import {RELAYS, RelayMode, getRelayTagValues} from "@welshman/util"
import {RelayList, RelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {addMinimalFallbacks} from "@welshman/router"
import {Router} from "./router.js"
import {Network} from "./network.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
/**
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
* outbox-model load depends on (see `Network.loadUsingOutbox`).
*/
export class RelayLists extends DerivedPlugin<RelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [RELAYS]}],
eventToItem: RelayList.factory(app.user?.signer),
getKey: (list: RelayList) => list.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
const networking = this.app.use(Network)
const router = this.app.use(Router)
return Promise.all([
networking.load({filters, relays: router.FromRelays(relayHints).getUrls()}),
networking.load({filters, relays: router.FromPubkey(pubkey).getUrls()}),
networking.load({filters, relays: router.Index().getUrls()}),
])
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => list?.urls() ?? [])
readUrls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => list?.readUrls() ?? [])
writeUrls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => list?.writeUrls() ?? [])
// NIP-65 relay-list mutations for the app's user
update = async (fn: (builder: RelayListBuilder) => void) => {
const user = User.require(this.app)
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
addRelay = (url: string, mode: RelayMode) => this.update(builder => builder.addUrl(url, mode))
setReadRelays = (urls: string[]) => this.update(builder => builder.setReadUrls(urls))
setWriteRelays = (urls: string[]) => this.update(builder => builder.setWriteUrls(urls))
removeRelay = async (url: string, mode: RelayMode) => {
const user = User.require(this.app)
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
const event = await builder.removeUrl(url, mode).toTemplate(user.signer)
// publishToOutbox is outbox-only, so build relays here to also notify the
// removed relay of its removal
const relays = [url, ...this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls()]
return this.app.use(Thunks).publish({event, relays})
}
setRelays = async (tags: string[][]) => {
const user = User.require(this.app)
const router = this.app.use(Router)
const builder = new RelayListBuilder(await this.forceLoad(user.pubkey))
const event = await builder.setTags(tags).toTemplate(user.signer)
const relays = router
.merge([router.Index(), router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return this.app.use(Thunks).publish({event, relays})
}
}
@@ -0,0 +1,21 @@
import {makeHttpAuth, sendManagementRequest} from "@welshman/util"
import type {ManagementRequest} from "@welshman/util"
import {User} from "../user.js"
import type {IApp} from "../app.js"
/**
* NIP-86 relay management. Signs an HTTP-auth event as the app's user and
* sends an admin request to a relay's management endpoint.
*/
export class RelayManagement {
constructor(readonly app: IApp) {}
post = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await User.require(this.app).sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
}
+207
View File
@@ -0,0 +1,207 @@
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
import {SocketStatus, SocketEvent} from "@welshman/net"
import type {ClientMessage, RelayMessage, Socket} from "@welshman/net"
import {MapPlugin} from "./base.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
export type RelayStatsItem = {
url: string
first_seen: number
recent_errors: number[]
open_count: number
close_count: number
publish_count: number
request_count: number
event_count: number
last_open: number
last_close: number
last_error: number
last_publish: number
last_request: number
last_event: number
last_auth: number
publish_success_count: number
publish_failure_count: number
eose_count: number
notice_count: number
}
export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
url,
first_seen: now(),
recent_errors: [],
open_count: 0,
close_count: 0,
publish_count: 0,
request_count: 0,
event_count: 0,
last_open: 0,
last_close: 0,
last_error: 0,
last_publish: 0,
last_request: 0,
last_event: 0,
last_auth: 0,
publish_success_count: 0,
publish_failure_count: 0,
eose_count: 0,
notice_count: 0,
})
/**
* Per-relay connection statistics, keyed by url, plus the `getQuality` heuristic
* the router uses to rank relays. A pure store — the socket wiring that fills it
* lives in `appPolicyRelayStats`.
*/
export class RelayStats extends MapPlugin<RelayStatsItem> {
getQuality = (url: string) => {
// Skip non-relays entirely
if (!isRelayUrl(url)) return 0
// Skip relays the user has blocked
const pubkey = this.app.user?.pubkey
if (pubkey && this.app.use(BlockedRelayLists).urls(pubkey).get().includes(url)) {
return 0
}
const stats = this.get(url)
// If we have recent errors, skip it
if (stats) {
if (stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
if (stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
if (stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
}
// Prefer stuff we're connected to
if (this.app.pool.has(url)) return 1
// Prefer stuff we've connected to in the past
if (stats) return 0.9
// If it's not a weird url give it an ok score
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
return 0.8
}
// Default to a "meh" score
return 0.7
}
private update = batch(150, (batched: RelayStatsUpdate[]) => {
for (const [url, updates] of groupBy(([url]) => url, batched)) {
if (!url || !isRelayUrl(url)) {
console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
continue
}
const prev = this.get(url)
const next = prev ? {...prev} : makeRelayStatsItem(url)
for (const [, update] of updates) {
update(next)
}
this.set(url, next)
}
})
private onSocketSend = ([verb]: ClientMessage, url: string) => {
if (verb === "REQ") {
this.update([
url,
stats => {
stats.request_count++
stats.last_request = now()
},
])
} else if (verb === "EVENT") {
this.update([
url,
stats => {
stats.publish_count++
stats.last_publish = now()
},
])
}
}
private onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
if (verb === "OK") {
const [, ok] = extra
this.update([
url,
stats => {
if (ok) {
stats.publish_success_count++
} else {
stats.publish_failure_count++
}
},
])
} else if (verb === "AUTH") {
this.update([url, stats => (stats.last_auth = now())])
} else if (verb === "EVENT") {
this.update([
url,
stats => {
stats.event_count++
stats.last_event = now()
},
])
} else if (verb === "EOSE") {
this.update([url, stats => stats.eose_count++])
} else if (verb === "NOTICE") {
this.update([url, stats => stats.notice_count++])
}
}
private onSocketStatus = (status: string, url: string) => {
if (status === SocketStatus.Open) {
this.update([
url,
stats => {
stats.last_open = now()
stats.open_count++
},
])
}
if (status === SocketStatus.Closed) {
this.update([
url,
stats => {
stats.last_close = now()
stats.close_count++
},
])
}
if (status === SocketStatus.Error) {
this.update([
url,
stats => {
stats.last_error = now()
stats.recent_errors = uniq(stats.recent_errors.concat(now())).slice(-10)
},
])
}
}
monitorSocket = (socket: Socket) => {
socket.on(SocketEvent.Send, this.onSocketSend)
socket.on(SocketEvent.Receive, this.onSocketReceive)
socket.on(SocketEvent.Status, this.onSocketStatus)
return () => {
socket.off(SocketEvent.Send, this.onSocketSend)
socket.off(SocketEvent.Receive, this.onSocketReceive)
socket.off(SocketEvent.Status, this.onSocketStatus)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import {fetchJson} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
import type {RelayProfile} from "@welshman/util"
import {LoadableMapPlugin} from "./base.js"
import type {Projection} from "./base.js"
/**
* NIP-11 relay profiles, keyed by url. A "local" loadable collection: items
* aren't nostr events, they're fetched over HTTP from each relay.
*/
export class Relays extends LoadableMapPlugin<RelayProfile> {
fetch = async (url: string): Promise<Maybe<RelayProfile>> => {
try {
const json = await fetchJson(url.replace(/^ws/, "http"), {
headers: {
Accept: "application/nostr+json",
},
})
if (json) {
const info = {...json, url} as RelayProfile
if (!Array.isArray(info.supported_nips)) {
info.supported_nips = []
}
info.supported_nips = info.supported_nips.map(String)
this.set(url, info)
return info
}
} catch (e) {
// pass
}
}
display = (url: string): Projection<string> =>
this.project(url, $relay => displayRelayProfile($relay, displayRelayUrl(url)))
hasNegentropy = async (url: string) => {
const relay = await this.load(url)
if (relay?.negentropy) return true
if (relay?.supported_nips?.includes("77")) return true
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
return false
}
hasNip = async (url: string, nip: number | string) =>
(await this.load(url))?.supported_nips?.includes(String(nip)) ?? false
}
+75
View File
@@ -0,0 +1,75 @@
import {
RoomCreateBuilder,
RoomDeleteBuilder,
RoomEditBuilder,
RoomJoinBuilder,
RoomLeaveBuilder,
RoomAddMemberBuilder,
RoomRemoveMemberBuilder,
} from "@welshman/domain"
import {Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IApp} from "../app.js"
// Room metadata used when publishing NIP-29 room events. `h` is the group id.
export type RoomMeta = {
h: string
name?: string
about?: string
picture?: string
pictureMeta?: string[]
isClosed?: boolean
isHidden?: boolean
isPrivate?: boolean
isRestricted?: boolean
livekit?: boolean
}
/**
* NIP-29 relay-based group (room) management. Each method publishes the relevant
* room event to the given relay as the app's user.
*/
export class Rooms {
constructor(readonly app: IApp) {}
private publish = (url: string, event: ThunkOptions["event"]) =>
this.app.use(Thunks).publish({event, relays: [url]})
create = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomCreateBuilder().setGroup(room.h).toTemplate())
delete = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomDeleteBuilder().setGroup(room.h).toTemplate())
edit = async (url: string, room: RoomMeta) => {
const builder = new RoomEditBuilder().setGroup(room.h)
if (room.name) builder.setName(room.name)
if (room.about) builder.setAbout(room.about)
if (room.picture) builder.setPicture(room.picture, room.pictureMeta)
builder
.setClosed(Boolean(room.isClosed))
.setHidden(Boolean(room.isHidden))
.setPrivate(Boolean(room.isPrivate))
.setRestricted(Boolean(room.isRestricted))
.setLivekit(Boolean(room.livekit))
return this.publish(url, await builder.toTemplate())
}
join = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomJoinBuilder().setGroup(room.h).toTemplate())
leave = async (url: string, room: RoomMeta) =>
this.publish(url, await new RoomLeaveBuilder().setGroup(room.h).toTemplate())
addMember = async (url: string, room: RoomMeta, pubkey: string) =>
this.publish(url, await new RoomAddMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate())
removeMember = async (url: string, room: RoomMeta, pubkey: string) =>
this.publish(
url,
await new RoomRemoveMemberBuilder().setGroup(room.h).addPubkey(pubkey).toTemplate(),
)
}
+29
View File
@@ -0,0 +1,29 @@
import {RelayMode} from "@welshman/util"
import {Router as BaseRouter} from "@welshman/router"
import {RelayLists} from "./relayLists.js"
import {RelayStats} from "./relayStats.js"
import type {IApp} from "../app.js"
/**
* The upstream `@welshman/router` Router, wired to this app: relay lists come
* from the `RelayLists` collection, quality from `RelayStats`, and the user
* pubkey + relay-getters from the app (via `app.config`). Reach it via
* `app.use(Router)`. This replaces the old forked copy — one source of truth,
* no global `routerContext`/`Router.get()`.
*/
export class Router extends BaseRouter {
constructor(app: IApp) {
super({
getUserPubkey: () => app.user?.pubkey,
getPubkeyRelays: (pubkey, mode) =>
(mode === RelayMode.Read
? app.use(RelayLists).readUrls(pubkey)
: app.use(RelayLists).writeUrls(pubkey)
).get(),
getRelayQuality: url => app.use(RelayStats).getQuality(url),
getDefaultRelays: app.config.getDefaultRelays,
getIndexerRelays: app.config.getIndexerRelays,
getSearchRelays: app.config.getSearchRelays,
})
}
}
+135
View File
@@ -0,0 +1,135 @@
import Fuse from "fuse.js"
import type {IFuseOptions, FuseResult} from "fuse.js"
import {debounce} from "throttle-debounce"
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import {dec, inc, sortBy} from "@welshman/lib"
import {PROFILE} from "@welshman/util"
import type {RelayProfile} from "@welshman/util"
import type {Profile} from "@welshman/domain"
import {throttled} from "@welshman/store"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import {Topics} from "./topics.js"
import type {Topic} from "./topics.js"
import {Relays} from "./relays.js"
import {Handles} from "./handles.js"
import {Wot} from "./wot.js"
export type SearchOptions<V, T> = {
getValue: (item: T) => V
fuseOptions?: IFuseOptions<T>
onSearch?: (term: string) => void
sortFn?: (items: FuseResult<T>) => any
}
export type Search<V, T> = {
options: T[]
getValue: (item: T) => V
getOption: (value: V) => T | undefined
searchOptions: (term: string) => T[]
searchValues: (term: string) => V[]
}
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
const map = new Map<V, T>(options.map(item => [opts.getValue(item), item]))
const search = (term: string) => {
opts.onSearch?.(term)
let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult<T>)
if (opts.sortFn) {
results = sortBy(opts.sortFn, results)
}
return results.map(result => result.item)
}
return {
options,
getValue: opts.getValue,
getOption: (value: V) => map.get(value),
searchOptions: (term: string) => search(term),
searchValues: (term: string) => search(term).map(opts.getValue),
}
}
/**
* Reactive fuzzy searches over the app's profiles, topics, and relays.
* `profileSearch` blends fuse scores with web-of-trust weight (via `Wot`) and
* fires a debounced NIP-50 network search through the app's loader.
*/
export class Searches {
profileSearch: Readable<Search<string, Profile>>
topicSearch: Readable<Search<string, Topic>>
relaySearch: Readable<Search<string, RelayProfile>>
constructor(readonly app: IApp) {
this.profileSearch = derived(
[throttled(800, this.app.use(Profiles).all.$), throttled(800, this.app.use(Handles).index.$)],
([$profiles, $handlesByNip05]) =>
createSearch($profiles, {
onSearch: this.searchProfiles,
getValue: (profile: Profile) => profile.author(),
sortFn: ({score = 1, item}) => {
const wotScore = this.app.use(Wot).graph.get().get(item.author()) || 0
return dec(score) * inc(wotScore / (this.app.use(Wot).max.get() || 1))
},
fuseOptions: {
keys: [
"nip05",
{name: "name", weight: 0.8},
{name: "about", weight: 0.3},
],
threshold: 0.3,
shouldSort: false,
// Read fields off the domain reader's parsed `values`; only expose a
// nip05 that's verified against the loaded handle (anti-spoofing).
getFn: (profile: Profile, path) => {
const key = Array.isArray(path) ? path[0] : path
if (key === "nip05") {
const nip05 = profile.nip05()
return nip05 && $handlesByNip05.get(nip05)?.pubkey === profile.author()
? nip05
: ""
}
return profile.values[key] ?? ""
},
},
}),
)
this.topicSearch = derived(this.app.use(Topics).all, $topics =>
createSearch($topics, {
getValue: (topic: Topic) => topic.name,
fuseOptions: {keys: ["name"]},
}),
)
this.relaySearch = derived(this.app.use(Relays).all.$, $relays =>
createSearch($relays, {
getValue: (relay: RelayProfile) => relay.url,
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
},
}),
)
}
searchProfiles = debounce(500, (search: string) => {
if (search.length > 2) {
this.app.use(Network).load({
filters: [{kinds: [PROFILE], search}],
relays: this.app.use(Router).Search().getUrls(),
})
}
})
}
@@ -0,0 +1,47 @@
import {SEARCH_RELAYS} from "@welshman/util"
import {SearchRelayList, SearchRelayListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {Projection} from "./base.js"
import {Network} from "./network.js"
import {User} from "../user.js"
import {Thunks} from "./thunk.js"
import type {IApp} from "../app.js"
/**
* NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class SearchRelayLists extends DerivedPlugin<SearchRelayList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [SEARCH_RELAYS]}],
eventToItem: SearchRelayList.factory(app.user?.signer),
getKey: list => list.author(),
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, {kinds: [SEARCH_RELAYS]}, relayHints)
}
urls = (pubkey: string): Projection<string[]> =>
this.project(pubkey, list => list?.urls() ?? [])
update = async (fn: (builder: SearchRelayListBuilder) => void) => {
const user = User.require(this.app)
const builder = new SearchRelayListBuilder(await this.forceLoad(user.pubkey))
fn(builder)
const event = await builder.toTemplate(user.signer)
return this.app.use(Thunks).publishToOutbox({event})
}
addUrl = (url: string) => this.update(builder => builder.addUrl(url))
removeUrl = (url: string) => this.update(builder => builder.removeUrl(url))
setUrls = (urls: string[]) => this.update(builder => builder.setUrls(urls))
}
+58
View File
@@ -0,0 +1,58 @@
import {
getEventsById,
deriveEventsById,
deriveEvents,
makeDeriveEvent,
getEventsByIdByUrl,
deriveEventsByIdByUrl,
getEventsByIdForUrl,
deriveEventsByIdForUrl,
deriveItemsByKey,
deriveIsDeleted,
} from "@welshman/store"
import type {
EventsByIdOptions,
EventOptions,
EventsByIdByUrlOptions,
EventsByIdForUrlOptions,
ItemsByKeyOptions,
} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import type {IApp} from "../app.js"
/**
* Store/derivation utilities bound to the app's repository and tracker. Reach
* it via `app.use(Stores)`.
*/
export class Stores {
constructor(readonly app: IApp) {}
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
getEventsById({...options, repository: this.app.repository})
eventsById = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEventsById({...options, repository: this.app.repository})
events = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEvents({...options, repository: this.app.repository})
makeEvent = (options: Omit<EventOptions, "repository">) =>
makeDeriveEvent({...options, repository: this.app.repository})
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
getEventsByIdByUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
eventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdByUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
getEventsByIdForUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
eventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdForUrl({...options, tracker: this.app.tracker, repository: this.app.repository})
itemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
deriveItemsByKey<T>({...options, repository: this.app.repository})
isDeleted = (event: TrustedEvent) => deriveIsDeleted(this.app.repository, event)
}
+53
View File
@@ -0,0 +1,53 @@
import {isSignedEvent} from "@welshman/util"
import type {Filter, SignedEvent} from "@welshman/util"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
import {Relays} from "./relays.js"
export type AppSyncOpts = {
relays: string[]
filters: Filter[]
}
/**
* Negentropy-aware sync. Pulls/pushes events between the local repository and a
* set of relays, using NIP-77 reconciliation where the relay supports it and
* falling back to plain request/publish otherwise. Reads NIP-11 relay profiles
* from the `Relays` collection to detect negentropy support.
*/
export class Sync {
constructor(readonly app: IApp) {}
query = (filters: Filter[]) =>
this.app.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
pull = async ({relays, filters}: AppSyncOpts) => {
const net = this.app.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
if (await this.app.use(Relays).hasNegentropy(relay)) {
await net.pull({filters, events, relays: [relay]})
} else {
await net.request({filters, relays: [relay], autoClose: true})
}
}),
)
}
push = async ({relays, filters}: AppSyncOpts) => {
const net = this.app.use(Network)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
if (await this.app.use(Relays).hasNegentropy(relay)) {
await net.push({filters, events, relays: [relay]})
} else {
await Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]})))
}
}),
)
}
}
+142
View File
@@ -0,0 +1,142 @@
import {uniq, remove} from "@welshman/lib"
import {
getAddress,
isReplaceable,
getReplyTags,
getPubkeyTagValues,
isReplaceableKind,
isShareableRelayUrl,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import type {IApp} from "../app.js"
/**
* Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router
* for relay hints, the profiles collection for display names, and the app's
* user to avoid self-tagging.
*/
export class Tags {
constructor(readonly app: IApp) {}
tagZapSplit = (pubkey: string, split = 1) => [
"zap",
pubkey,
this.app.use(Router).FromPubkey(pubkey).getUrl() || "",
String(split),
]
tagPubkey = (pubkey: string) => [
"p",
pubkey,
this.app.use(Router).FromPubkey(pubkey).getUrl() || "",
this.app.use(Profiles).display(pubkey).get(),
]
tagEvent = (event: TrustedEvent, url = "", mark = "") => {
if (!url) {
url = this.app.use(Router).Event(event).getUrl() || ""
}
const tags = [["e", event.id, url, mark, event.pubkey]]
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), url, mark, event.pubkey])
}
return tags
}
tagEventPubkeys = (event: TrustedEvent) =>
uniq(
remove(this.app.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]),
).map(pubkey => this.tagPubkey(pubkey))
tagEventForQuote = (event: TrustedEvent, relay?: string) => {
const hint = relay || this.app.use(Router).Event(event).getUrl() || ""
return ["q", event.id, hint, event.pubkey]
}
tagEventForReply = (event: TrustedEvent, relay?: string) => {
const tags = this.tagEventPubkeys(event)
const {roots, replies} = getReplyTags(event.tags)
const parents = roots.length > 0 ? roots : replies
const mark = parents.length > 0 ? "reply" : "root"
const hint = relay || this.app.use(Router).Event(event).getUrl() || ""
// If the parent included roots use them, otherwise use replies as a fallback
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
const hint = isShareableRelayUrl(originalHint)
? originalHint
: this.app.use(Router).EventRoots(event).getUrl()
tags.push([k, id, hint || "", "root", pubkey])
}
// e-tag the event
tags.push(["e", event.id, hint, mark, event.pubkey])
// a-tag the event
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), hint, mark, event.pubkey])
}
return tags
}
tagEventForComment = (event: TrustedEvent, relay?: string) => {
const pubkeyHint = this.app.use(Router).FromPubkey(event.pubkey).getUrl() || ""
const eventHint = relay || this.app.use(Router).Event(event).getUrl() || ""
const address = getAddress(event)
const seenRoots = new Set<string>()
const tags: string[][] = []
for (const [t, ...tag] of event.tags) {
if (["K", "E", "A", "I", "P"].includes(t)) {
tags.push([t, ...tag])
seenRoots.add(t)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["P", event.pubkey, pubkeyHint])
tags.push(["E", event.id, eventHint, event.pubkey])
if (isReplaceableKind(event.kind)) {
tags.push(["A", address, eventHint, event.pubkey])
}
}
tags.push(["k", String(event.kind)])
tags.push(["p", event.pubkey, pubkeyHint])
tags.push(["e", event.id, eventHint, event.pubkey])
if (isReplaceableKind(event.kind)) {
tags.push(["a", address, eventHint, event.pubkey])
}
return tags
}
tagEventForReaction = (event: TrustedEvent, relay?: string) => {
const hint = relay || this.app.use(Router).Event(event).getUrl() || ""
const tags: string[][] = []
// Mention the event's author
if (event.pubkey !== this.app.user?.pubkey) {
tags.push(this.tagPubkey(event.pubkey))
}
tags.push(["k", String(event.kind)])
tags.push(["e", event.id, hint])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), hint])
}
return tags
}
}
+411
View File
@@ -0,0 +1,411 @@
import type {Subscriber} from "svelte/store"
import {writable} from "svelte/store"
import type {Override} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, uniq, without} from "@welshman/lib"
import {
HashedEvent,
EventTemplate,
SignedEvent,
isSignedEvent,
WRAPPED_KINDS,
prep,
makePow,
} from "@welshman/util"
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
import {Nip01Signer, Nip59} from "@welshman/signer"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
import {addMinimalFallbacks} from "@welshman/router"
import {Router} from "./router.js"
import {User} from "../user.js"
export type ThunkOptions = Override<
PublishOptions,
{
app: IApp
event: EventTemplate
recipient?: string
delay?: number
pow?: number
}
>
/**
* Shared base for `Thunk` and `MergedThunk`: a subscribable bag of per-relay
* publish `results`.
*/
export abstract class BaseThunk {
_subs: Subscriber<any>[] = []
results: PublishResultsByRelay = {}
abstract abort(): void
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
subscribe(subscriber: Subscriber<this>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
}
getUrlsWithStatus(statuses: PublishStatus | PublishStatus[]) {
const matches = ensurePlural(statuses)
return Object.entries(this.results)
.filter(([_, {status}]) => matches.includes(status))
.map(nth(0)) as string[]
}
getCompleteUrls() {
return this.getUrlsWithStatus(
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
)
}
getIncompleteUrls() {
return this.getUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending])
}
getFailedUrls() {
return this.getUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout])
}
hasStatus(statuses: PublishStatus | PublishStatus[]) {
return this.getUrlsWithStatus(statuses).length > 0
}
isComplete() {
return !this.hasStatus([PublishStatus.Sending, PublishStatus.Pending])
}
getError() {
for (const [_, {status, detail}] of Object.entries(this.results)) {
if (status === PublishStatus.Failure) {
return detail
}
}
if (this.isComplete()) {
return ""
}
}
waitForError() {
return new Promise<string>(resolve => {
this.subscribe(thunk => {
const error = thunk.getError()
if (error !== undefined) {
resolve(error)
}
})
})
}
waitForCompletion() {
return new Promise<void>(resolve => {
this.subscribe(thunk => {
if (thunk.isComplete()) {
resolve()
}
})
})
}
}
export class Thunk extends BaseThunk {
event: HashedEvent
complete = defer<void>()
controller = new AbortController()
wrap?: SignedEvent
constructor(readonly options: ThunkOptions) {
super()
if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
}
this.event = prep(options.event, this.user.pubkey)
for (const relay of options.relays) {
this.results[relay] = {
relay,
status: PublishStatus.Sending,
detail: "sending...",
}
}
this.controller.signal.addEventListener("abort", () => {
for (const relay of options.relays) {
this._setAborted({
relay,
status: PublishStatus.Aborted,
detail: "aborted",
})
}
})
}
get user() {
return User.require(this.options.app)
}
_fail(detail: string) {
for (const relay of this.options.relays) {
this.results[relay] = {
relay,
status: PublishStatus.Failure,
detail: detail,
}
}
this._notify()
}
_setPending = (result: PublishResult) => {
this.options.onPending?.(result)
this.results[result.relay] = result
this._notify()
}
_setTimeout = (result: PublishResult) => {
this.options.onTimeout?.(result)
this.results[result.relay] = result
this._notify()
}
_setAborted = (result: PublishResult) => {
this.options.onAborted?.(result)
this.results[result.relay] = result
this._notify()
}
async _publish(event: SignedEvent) {
// Wait if the thunk is to be delayed
if (this.options.delay) {
await sleep(this.options.delay)
}
// Skip publishing if aborted
if (this.controller.signal.aborted) {
return
}
// Send it off
await this.options.app.use(Network).publish({
...this.options,
event,
onSuccess: (result: PublishResult) => {
this.options.onSuccess?.(result)
this.results[result.relay] = result
this._notify()
},
onFailure: (result: PublishResult) => {
this.options.onFailure?.(result)
this.results[result.relay] = result
this._notify()
},
onPending: this._setPending,
onTimeout: this._setTimeout,
onAborted: this._setAborted,
onComplete: (result: PublishResult) => {
if (result.status !== PublishStatus.Success) {
this.options.app.tracker.removeRelay(event.id, result.relay)
}
this.options.onComplete?.(result)
this._subs = []
},
})
// Notify the caller that we're done
this.complete.resolve()
}
async publish() {
// Handle abort immediately if possible
if (this.controller.signal.aborted) return
const {recipient} = this.options
// If we're sending it privately, wrap the event using nip 59
if (recipient) {
const wrapper = Nip01Signer.ephemeral()
const nip59 = new Nip59(this.user.signer, wrapper)
this.wrap = await nip59.wrap(recipient, this.event)
// If we're calculating pow, update the hash and re-sign
if (this.options.pow) {
this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, {
signal: AbortSignal.timeout(30_000),
})
}
this.options.app.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
return this._publish(this.wrap)
}
// If the event has been signed, we're good to go
if (isSignedEvent(this.event)) {
if (this.options.pow) {
console.warn("Event is already signed, skipping proof of work calculation")
}
return this._publish(this.event)
}
// Allow for lazily signing/powing events in order to decrease apparent latency in the UI
// that results from waiting for remote signers
try {
if (this.options.pow) {
this.event = await makePow(this.event, this.options.pow).result
}
const signedEvent = await this.user.signer.sign(this.event, {
signal: AbortSignal.timeout(30_000),
})
// Update tracker and repository with the signed event since the id will have changed
if (this.options.pow) {
for (const url of this.options.relays) {
this.options.app.tracker.removeRelay(this.event.id, url)
this.options.app.tracker.track(signedEvent.id, url)
}
}
this.options.app.repository.removeEvent(this.event.id)
this.options.app.repository.publish(signedEvent)
return this._publish(signedEvent)
} catch (e: any) {
console.error("Failed to sign event", e)
return this._fail(String(e || "Failed to sign event"))
}
}
abort() {
this.controller.abort()
}
}
export class MergedThunk extends BaseThunk {
constructor(readonly thunks: Thunk[]) {
super()
const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus
const relays = new Set(thunks.flatMap(thunk => thunk.options.relays))
for (const thunk of thunks) {
thunk.subscribe(() => {
this.results = {}
for (const relay of relays) {
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
const match = thunks.find(t => t.results[relay]?.status === status)
if (match) {
this.results[relay] = match.results[relay]!
}
}
}
this._notify()
if (thunks.every(t => t.isComplete())) {
this._subs = []
}
})
}
}
abort() {
this.thunks.forEach(thunk => thunk.abort())
}
}
/**
* Per-app thunk manager — the publish-side counterpart of `Network`. Owns
* the app's optimistic-publish `history` store and the `queue` that paces
* publishing. Reach it via `app.use(Thunks)`; `publish` fills in the app
* (the acting user is derived from it), enqueues the thunk (optimistically
* writing it to the repository), and returns it.
*/
export class Thunks {
history = writable<Thunk[]>([])
queue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
constructor(readonly app: IApp) {}
enqueue(thunk: Thunk) {
this.queue.push(thunk)
for (const url of thunk.options.relays) {
this.app.tracker.track(thunk.event.id, url)
}
this.app.repository.publish(thunk.event)
this.history.update($history => append(thunk, $history))
thunk.controller.signal.addEventListener("abort", () => {
if (thunk.wrap) {
this.app.wrapManager.remove(thunk.wrap.id)
} else {
this.app.repository.removeEvent(thunk.event.id)
}
this.history.update($history => remove(thunk, $history))
})
}
publish = (options: Omit<ThunkOptions, "app">) => {
const thunk = new Thunk({...options, app: this.app})
this.enqueue(thunk)
return thunk
}
// Publish as the user to their outbox (write) relays
publishToOutbox = (options: Omit<ThunkOptions, "app" | "relays">) =>
this.publish({
...options,
relays: this.app.use(Router).FromUser().policy(addMinimalFallbacks).getUrls(),
})
retry = (thunk: BaseThunk) =>
thunk instanceof MergedThunk
? new MergedThunk(thunk.thunks.map(t => this.publish(t.options)))
: this.publish((thunk as Thunk).options)
merge(thunks: BaseThunk[]) {
return new MergedThunk(Array.from(this.flatten(thunks)))
}
*flatten(thunks: BaseThunk[]): Iterable<Thunk> {
for (const thunk of thunks) {
if (thunk instanceof MergedThunk) {
yield* this.flatten(thunk.thunks)
} else if (thunk instanceof Thunk) {
yield thunk
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {on} from "@welshman/lib"
import {getTopicTagValues} from "@welshman/util"
import type {RepositoryUpdate} from "@welshman/net"
import {deriveItems} from "@welshman/store"
import type {IApp} from "../app.js"
export type Topic = {
name: string
count: number
}
/**
* Hashtag topics with occurrence counts, derived live from the app's
* repository tag index.
*/
export class Topics {
byName: Readable<Map<string, Topic>>
all: Readable<Topic[]>
constructor(readonly app: IApp) {
const topicsByName = new Map<string, Topic>()
const addTopic = (name: string) => {
const topic = topicsByName.get(name)
if (topic) {
topic.count++
} else {
topicsByName.set(name, {name, count: 1})
}
}
for (const tagString of app.repository.eventsByTag.keys()) {
if (tagString.startsWith("t:")) {
addTopic(tagString.slice(2).toLowerCase())
}
}
this.byName = readable(topicsByName, set =>
on(app.repository, "update", ({added}: RepositoryUpdate) => {
let dirty = false
for (const event of added) {
for (const name of getTopicTagValues(event.tags)) {
addTopic(name)
dirty = true
}
}
if (dirty) {
set(topicsByName)
}
}),
)
this.all = deriveItems(this.byName)
}
}
+192
View File
@@ -0,0 +1,192 @@
import {readable, derived} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import type {FollowList, MuteList} from "@welshman/domain"
import type {IApp} from "../app.js"
import {projection, projectFrom} from "./base.js"
import type {Projection} from "./base.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
/**
* Web-of-trust scoring derived from follow and mute lists. The trust graph is
* built from the perspective of the app's user (or, with no user, the union
* of every known follow list) and updated reactively as lists change.
*
* The aggregate `*ByPubkey`/`graph`/`max` fields and the parameterized methods
* (`follows`, `wotScore`, …) are all `Projection`s — subscribe via `.$`, snapshot
* via `.get()`.
*/
export class Wot {
followersByPubkey: Projection<Map<string, Set<string>>>
mutersByPubkey: Projection<Map<string, Set<string>>>
graph: Projection<Map<string, number>>
max: Projection<number | undefined>
constructor(readonly app: IApp) {
const followersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
this.app.use(FollowLists).index.$.subscribe(
throttle(1000, lists => {
const $followersByPubkey = new Map<string, Set<string>>()
for (const list of lists.values()) {
for (const pubkey of list?.pubkeys() ?? []) {
addToMapKey($followersByPubkey, pubkey, list.author())
}
}
set($followersByPubkey)
}),
),
)
const mutersByPubkeyStore = readable(new Map<string, Set<string>>(), set =>
this.app.use(MuteLists).index.$.subscribe(
throttle(1000, lists => {
const $mutersByPubkey = new Map<string, Set<string>>()
for (const list of lists.values()) {
for (const pubkey of list?.pubkeys() ?? []) {
addToMapKey($mutersByPubkey, pubkey, list.author())
}
}
set($mutersByPubkey)
}),
),
)
const graphStore = readable(new Map<string, number>(), set => {
const rebuild = throttle(1000, () => {
const $followLists = this.app.use(FollowLists).index.get()
const $muteLists = this.app.use(MuteLists).index.get()
const $pubkey = this.app.user?.pubkey
const $graph = new Map<string, number>()
const roots = $pubkey
? ($followLists.get($pubkey)?.pubkeys() ?? [])
: Array.from($followLists.keys())
for (const follow of roots) {
for (const pubkey of $followLists.get(follow)?.pubkeys() ?? []) {
$graph.set(pubkey, inc($graph.get(pubkey)))
}
for (const pubkey of $muteLists.get(follow)?.pubkeys() ?? []) {
$graph.set(pubkey, dec($graph.get(pubkey)))
}
}
set($graph)
})
const unsubscribers = [
this.app.use(FollowLists).index.$.subscribe(rebuild),
this.app.use(MuteLists).index.$.subscribe(rebuild),
]
return () => unsubscribers.forEach(unsubscribe => unsubscribe())
})
const maxStore = derived(graphStore, $g => max(Array.from($g.values())))
this.followersByPubkey = projection(followersByPubkeyStore)
this.mutersByPubkey = projection(mutersByPubkeyStore)
this.graph = projection(graphStore)
this.max = projection(maxStore)
}
follows = (pubkey: string): Projection<string[]> =>
projectFrom(this.app.use(FollowLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
mutes = (pubkey: string): Projection<string[]> =>
projectFrom(this.app.use(MuteLists).index, $lists => $lists.get(pubkey)?.pubkeys() ?? [])
network = (pubkey: string): Projection<string[]> =>
projectFrom(this.app.use(FollowLists).index, $lists => {
const pubkeys = new Set($lists.get(pubkey)?.pubkeys() ?? [])
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of $lists.get(follow)?.pubkeys() ?? []) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
}
}
return Array.from(network)
})
followers = (pubkey: string): Projection<string[]> =>
projectFrom(this.followersByPubkey, $followers => Array.from($followers.get(pubkey) || []))
muters = (pubkey: string): Projection<string[]> =>
projectFrom(this.mutersByPubkey, $muters => Array.from($muters.get(pubkey) || []))
followsWhoFollow = (pubkey: string, target: string): Projection<string[]> =>
projectFrom(this.app.use(FollowLists).index, $lists =>
($lists.get(pubkey)?.pubkeys() ?? []).filter(other =>
($lists.get(other)?.pubkeys() ?? []).includes(target),
),
)
followsWhoMute = (pubkey: string, target: string): Projection<string[]> => {
const read = (
$follows: ReadonlyMap<string, FollowList>,
$mutes: ReadonlyMap<string, MuteList>,
) =>
($follows.get(pubkey)?.pubkeys() ?? []).filter(other =>
($mutes.get(other)?.pubkeys() ?? []).includes(target),
)
return projection(
derived(
[this.app.use(FollowLists).index.$, this.app.use(MuteLists).index.$],
([$follows, $mutes]) => read($follows, $mutes),
),
() => read(this.app.use(FollowLists).index.get(), this.app.use(MuteLists).index.get()),
)
}
wotScore = (pubkey: string, target: string): Projection<number> => {
const read = (
$follows: ReadonlyMap<string, FollowList>,
$mutes: ReadonlyMap<string, MuteList>,
$followers: ReadonlyMap<string, Set<string>>,
$muters: ReadonlyMap<string, Set<string>>,
) => {
let follows: string[]
let mutes: string[]
if (pubkey) {
const theirFollows = $follows.get(pubkey)?.pubkeys() ?? []
follows = theirFollows.filter(other => ($follows.get(other)?.pubkeys() ?? []).includes(target))
mutes = theirFollows.filter(other => ($mutes.get(other)?.pubkeys() ?? []).includes(target))
} else {
follows = Array.from($followers.get(target) || [])
mutes = Array.from($muters.get(target) || [])
}
return follows.length - mutes.length
}
return projection(
derived(
[
this.app.use(FollowLists).index.$,
this.app.use(MuteLists).index.$,
this.followersByPubkey.$,
this.mutersByPubkey.$,
],
([$follows, $mutes, $followers, $muters]) => read($follows, $mutes, $followers, $muters),
),
() =>
read(
this.app.use(FollowLists).index.get(),
this.app.use(MuteLists).index.get(),
this.followersByPubkey.get(),
this.mutersByPubkey.get(),
),
)
}
}
+80
View File
@@ -0,0 +1,80 @@
import {get, writable} from "svelte/store"
import {TaskQueue, uniq, now} from "@welshman/lib"
import {getPubkeyTagValues, prep} from "@welshman/util"
import type {TrustedEvent, SignedEvent, EventTemplate} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {MergedThunk, Thunks} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import {User} from "../user.js"
import {MessagingRelayLists} from "./messagingRelayLists.js"
import type {IApp} from "../app.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
"event" | "relays" | "recipient" | "app" | "user"
> & {
event: EventTemplate
recipients: string[]
}
/**
* Per-app wrap (NIP-59) state: the unwrap queue plus failure/dedup
* tracking. Scoped to `app.user`, so an app only ever unwraps its own user's
* messages into its own repository — which is what keeps DM history from being
* merged across identities. The repository subscription that feeds it lives in
* `appPolicyWraps`.
*/
export class Wraps {
failedUnwraps = new Set<string>()
queue: TaskQueue<TrustedEvent>
constructor(readonly app: IApp) {
this.queue = new TaskQueue<TrustedEvent>({
batchSize: 50,
batchDelay: 30,
processItem: async (wrap: TrustedEvent) => {
const signer = this.app.user?.signer
const recipient = this.app.user?.pubkey
// Only unwrap messages addressed to our user
if (!signer || !recipient || !getPubkeyTagValues(wrap.tags).includes(recipient)) {
return
}
try {
const rumor = await Nip59.fromSigner(signer).unwrap(wrap as SignedEvent)
this.app.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient})
} catch (e) {
this.failedUnwraps.add(wrap.id)
}
},
})
}
enqueue = (wrap: TrustedEvent) => {
if (this.failedUnwraps.has(wrap.id)) return
if (this.app.wrapManager.getRumor(wrap.id)) return
this.queue.push(wrap)
}
// NIP-59: wrap an event for each recipient (using their messaging relays) and
// publish the wraps as the app's user.
publish = async ({event, recipients, ...options}: SendWrappedOptions) => {
const user = User.require(this.app)
// Stabilize the event id across the different wraps
const stableEvent = prep(event, user.pubkey, now())
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = (await this.app.use(MessagingRelayLists).load(recipient))?.urls() ?? []
return this.app.use(Thunks).publish({event: stableEvent, relays, recipient, ...options})
}),
),
)
}
}
+157
View File
@@ -0,0 +1,157 @@
import type {Readable} from "svelte/store"
import {
removeUndefined,
fetchJson,
bech32ToHex,
hexToBech32,
tryCatch,
batcher,
postJson,
} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {getTagValue, getZapSplits, zapFromEvent} from "@welshman/util"
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import type {Profile} from "@welshman/domain"
import {deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
import {LoadableMapPlugin, projection} from "./base.js"
import type {Projection} from "./base.js"
import type {IApp} from "../app.js"
import {Profiles} from "./profiles.js"
/**
* Lightning zapper info, keyed by lnurl. A "local" loadable collection: items
* aren't nostr events, they're fetched over HTTP (either directly from each
* lnurl, or via a dufflepud proxy to protect user privacy). Depends on the
* profiles collection to resolve a pubkey's lnurl.
*/
export class Zappers extends LoadableMapPlugin<Zapper> {
fetch = batcher(800, async (lnurls: string[]) => {
const result = new Map<string, Zapper>()
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
const addZapper = (lnurl: string, info: any) => {
if (info) {
try {
result.set(lnurl, {...info, lnurl})
} catch (_e) {
// pass
}
}
}
if (this.app.config.dufflepudUrl) {
const hexUrls = valid.map(bech32ToHex)
const res: any = await tryCatch(
async () =>
await postJson(`${this.app.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}),
)
for (const {lnurl, info} of res?.data || []) {
addZapper(hexToBech32("lnurl", lnurl), info)
}
} else {
await Promise.all(
valid.map(async lnurl => {
addZapper(lnurl, await tryCatch(async () => await fetchJson(bech32ToHex(lnurl))))
}),
)
}
for (const [lnurl, zapper] of result) {
this.set(lnurl, zapper)
}
return lnurls.map(lnurl => result.get(lnurl))
})
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await this.app.use(Profiles).load(pubkey, relays)
const lnurl = $profile?.lnurl()
return lnurl ? this.load(lnurl) : undefined
}
forPubkey = (pubkey: string, relays: string[] = []): Projection<Maybe<Zapper>> => {
this.loadForPubkey(pubkey, relays)
const read = ([$zappersByLnurl, $profile]: [ReadonlyMap<string, Zapper>, Maybe<Profile>]) => {
const lnurl = $profile?.lnurl()
return lnurl ? $zappersByLnurl.get(lnurl) : undefined
}
return projection(
deriveDeduplicated([this.index.$, this.app.use(Profiles).one(pubkey, relays)], read),
() => read([this.index.get(), this.app.use(Profiles).get(pubkey)]),
)
}
/**
* Resolve the zapper a zap receipt should be validated against. A receipt's
* `p` tag is the recipient (copied from the zap request), so we honor only
* receipts addressed to one of the parent's designated split recipients and
* load *that* recipient's zapper. The old lookup always used the first
* recipient's lnurl, which silently dropped legitimate zaps to any of the
* other split recipients.
*/
loadZapperForZap = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => {
const recipient = getTagValue("p", zapReceipt.tags)
const split = getZapSplits(parent).find(split => split.pubkey === recipient)
if (!split) return
return this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
}
validateZapReceipt = async (zapReceipt: TrustedEvent, parent: TrustedEvent) => {
const zapper = await this.loadZapperForZap(zapReceipt, parent)
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
}
validateZapReceipts = async (zapReceipts: TrustedEvent[], parent: TrustedEvent) =>
removeUndefined(
await Promise.all(zapReceipts.map(zapReceipt => this.validateZapReceipt(zapReceipt, parent))),
)
validZapReceipts = (zapReceipts: TrustedEvent[], parent: TrustedEvent): Projection<Zap[]> => {
const splits = getZapSplits(parent)
const profiles = this.app.use(Profiles)
// Ensure each recipient's profile (-> lnurl) and zapper are being loaded.
for (const split of splits) {
this.loadForPubkey(split.pubkey, removeUndefined([split.relay]))
}
const read = (values: any[]) => {
const $zappersByLnurl = values[0] as Map<string, Zapper>
const $profiles = values.slice(1) as Array<Profile | undefined>
const zapperByPubkey = new Map<string, Zapper>()
splits.forEach((split, i) => {
const lnurl = $profiles[i]?.lnurl()
const zapper = lnurl ? $zappersByLnurl.get(lnurl) : undefined
if (zapper) zapperByPubkey.set(split.pubkey, zapper)
})
return removeUndefined(
zapReceipts.map(zapReceipt => {
const recipient = getTagValue("p", zapReceipt.tags)
const zapper = recipient ? zapperByPubkey.get(recipient) : undefined
return zapper ? zapFromEvent(zapReceipt, zapper) : undefined
}),
)
}
const stores: Readable<any>[] = [this.index.$, ...splits.map(split => profiles.one(split.pubkey))]
return projection(
deriveDeduplicatedByValue(stores, read),
() => read([this.index.get(), ...splits.map(split => profiles.get(split.pubkey))]),
)
}
}
+145
View File
@@ -0,0 +1,145 @@
import type {Unsubscriber} from "svelte/store"
import {on, noop, always, call} from "@welshman/lib"
import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net"
import type {RelayMessage, Socket} from "@welshman/net"
import type {IApp} from "./app.js"
import {RelayStats} from "./plugins/relayStats.js"
import {Wraps} from "./plugins/wraps.js"
import {BlockedRelayLists} from "./plugins/blockedRelayLists.js"
import {LoggingSigner} from "./logging.js"
import type {LogMessage} from "./logging.js"
/**
* An app policy is a side effect applied once per app at construction,
* returning a cleanup function — directly analogous to a socket policy. Policies
* own everything that subscribes or links components together (event ingestion,
* stats collection, gift-wrap unwrapping), so the data classes themselves stay
* pure and free of subscriptions, and teardown is centralized in `cleanup()`.
*/
export type AppPolicy = (app: IApp) => Unsubscriber
/**
* Builds an app policy that authenticates the app's sockets (NIP-42) with
* the user's signer. It appends an auth socket policy to the pool's
* `socketPolicies`, so every socket the pool creates answers AUTH challenges
* according to `shouldAuth`; the policy is spliced back out on cleanup. No-op
* when the app has no user.
*
* Use the `appPolicyAuthAlways` / `appPolicyAuthNever` presets below, or
* call this with a custom predicate.
*/
export const makeAppPolicyAuth =
(shouldAuth: (socket: Socket, app: IApp) => boolean): AppPolicy =>
app => {
if (!app.user) {
return noop
}
const policy = makeSocketPolicyAuth({
sign: app.user.signer.sign,
shouldAuth: socket => shouldAuth(socket, app),
})
app.pool.socketPolicies.push(policy)
return () => {
const index = app.pool.socketPolicies.indexOf(policy)
if (index !== -1) {
app.pool.socketPolicies.splice(index, 1)
}
}
}
export const appPolicyAuthNever = makeAppPolicyAuth(always(false))
export const appPolicyAuthAlways = makeAppPolicyAuth(always(true))
export const appPolicyAuthUnlessBlocked = makeAppPolicyAuth((socket, app) => {
if (!app.user) {
return false
}
return !app
.use(BlockedRelayLists)
.urls(app.user.pubkey)
.get()
.includes(socket.url)
})
/**
* Ingests every event received on any socket into the app's repository. The
* net layer doesn't do this for us, and it's how all the repository-backed
* collections (and gift-wrap unwrapping) get populated.
*/
export const appPolicyIngest: AppPolicy = app =>
app.pool.subscribe(socket => {
const onReceive = (message: RelayMessage) => {
if (!isRelayEvent(message)) return
const event = message[2]
if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return
if (!verifyEvent(event)) return
app.tracker.track(event.id, socket.url)
app.repository.publish(event)
}
socket.on(SocketEvent.Receive, onReceive)
return () => socket.off(SocketEvent.Receive, onReceive)
})
/**
* Listens to socket activity on the app's pool into the RelayStats store.
*/
export const appPolicyRelayStats: AppPolicy = app => {
return app.pool.subscribe(app.use(RelayStats).monitorSocket)
}
/**
* Watches the app's repository for gift wraps (existing and incoming) and
* feeds them to the unwrap queue.
*/
export const appPolicyWraps: AppPolicy = app => {
const wraps = app.use(Wraps)
for (const wrap of app.repository.query([{kinds: [WRAP]}])) {
wraps.enqueue(wrap)
}
return on(app.repository, "update", ({added}: {added: TrustedEvent[]}) => {
for (const event of added) {
if (event.kind === WRAP) {
wraps.enqueue(event)
}
}
})
}
/**
* Forwards "message" events from the user's signer to `onMessage`. Opt-in —
* add `makeAppPolicyLogger(handler)` to an app's `policies`.
*/
export const makeAppPolicyLogger =
(onMessage: (message: LogMessage) => void): AppPolicy =>
app => {
const unsubscribers: Unsubscriber[] = []
const signer = app.user?.signer
if (signer instanceof LoggingSigner) {
unsubscribers.push(on(signer, "message", onMessage))
}
return () => unsubscribers.forEach(call)
}
export const defaultAppPolicies: AppPolicy[] = [
appPolicyIngest,
appPolicyRelayStats,
appPolicyWraps,
appPolicyAuthUnlessBlocked,
]
-43
View File
@@ -1,43 +0,0 @@
import {derived, readable} from "svelte/store"
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const profilesByPubkey = deriveItemsByKey({
repository,
eventToItem: readProfile,
filters: [{kinds: [PROFILE]}],
getKey: profile => profile.event.pubkey,
})
export const profiles = deriveItems(profilesByPubkey)
export const getProfilesByPubkey = getter(profilesByPubkey)
export const getProfiles = getter(profiles)
export const getProfile = (pubkey: string) => getProfilesByPubkey().get(pubkey)
export const forceLoadProfile = makeForceLoadItem(makeOutboxLoader(PROFILE), getProfile)
export const loadProfile = makeLoadItem(makeOutboxLoader(PROFILE), getProfile)
export const deriveProfile = makeDeriveItem(profilesByPubkey, loadProfile)
export const displayProfileByPubkey = (pubkey: string | undefined) =>
pubkey ? displayProfile(getProfile(pubkey), displayPubkey(pubkey)) : ""
export const deriveProfileDisplay = (pubkey: string | undefined, ...args: any[]) =>
pubkey
? derived(deriveProfile(pubkey, ...args), $profile =>
displayProfile($profile, displayPubkey(pubkey)),
)
: readable("")
-87
View File
@@ -1,87 +0,0 @@
import {chunk, first} from "@welshman/lib"
import {
RELAYS,
asDecryptedEvent,
readList,
TrustedEvent,
sortEventsDesc,
getRelaysFromList,
RelayMode,
Filter,
isPlainReplaceableKind,
} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {load} from "@welshman/net"
import {Router, addMinimalFallbacks} from "@welshman/router"
import {repository} from "./core.js"
export const fetchRelayList = async (pubkey: string, relayHints: string[] = []) => {
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
await Promise.all([
load({filters, relays: Router.get().FromRelays(relayHints).getUrls()}),
load({filters, relays: Router.get().FromPubkey(pubkey).getUrls()}),
load({filters, relays: Router.get().Index().getUrls()}),
])
}
export const relayListsByPubkey = deriveItemsByKey({
repository,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
filters: [{kinds: [RELAYS]}],
getKey: relayList => relayList.event.pubkey,
})
export const relayLists = deriveItems(relayListsByPubkey)
export const getRelayListsByPubkey = getter(relayListsByPubkey)
export const getRelayLists = getter(relayLists)
export const getRelayList = (pubkey: string) => getRelayListsByPubkey().get(pubkey)
export const forceLoadRelayList = makeForceLoadItem(fetchRelayList, getRelayList)
export const loadRelayList = makeLoadItem(fetchRelayList, getRelayList)
export const deriveRelayList = makeDeriveItem(relayListsByPubkey, loadRelayList)
// Outbox loader
export const loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
const filters = [{...filter, kinds: [kind], authors: [pubkey]}]
const writeRelays = getRelaysFromList(await loadRelayList(pubkey), RelayMode.Write)
const allRelays = Router.get()
.FromRelays(writeRelays)
.policy(addMinimalFallbacks)
.limit(8)
.getUrls()
if (isPlainReplaceableKind(kind)) {
filters[0].limit = 1
}
for (const relays of chunk(2, allRelays)) {
const events = await load({filters, relays})
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
}
export const makeOutboxLoader =
(kind: number, filter: Filter = {}, limit = 1) =>
async (pubkey: string, relayHints: string[] = []) => {
const filters = [{...filter, kinds: [kind], authors: [pubkey], limit}]
const relays = Router.get().FromRelays(relayHints).getUrls()
await Promise.all([load({filters, relays}), loadUsingOutbox(kind, pubkey, filter)])
}
-244
View File
@@ -1,244 +0,0 @@
import {writable, Subscriber} from "svelte/store"
import {getter, makeDeriveItem} from "@welshman/store"
import {groupBy, batch, now, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl, getRelaysFromList} from "@welshman/util"
import {Pool, Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net"
import {getBlockedRelayList} from "./blockedRelayLists.js"
import {pubkey} from "./session.js"
export type RelayStats = {
url: string
first_seen: number
recent_errors: number[]
open_count: number
close_count: number
publish_count: number
request_count: number
event_count: number
last_open: number
last_close: number
last_error: number
last_publish: number
last_request: number
last_event: number
last_auth: number
publish_success_count: number
publish_failure_count: number
eose_count: number
notice_count: number
}
export const makeRelayStats = (url: string): RelayStats => ({
url,
first_seen: now(),
recent_errors: [],
open_count: 0,
close_count: 0,
publish_count: 0,
request_count: 0,
event_count: 0,
last_open: 0,
last_close: 0,
last_error: 0,
last_publish: 0,
last_request: 0,
last_event: 0,
last_auth: 0,
publish_success_count: 0,
publish_failure_count: 0,
eose_count: 0,
notice_count: 0,
})
export const relayStatsByUrl = writable(new Map<string, RelayStats>())
export const getRelayStatsByUrl = getter(relayStatsByUrl)
export const getRelayStats = (url: string) => getRelayStatsByUrl().get(url)
export const relayStatsSubscribers: Subscriber<RelayStats>[] = []
export const notifyRelayStats = (relayStats: RelayStats) =>
relayStatsSubscribers.forEach(sub => sub(relayStats))
export const onRelayStats = (sub: (relayStats: RelayStats) => void) => {
relayStatsSubscribers.push(sub)
return () =>
relayStatsSubscribers.splice(
relayStatsSubscribers.findIndex(s => s === sub),
1,
)
}
export const deriveRelayStats = makeDeriveItem(relayStatsByUrl)
export const getRelayQuality = (url: string) => {
// Skip non-relays entirely
if (!isRelayUrl(url)) return 0
const $pubkey = pubkey.get()
if ($pubkey && getRelaysFromList(getBlockedRelayList($pubkey)).includes(url)) return 0
const relayStats = getRelayStats(url)
// If we have recent errors, skip it
if (relayStats) {
if (relayStats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
if (relayStats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
if (relayStats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
}
// Prefer stuff we're connected to
if (Pool.get().has(url)) return 1
// Prefer stuff we've connected to in the past
if (relayStats) return 0.9
// If it's not weird url give it an ok score
if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) {
return 0.8
}
// Default to a "meh" score
return 0.7
}
// Utilities for syncing stats from connections to relays
type RelayStatsUpdate = [string, (stats: RelayStats) => void]
const updateRelayStats = batch(1000, (updates: RelayStatsUpdate[]) => {
relayStatsByUrl.update($relayStatsByUrl => {
for (const [url, items] of groupBy(([url]) => url, updates)) {
if (!url || !isRelayUrl(url)) {
console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
continue
}
const $relayStatsItem: RelayStats = $relayStatsByUrl.get(url) || makeRelayStats(url)
for (const [_, update] of items) {
update($relayStatsItem)
}
// Copy so the database gets updated, since we're mutating in updates
const next = {...$relayStatsItem}
$relayStatsByUrl.set(url, next)
notifyRelayStats(next)
}
return $relayStatsByUrl
})
})
const onSocketSend = ([verb]: ClientMessage, url: string) => {
if (verb === "REQ") {
updateRelayStats([
url,
stats => {
stats.request_count++
stats.last_request = now()
},
])
} else if (verb === "EVENT") {
updateRelayStats([
url,
stats => {
stats.publish_count++
stats.last_publish = now()
},
])
}
}
const onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
if (verb === "OK") {
const [_, ok] = extra
updateRelayStats([
url,
stats => {
if (ok) {
stats.publish_success_count++
} else {
stats.publish_failure_count++
}
},
])
} else if (verb === "AUTH") {
updateRelayStats([
url,
stats => {
stats.last_auth = now()
},
])
} else if (verb === "EVENT") {
updateRelayStats([
url,
stats => {
stats.event_count++
stats.last_event = now()
},
])
} else if (verb === "EOSE") {
updateRelayStats([
url,
stats => {
stats.eose_count++
},
])
} else if (verb === "NOTICE") {
updateRelayStats([
url,
stats => {
stats.notice_count++
},
])
}
}
const onSocketStatus = (status: string, url: string) => {
if (status === SocketStatus.Open) {
updateRelayStats([
url,
stats => {
stats.last_open = now()
stats.open_count++
},
])
}
if (status === SocketStatus.Closed) {
updateRelayStats([
url,
stats => {
stats.last_close = now()
stats.close_count++
},
])
}
if (status === SocketStatus.Error) {
updateRelayStats([
url,
stats => {
stats.last_error = now()
stats.recent_errors = stats.recent_errors.concat(now()).slice(-100)
},
])
}
}
export const trackRelayStats = (socket: Socket) => {
socket.on(SocketEvent.Send, onSocketSend)
socket.on(SocketEvent.Receive, onSocketReceive)
socket.on(SocketEvent.Status, onSocketStatus)
return () => {
socket.off(SocketEvent.Send, onSocketSend)
socket.off(SocketEvent.Receive, onSocketReceive)
socket.off(SocketEvent.Status, onSocketStatus)
}
}
-73
View File
@@ -1,73 +0,0 @@
import {writable, derived, Subscriber} from "svelte/store"
import {fetchJson, Maybe} from "@welshman/lib"
import {RelayProfile} from "@welshman/util"
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
import {getter, deriveItems, makeForceLoadItem, makeLoadItem, makeDeriveItem} from "@welshman/store"
export const relaysByUrl = writable(new Map<string, RelayProfile>())
export const relays = deriveItems(relaysByUrl)
export const getRelaysByUrl = getter(relaysByUrl)
export const getRelays = getter(relays)
export const getRelay = (url: string) => getRelaysByUrl().get(url)
export const relaySubscribers: Subscriber<RelayProfile>[] = []
export const notifyRelay = (relay: RelayProfile) => relaySubscribers.forEach(sub => sub(relay))
export const onRelay = (sub: (relay: RelayProfile) => void) => {
relaySubscribers.push(sub)
return () => {
const i = relaySubscribers.findIndex(s => s === sub)
if (i !== -1) relaySubscribers.splice(i, 1)
}
}
export const fetchRelay = async (url: string): Promise<Maybe<RelayProfile>> => {
try {
const json = await fetchJson(url.replace(/^ws/, "http"), {
headers: {
Accept: "application/nostr+json",
},
})
if (json) {
const info = {...json, url}
if (!Array.isArray(info.supported_nips)) {
info.supported_nips = []
}
info.supported_nips = info.supported_nips.map(String)
relaysByUrl.update($relaysByUrl => {
$relaysByUrl.set(url, info)
return $relaysByUrl
})
notifyRelay(info)
return info
}
} catch (e) {
// pass
}
}
export const forceLoadRelay = makeForceLoadItem(fetchRelay, getRelay)
export const loadRelay = makeLoadItem(fetchRelay, getRelay)
export const deriveRelay = makeDeriveItem(relaysByUrl, loadRelay)
export const displayRelayByPubkey = (url: string) =>
displayRelayProfile(getRelay(url), displayRelayUrl(url))
export const deriveRelayDisplay = (url: string) =>
derived(deriveRelay(url), $relay => displayRelayProfile($relay, displayRelayUrl(url)))
-110
View File
@@ -1,110 +0,0 @@
import Fuse, {IFuseOptions, FuseResult} from "fuse.js"
import {debounce} from "throttle-debounce"
import {derived} from "svelte/store"
import {dec, inc, sortBy} from "@welshman/lib"
import {PROFILE, PublishedProfile, RelayProfile} from "@welshman/util"
import {load} from "@welshman/net"
import {throttled} from "@welshman/store"
import {Router} from "@welshman/router"
import {getWotGraph, getMaxWot} from "./wot.js"
import {profiles} from "./profiles.js"
import {topics, Topic} from "./topics.js"
import {relays} from "./relays.js"
import {handlesByNip05} from "./handles.js"
export type SearchOptions<V, T> = {
getValue: (item: T) => V
fuseOptions?: IFuseOptions<T>
onSearch?: (term: string) => void
sortFn?: (items: FuseResult<T>) => any
}
export type Search<V, T> = {
options: T[]
getValue: (item: T) => V
getOption: (value: V) => T | undefined
searchOptions: (term: string) => T[]
searchValues: (term: string) => V[]
}
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
const map = new Map<V, T>(options.map(item => [opts.getValue(item), item]))
const search = (term: string) => {
opts.onSearch?.(term)
let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult<T>)
if (opts.sortFn) {
results = sortBy(opts.sortFn, results)
}
return results.map(result => result.item)
}
return {
options,
getValue: opts.getValue,
getOption: (value: V) => map.get(value),
searchOptions: (term: string) => search(term),
searchValues: (term: string) => search(term).map(opts.getValue),
}
}
export const searchProfiles = debounce(500, (search: string) => {
if (search.length > 2) {
load({
filters: [{kinds: [PROFILE], search}],
relays: Router.get().Search().getUrls(),
})
}
})
export const profileSearch = derived(
[throttled(800, profiles), throttled(800, handlesByNip05)],
([$profiles, $handlesByNip05]) => {
// Remove invalid nip05's from profiles
const options = $profiles.map(p => {
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
return isNip05Valid ? p : {...p, nip05: ""}
})
return createSearch(options, {
onSearch: searchProfiles,
getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => {
const wotScore = getWotGraph().get(item.event.pubkey) || 0
return dec(score) * inc(wotScore / getMaxWot())
},
fuseOptions: {
keys: [
"nip05",
{name: "name", weight: 0.8},
{name: "display_name", weight: 0.5},
{name: "about", weight: 0.3},
],
threshold: 0.3,
shouldSort: false,
},
})
},
)
export const topicSearch = derived(topics, $topics =>
createSearch($topics, {
getValue: (topic: Topic) => topic.name,
fuseOptions: {keys: ["name"]},
}),
)
export const relaySearch = derived(relays, $relays =>
createSearch($relays, {
getValue: (relay: RelayProfile) => relay.url,
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
},
}),
)
-36
View File
@@ -1,36 +0,0 @@
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import {TrustedEvent} from "@welshman/util"
import {
deriveItemsByKey,
deriveItems,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
getter,
} from "@welshman/store"
import {repository} from "./core.js"
import {makeOutboxLoader} from "./relayLists.js"
export const searchRelayListsByPubkey = deriveItemsByKey({
repository,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
filters: [{kinds: [SEARCH_RELAYS]}],
getKey: searchRelayLists => searchRelayLists.event.pubkey,
})
export const searchRelayLists = deriveItems(searchRelayListsByPubkey)
export const getSearchRelayListsByPubkey = getter(searchRelayListsByPubkey)
export const getSearchRelayLists = getter(searchRelayLists)
export const getSearchRelayList = (pubkey: string) => getSearchRelayListsByPubkey().get(pubkey)
export const forceLoadSearchRelayList = makeForceLoadItem(
makeOutboxLoader(SEARCH_RELAYS),
getSearchRelayList,
)
export const loadSearchRelayList = makeLoadItem(makeOutboxLoader(SEARCH_RELAYS), getSearchRelayList)
export const deriveSearchRelayList = makeDeriveItem(searchRelayListsByPubkey, loadSearchRelayList)
+55 -311
View File
@@ -1,338 +1,82 @@
import {Client, ClientOptions, PomadeSigner} from "@pomade/core" import {Client as PomadeClient, PomadeSigner} from "@pomade/core"
import {derived, writable} from "svelte/store" import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
import {cached, randomId, append, omit, equals, assoc, TaskQueue} from "@welshman/lib" import type {MaybeAsync} from "@welshman/lib"
import {withGetter} from "@welshman/store" import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer"
import { import type {ISigner} from "@welshman/signer"
Wallet,
WRAP,
getPubkeyTagValues,
StampedEvent,
SignedEvent,
getPubkey,
} from "@welshman/util"
import {
Nip59,
WrappedSigner,
Nip46Broker,
Nip46Signer,
Nip07Signer,
Nip01Signer,
Nip55Signer,
ISigner,
} from "@welshman/signer"
import {WrapManager} from "@welshman/net"
import {tracker, repository} from "./core.js"
export enum SessionMethod { // ── Sessions: serializable {method, data} descriptors ──
Nip01 = "nip01",
Nip07 = "nip07", export type Session<M extends string = string, D = unknown> = {
Nip46 = "nip46", method: M
Nip55 = "nip55", data: D
Pomade = "pomade",
Pubkey = "pubkey",
Anonymous = "anonymous",
} }
export type SessionNip01 = { // ── Session handlers: a method string, its data shape, and how to build a signer ──
method: SessionMethod.Nip01
pubkey: string export type SessionHandler<M extends string, D> = {
secret: string method: M
getSigner: (data: D) => MaybeAsync<ISigner>
} }
export type SessionNip07 = { /**
method: SessionMethod.Nip07 * Define a session handler. `M` and `D` are inferred from the arguments, so
pubkey: string * `getSigner` is type-checked against the data shape — and the same handler is
} * used to build typed sessions (`toSession`) and to reconstruct signers.
*/
export const defineSessionHandler = <M extends string, D>(handler: SessionHandler<M, D>) => handler
export type SessionNip46 = { /** Build a typed, serializable session from a handler and its data. */
method: SessionMethod.Nip46 export const toSession = <M extends string, D>(
pubkey: string handler: SessionHandler<M, D>,
secret: string data: D,
handler: { ): Session<M, D> => ({method: handler.method, data})
pubkey: string
relays: string[]
}
}
export type SessionNip55 = { // ── Built-in handlers ──
method: SessionMethod.Nip55
pubkey: string
signer: string
}
export type SessionPomade = { export const nip01 = defineSessionHandler({
method: SessionMethod.Pomade method: "nip01",
pubkey: string getSigner: (data: {secret: string}) => new Nip01Signer(data.secret),
clientOptions: ClientOptions
email: string
}
export type SessionPubkey = {
method: SessionMethod.Pubkey
pubkey: string
}
export type SessionAnonymous = {
method: SessionMethod.Anonymous
}
export type SessionAnyMethod =
| SessionNip01
| SessionNip07
| SessionNip46
| SessionNip55
| SessionPomade
| SessionPubkey
| SessionAnonymous
export type Session = SessionAnyMethod & {wallet?: Wallet} & Record<string, any>
export const pubkey = withGetter(writable<string | undefined>(undefined))
export const sessions = withGetter(writable<Record<string, Session>>({}))
export const session = withGetter(
derived([pubkey, sessions], ([$pubkey, $sessions]) => ($pubkey ? $sessions[$pubkey] : undefined)),
)
export const getSession = (pubkey: string) => sessions.get()[pubkey]
export const addSession = (session: Session) => {
sessions.update(assoc(session.pubkey, session))
pubkey.set(session.pubkey)
}
export const putSession = (session: Session) => {
if (!equals(getSession(session.pubkey), session)) {
sessions.update(assoc(session.pubkey, session))
}
}
export const updateSession = (pubkey: string, f: (session: Session) => Session) =>
putSession(f(getSession(pubkey)))
export const dropSession = (_pubkey: string) => {
getSigner.pop(getSession(_pubkey))?.cleanup?.()
pubkey.update($pubkey => ($pubkey === _pubkey ? undefined : $pubkey))
sessions.update($sessions => omit([_pubkey], $sessions))
}
export const clearSessions = () => {
for (const pubkey of Object.keys(sessions.get())) {
dropSession(pubkey)
}
}
// Session factories
export const makeNip01Session = (secret: string): SessionNip01 => ({
method: SessionMethod.Nip01,
secret,
pubkey: getPubkey(secret),
}) })
export const makeNip07Session = (pubkey: string): SessionNip07 => ({ export const nip07 = defineSessionHandler({
method: SessionMethod.Nip07, method: "nip07",
pubkey, getSigner: (_data: Record<string, never>) => new Nip07Signer(),
}) })
export const makeNip46Session = ( export const nip46 = defineSessionHandler({
pubkey: string, method: "nip46",
clientSecret: string, getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) =>
signerPubkey: string, new Nip46Signer(new Nip46Broker(data)),
relays: string[],
): SessionNip46 => ({
method: SessionMethod.Nip46,
pubkey,
secret: clientSecret,
handler: {pubkey: signerPubkey, relays},
}) })
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({ export const nip55 = defineSessionHandler({
method: SessionMethod.Nip55, method: "nip55",
pubkey, getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey),
signer,
}) })
export const makePomadeSession = ( export const pomade = defineSessionHandler({
pubkey: string, method: "pomade",
email: string, getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) =>
clientOptions: ClientOptions, new PomadeSigner(new PomadeClient(data.clientOptions)),
): SessionPomade => ({
method: SessionMethod.Pomade,
pubkey,
clientOptions,
email,
}) })
export const makePubkeySession = (pubkey: string): SessionPubkey => ({ // ── Registry: deserialize a stored session back into a signer ──
method: SessionMethod.Pubkey,
pubkey,
})
// Type guards export const sessionHandlers = new Map<string, SessionHandler<string, any>>()
export const isNip01Session = (session?: Session): session is SessionNip01 => export const registerSessionHandler = (handler: SessionHandler<string, any>) => {
session?.method === SessionMethod.Nip01 sessionHandlers.set(handler.method, handler)
export const isNip07Session = (session?: Session): session is SessionNip07 =>
session?.method === SessionMethod.Nip07
export const isNip46Session = (session?: Session): session is SessionNip46 =>
session?.method === SessionMethod.Nip46
export const isNip55Session = (session?: Session): session is SessionNip55 =>
session?.method === SessionMethod.Nip55
export const isPomadeSession = (session?: Session): session is SessionPomade =>
session?.method === SessionMethod.Pomade
export const isPubkeySession = (session?: Session): session is SessionPubkey =>
session?.method === SessionMethod.Pubkey
// Login utilities
export const loginWithNip01 = (secret: string) => addSession(makeNip01Session(secret))
export const loginWithNip07 = (pubkey: string) => addSession(makeNip07Session(pubkey))
export const loginWithNip46 = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
) => addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
export const loginWithNip55 = (pubkey: string, signer: string) =>
addSession(makeNip55Session(pubkey, signer))
export const loginWithPomade = (pubkey: string, email: string, clientOptions: ClientOptions) =>
addSession(makePomadeSession(pubkey, email, clientOptions))
export const loginWithPubkey = (pubkey: string) => addSession(makePubkeySession(pubkey))
// Other stuff
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
export type SignerLogEntry = {
id: string
method: string
started_at: number
finished_at?: number
ok?: boolean
} }
export const signerLog = withGetter(writable<SignerLogEntry[]>([])) export const unregisterSessionHandler = (handler: SessionHandler<string, any>) => {
sessionHandlers.delete(handler.method)
export const wrapSigner = (signer: ISigner) =>
new WrappedSigner(signer, async <T>(method: string, thunk: () => Promise<T>) => {
const id = randomId()
signerLog.update(log => append({id, method, started_at: Date.now()}, log))
try {
const result = await thunk()
signerLog.update(log =>
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)),
)
return result
} catch (error: any) {
signerLog.update(log =>
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)),
)
throw error
}
})
export const getSigner = cached({
maxSize: 100,
getKey: ([session]: [Session | undefined]) => `${session?.method}:${session?.pubkey}`,
getValue: ([session]: [Session | undefined]) => {
if (isNip07Session(session)) return wrapSigner(new Nip07Signer())
if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret))
if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey))
if (isPomadeSession(session))
return wrapSigner(new PomadeSigner(new Client(session.clientOptions)))
if (isNip46Session(session)) {
const {
secret: clientSecret,
handler: {relays, pubkey: signerPubkey},
} = session
const broker = new Nip46Broker({clientSecret, signerPubkey, relays})
const signer = new Nip46Signer(broker)
return wrapSigner(signer)
}
},
})
export const getSignerFromPubkey = (pubkey: string) => {
const session = getSession(pubkey)
if (session) {
return getSigner(session)
}
} }
export const signer = withGetter(derived(session, getSigner)) export const getSignerFromSession = (session: Session): MaybeAsync<ISigner> | undefined =>
sessionHandlers.get(session.method)?.getSigner(session.data)
export const sign = (event: StampedEvent) => signer.get()?.sign(event) // ── Initialize default session handlers ──
export const nip44EncryptToSelf = (payload: string) => { for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) {
const $pubkey = pubkey.get() registerSessionHandler(sessionHandler)
const $signer = signer.get()
if (!$signer) {
throw new Error("Unable to encrypt to self without valid signer")
}
return $signer.nip44.encrypt($pubkey!, payload)
}
// Gift wrap utilities
export const wrapManager = new WrapManager({repository, tracker})
export const shouldUnwrap = withGetter(writable(false))
export const failedUnwraps = new Set<string>()
export const wrapQueue = new TaskQueue({
batchSize: 5,
batchDelay: 30,
processItem: async (wrap: SignedEvent) => {
for (const recipient of getPubkeyTagValues(wrap.tags)) {
const signer = getSignerFromPubkey(recipient)
if (signer) {
try {
const rumor = await Nip59.fromSigner(signer).unwrap(wrap)
wrapManager.add({wrap, rumor, recipient})
return rumor
} catch (e) {
failedUnwraps.add(wrap.id)
}
}
}
},
})
export const unwrapAndStore = async (wrap: SignedEvent) => {
if (wrap.kind !== WRAP) {
throw new Error("Tried to unwrap an invalid event")
}
if (!shouldUnwrap.get()) {
throw new Error("Discarding wrapped event because `shouldUnwrap` is not enabled")
}
if (!failedUnwraps.has(wrap.id) && !wrapManager.getRumor(wrap.id)) {
wrapQueue.push(wrap)
}
} }
-47
View File
@@ -1,47 +0,0 @@
import type {Filter} from "@welshman/util"
import {isSignedEvent, SignedEvent} from "@welshman/util"
import {push as basePush, pull as basePull, publishOne, requestOne} from "@welshman/net"
import {repository} from "./core.js"
import {getRelay} from "./relays.js"
const query = (filters: Filter[]) =>
repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
export const hasNegentropy = (url: string) => {
const relay = getRelay(url)
if (relay?.negentropy) return true
if (relay?.supported_nips?.includes?.("77")) return true
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
return false
}
export type AppSyncOpts = {
relays: string[]
filters: Filter[]
}
export const pull = async ({relays, filters}: AppSyncOpts) => {
const events = query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
await (hasNegentropy(relay)
? basePull({filters, events, relays: [relay]})
: requestOne({filters, relay, autoClose: true}))
}),
)
}
export const push = async ({relays, filters}: AppSyncOpts) => {
const events = query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
await (hasNegentropy(relay)
? basePush({filters, events, relays: [relay]})
: Promise.all(events.map((event: SignedEvent) => publishOne({event, relay}))))
}),
)
}
-131
View File
@@ -1,131 +0,0 @@
import {uniq, remove} from "@welshman/lib"
import {
getAddress,
isReplaceable,
getReplyTags,
getPubkeyTagValues,
isReplaceableKind,
isShareableRelayUrl,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Router} from "@welshman/router"
import {displayProfileByPubkey} from "./profiles.js"
import {pubkey} from "./session.js"
export const tagZapSplit = (pubkey: string, split = 1) => [
"zap",
pubkey,
Router.get().FromPubkey(pubkey).getUrl() || "",
String(split),
]
export const tagPubkey = (pubkey: string, ...args: unknown[]) => [
"p",
pubkey,
Router.get().FromPubkey(pubkey).getUrl() || "",
displayProfileByPubkey(pubkey),
]
export const tagEvent = (event: TrustedEvent, url = "", mark = "") => {
if (!url) {
url = Router.get().Event(event).getUrl() || ""
}
const tags = [["e", event.id, url, mark, event.pubkey]]
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), url, mark, event.pubkey])
}
return tags
}
export const tagEventPubkeys = (event: TrustedEvent) =>
uniq(remove(pubkey.get()!, [event.pubkey, ...getPubkeyTagValues(event.tags)])).map(tagPubkey)
export const tagEventForQuote = (event: TrustedEvent, relay?: string) => {
const hint = relay || Router.get().Event(event).getUrl() || ""
return ["q", event.id, hint, event.pubkey]
}
export const tagEventForReply = (event: TrustedEvent, relay?: string) => {
const tags = tagEventPubkeys(event)
const {roots, replies} = getReplyTags(event.tags)
const parents = roots.length > 0 ? roots : replies
const mark = parents.length > 0 ? "reply" : "root"
const hint = relay || Router.get().Event(event).getUrl() || ""
// If the parent included roots use them, otherwise use replies as a fallback
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
const hint = isShareableRelayUrl(originalHint)
? originalHint
: Router.get().EventRoots(event).getUrl()
tags.push([k, id, hint || "", "root", pubkey])
}
// e-tag the event
tags.push(["e", event.id, hint, mark, event.pubkey])
// a-tag the event
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), hint, mark, event.pubkey])
}
return tags
}
export const tagEventForComment = (event: TrustedEvent, relay?: string) => {
const pubkeyHint = Router.get().FromPubkey(event.pubkey).getUrl() || ""
const eventHint = relay || Router.get().Event(event).getUrl() || ""
const address = getAddress(event)
const seenRoots = new Set<string>()
const tags: string[][] = []
for (const [t, ...tag] of event.tags) {
if (["K", "E", "A", "I", "P"].includes(t)) {
tags.push([t, ...tag])
seenRoots.add(t)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["P", event.pubkey, pubkeyHint])
tags.push(["E", event.id, eventHint, event.pubkey])
if (isReplaceableKind(event.kind)) {
tags.push(["A", address, eventHint, event.pubkey])
}
}
tags.push(["k", String(event.kind)])
tags.push(["p", event.pubkey, pubkeyHint])
tags.push(["e", event.id, eventHint, event.pubkey])
if (isReplaceableKind(event.kind)) {
tags.push(["a", address, eventHint, event.pubkey])
}
return tags
}
export const tagEventForReaction = (event: TrustedEvent, relay?: string) => {
const hint = relay || Router.get().Event(event).getUrl() || ""
const tags: string[][] = []
// Mention the event's author
if (event.pubkey !== pubkey.get()) {
tags.push(tagPubkey(event.pubkey))
}
tags.push(["k", String(event.kind)])
tags.push(["e", event.id, hint])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), hint])
}
return tags
}
-431
View File
@@ -1,431 +0,0 @@
import type {Subscriber} from "svelte/store"
import {writable} from "svelte/store"
import type {Override} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, without} from "@welshman/lib"
import {
HashedEvent,
EventTemplate,
SignedEvent,
isSignedEvent,
WRAPPED_KINDS,
prep,
makePow,
} from "@welshman/util"
import {
publish,
PublishStatus,
PublishResult,
PublishOptions,
PublishResultsByRelay,
} from "@welshman/net"
import {ISigner, Nip01Signer, Nip59} from "@welshman/signer"
import {repository, tracker} from "./core.js"
import {pubkey, signer, wrapManager} from "./session.js"
export type ThunkOptions = Override<
PublishOptions,
{
event: EventTemplate
recipient?: string
delay?: number
pow?: number
}
>
export class Thunk {
_subs: Subscriber<Thunk>[] = []
pubkey: string
signer: ISigner
event: HashedEvent
results: PublishResultsByRelay = {}
complete = defer<void>()
controller = new AbortController()
wrap?: SignedEvent
constructor(readonly options: ThunkOptions) {
if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
}
const $pubkey = pubkey.get()
if (!$pubkey) {
throw new Error(`Attempted to publish an event without an active pubkey`)
}
const $signer = signer.get()
if (!$signer) {
throw new Error(`Attempted to publish an event without an active signer`)
}
this.pubkey = $pubkey
this.signer = $signer
this.event = prep(options.event, this.pubkey)
for (const relay of options.relays) {
this.results[relay] = {
relay,
status: PublishStatus.Sending,
detail: "sending...",
}
}
this.controller.signal.addEventListener("abort", () => {
for (const relay of options.relays) {
this._setAborted({
relay,
status: PublishStatus.Aborted,
detail: "aborted",
})
}
})
}
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
_fail(detail: string) {
for (const relay of this.options.relays) {
this.results[relay] = {
relay,
status: PublishStatus.Failure,
detail: detail,
}
}
this._notify()
}
_setPending = (result: PublishResult) => {
this.options.onPending?.(result)
this.results[result.relay] = result
this._notify()
}
_setTimeout = (result: PublishResult) => {
this.options.onTimeout?.(result)
this.results[result.relay] = result
this._notify()
}
_setAborted = (result: PublishResult) => {
this.options.onAborted?.(result)
this.results[result.relay] = result
this._notify()
}
async _publish(event: SignedEvent) {
// Wait if the thunk is to be delayed
if (this.options.delay) {
await sleep(this.options.delay)
}
// Skip publishing if aborted
if (this.controller.signal.aborted) {
return
}
// Send it off
await publish({
...this.options,
event,
onSuccess: (result: PublishResult) => {
this.options.onSuccess?.(result)
this.results[result.relay] = result
this._notify()
},
onFailure: (result: PublishResult) => {
tracker.removeRelay(event.id, result.relay)
this.options.onFailure?.(result)
this.results[result.relay] = result
this._notify()
},
onPending: this._setPending,
onTimeout: (result: PublishResult) => {
tracker.removeRelay(event.id, result.relay)
this._setTimeout(result)
},
onAborted: (result: PublishResult) => {
tracker.removeRelay(event.id, result.relay)
this._setAborted(result)
},
onComplete: (result: PublishResult) => {
this.options.onComplete?.(result)
this._subs = []
},
})
// Notify the caller that we're done
this.complete.resolve()
}
async publish() {
// Handle abort immediately if possible
if (this.controller.signal.aborted) return
const {recipient} = this.options
// If we're sending it privately, wrap the event using nip 59
if (recipient) {
const wrapper = Nip01Signer.ephemeral()
const nip59 = new Nip59(this.signer, wrapper)
this.wrap = await nip59.wrap(recipient, this.event)
// If we're calculating pow, update the hash and re-sign
if (this.options.pow) {
this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, {
signal: AbortSignal.timeout(30_000),
})
}
wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
return this._publish(this.wrap)
}
// If the event has been signed, we're good to go
if (isSignedEvent(this.event)) {
if (this.options.pow) {
console.warn("Event is already signed, skipping proof of work calculation")
}
return this._publish(this.event)
}
// Allow for lazily signing/powing events in order to decrease apparent latency in the UI
// that results from waiting for remote signers
try {
if (this.options.pow) {
this.event = await makePow(this.event, this.options.pow).result
}
const signedEvent = await this.signer.sign(this.event, {
signal: AbortSignal.timeout(30_000),
})
// Update tracker and repository with the signed event since the id will have changed
if (this.options.pow) {
for (const url of this.options.relays) {
tracker.removeRelay(this.event.id, url)
tracker.track(signedEvent.id, url)
}
}
repository.removeEvent(this.event.id)
repository.publish(signedEvent)
return this._publish(signedEvent)
} catch (e: any) {
console.error("Failed to sign event", e)
return this._fail(String(e || "Failed to sign event"))
}
}
enqueue() {
thunkQueue.push(this)
for (const url of this.options.relays) {
tracker.track(this.event.id, url)
}
repository.publish(this.event)
thunks.update($thunks => append(this, $thunks))
this.controller.signal.addEventListener("abort", () => {
if (this.wrap) {
wrapManager.remove(this.wrap.id)
} else {
repository.removeEvent(this.event.id)
}
thunks.update($thunks => remove(this, $thunks))
})
}
subscribe(subscriber: Subscriber<Thunk>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
}
}
export class MergedThunk {
_subs: Subscriber<MergedThunk>[] = []
results: PublishResultsByRelay = {}
constructor(readonly thunks: Thunk[]) {
const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus
const relays = new Set(thunks.flatMap(thunk => thunk.options.relays))
for (const thunk of thunks) {
thunk.subscribe($thunk => {
this.results = {}
for (const relay of relays) {
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
const thunk = thunks.find(t => t.results[relay]?.status === status)
if (thunk) {
this.results[relay] = thunk.results[relay]!
}
}
}
this._notify()
if (thunks.every(thunkIsComplete)) {
this._subs = []
}
})
}
}
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
subscribe(subscriber: Subscriber<MergedThunk>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
}
}
export type AbstractThunk = Thunk | MergedThunk
export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk
export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk =>
thunk instanceof MergedThunk
// Thunk status urls
export const getThunkUrlsWithStatus = (
statuses: PublishStatus | PublishStatus[],
thunk: AbstractThunk,
) => {
statuses = ensurePlural(statuses)
return Object.entries(thunk.results)
.filter(([_, {status}]) => statuses.includes(status))
.map(nth(0)) as string[]
}
export const getCompleteThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus(
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
thunk,
)
export const getIncompleteThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
export const getFailedThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout], thunk)
// Thunk status checks
export const thunkHasStatus = (statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk) =>
getThunkUrlsWithStatus(statuses, thunk).length > 0
export const thunkIsComplete = (thunk: AbstractThunk) =>
!thunkHasStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
// Thunk errors
export const getThunkError = (thunk: Thunk) => {
for (const [_, {status, detail}] of Object.entries(thunk.results)) {
if (status === PublishStatus.Failure) {
return detail
}
}
if (thunkIsComplete(thunk)) {
return ""
}
}
// Thunk utilities that return promises
export const waitForThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
const error = getThunkError($thunk)
if (error !== undefined) {
resolve(error)
}
})
})
export const waitForThunkCompletion = (thunk: Thunk) =>
new Promise<void>(resolve => {
thunk.subscribe($thunk => {
if (thunkIsComplete($thunk)) {
resolve()
}
})
})
// Thunk state
export const thunks = writable<Thunk[]>([])
export const thunkQueue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
// Other thunk utilities
export const mergeThunks = (thunks: AbstractThunk[]) =>
new MergedThunk(Array.from(flattenThunks(thunks)))
export function* flattenThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
for (const thunk of thunks) {
if (isMergedThunk(thunk)) {
yield* flattenThunks(thunk.thunks)
} else {
yield thunk
}
}
}
export const publishThunk = (options: ThunkOptions) => {
const thunk = new Thunk(options)
thunk.enqueue()
return thunk
}
export const abortThunk = (thunk: AbstractThunk) => {
for (const child of flattenThunks([thunk])) {
child.controller.abort()
}
}
export const retryThunk = (thunk: AbstractThunk) =>
isMergedThunk(thunk)
? mergeThunks(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
-49
View File
@@ -1,49 +0,0 @@
import {readable} from "svelte/store"
import {on, call} from "@welshman/lib"
import {deriveItems} from "@welshman/store"
import {getTopicTagValues} from "@welshman/util"
import {repository} from "./core.js"
export type Topic = {
name: string
count: number
}
export const topicsByName = call(() => {
const topicsByName = new Map<string, Topic>()
const addTopic = (name: string) => {
const topic = topicsByName.get(name)
if (topic) {
topic.count++
} else {
topicsByName.set(name, {name, count: 1})
}
}
for (const tagString of repository.eventsByTag.keys()) {
if (tagString.startsWith("t:")) {
addTopic(tagString.slice(2).toLowerCase())
}
}
return readable<Map<string, Topic>>(topicsByName, set => {
return on(repository, "update", ({added}) => {
let dirty = false
for (const event of added) {
for (const name of getTopicTagValues(event.tags)) {
addTopic(name)
dirty = true
}
}
if (dirty) {
set(topicsByName)
}
})
})
})
export const topics = deriveItems(topicsByName)
+47 -84
View File
@@ -1,94 +1,57 @@
import {derived, Readable} from "svelte/store" import type {StampedEvent} from "@welshman/util"
import {ItemsByKey, deriveDeduplicated} from "@welshman/store" import type {ISigner} from "@welshman/signer"
import {pubkey} from "./session.js" import {LoggingSigner} from "./logging.js"
import {profilesByPubkey, forceLoadProfile, loadProfile} from "./profiles.js" import {getSignerFromSession} from "./session.js"
import {followListsByPubkey, forceLoadFollowList, loadFollowList} from "./follows.js" import type {Session} from "./session.js"
import {pinListsByPubkey, forceLoadPinList, loadPinList} from "./pins.js" import type {IApp} from "./app.js"
import {muteListsByPubkey, forceLoadMuteList, loadMuteList} from "./mutes.js"
import {
blossomServerListsByPubkey,
forceLoadBlossomServerList,
loadBlossomServerList,
} from "./blossom.js"
import {relayListsByPubkey, forceLoadRelayList, loadRelayList} from "./relayLists.js"
import {
messagingRelayListsByPubkey,
forceLoadMessagingRelayList,
loadMessagingRelayList,
} from "./messagingRelayLists.js"
import {
blockedRelayListsByPubkey,
forceLoadBlockedRelayList,
loadBlockedRelayList,
} from "./blockedRelayLists.js"
import {
searchRelayListsByPubkey,
forceLoadSearchRelayList,
loadSearchRelayList,
} from "./searchRelayLists.js"
import {wotGraph, getWotGraph} from "./wot.js"
export const makeUserData = <T>( /**
itemsByKey: Readable<ItemsByKey<T>>, * A single identity: a pubkey plus the signer that proves it. An `App` is
onDerive?: (key: string, ...args: any[]) => void, * centered on (at most) one `User`, since the data a user can access depends
) => * entirely on who they are.
deriveDeduplicated([itemsByKey, pubkey], ([$itemsByKey, $pubkey]) => { */
if (!$pubkey) return undefined export class User {
constructor(
readonly pubkey: string,
readonly signer: ISigner,
) {}
onDerive?.($pubkey) static async fromSigner(signer: ISigner) {
if (!(signer instanceof LoggingSigner)) {
return $itemsByKey.get($pubkey) signer = new LoggingSigner(signer)
})
export const makeUserLoader =
(loadItem: (key: string, ...args: any[]) => void) =>
async (...args: any[]) => {
const $pubkey = pubkey.get()
if ($pubkey) {
await loadItem($pubkey, ...args)
} }
const pubkey = await signer.getPubkey()
return new User(pubkey, signer)
} }
export const userProfile = makeUserData(profilesByPubkey, loadProfile) /**
export const forceLoadUserProfile = makeUserLoader(forceLoadProfile) * Reconstruct a signing user from a persisted session, using the registered
export const loadUserProfile = makeUserLoader(loadProfile) * session handlers to find the one for the session's method. The signer is
* wrapped in a `LoggingSigner` (observe it with `makeAppPolicyLogger`) and the
* pubkey is derived from it. Returns undefined when no handler is registered
* for the session's method.
*/
static async fromSession(session: Session): Promise<User | undefined> {
const signer = await getSignerFromSession(session)
export const userFollowList = makeUserData(followListsByPubkey, loadFollowList) return signer ? User.fromSigner(signer) : undefined
export const forceLoadUserFollowList = makeUserLoader(forceLoadFollowList) }
export const loadUserFollowList = makeUserLoader(loadFollowList)
export const userMuteList = makeUserData(muteListsByPubkey, loadMuteList) /**
export const forceLoadUserMuteList = makeUserLoader(forceLoadMuteList) * Return the app's signed-in user, throwing if there isn't one — the entry
export const loadUserMuteList = makeUserLoader(loadMuteList) * point for actions that can only run as a user (publishing, signing).
*/
static require(app: IApp): User {
if (!app.user) {
throw new Error("This action requires a signed-in user")
}
export const userPinList = makeUserData(pinListsByPubkey, loadPinList) return app.user
export const forceLoadUserPinList = makeUserLoader(forceLoadPinList) }
export const loadUserPinList = makeUserLoader(loadPinList)
export const userRelayList = makeUserData(relayListsByPubkey, loadRelayList) sign = (event: StampedEvent) => this.signer.sign(event)
export const forceLoadUserRelayList = makeUserLoader(forceLoadRelayList)
export const loadUserRelayList = makeUserLoader(loadRelayList)
export const userMessagingRelayList = makeUserData( nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
messagingRelayListsByPubkey, }
loadMessagingRelayList,
)
export const forceLoadUserMessagingRelayList = makeUserLoader(forceLoadMessagingRelayList)
export const loadUserMessagingRelayList = makeUserLoader(loadMessagingRelayList)
export const userSearchRelayList = makeUserData(searchRelayListsByPubkey, loadSearchRelayList)
export const forceLoadUserSearchRelayList = makeUserLoader(forceLoadSearchRelayList)
export const loadUserSearchRelayList = makeUserLoader(loadSearchRelayList)
export const userBlockedRelayList = makeUserData(blockedRelayListsByPubkey, loadBlockedRelayList)
export const forceLoadUserBlockedRelayList = makeUserLoader(forceLoadBlockedRelayList)
export const loadUserBlockedRelayList = makeUserLoader(loadBlockedRelayList)
export const userBlossomServerList = makeUserData(blossomServerListsByPubkey, loadBlossomServerList)
export const forceLoadUserBlossomServerList = makeUserLoader(forceLoadBlossomServerList)
export const loadUserBlossomServerList = makeUserLoader(loadBlossomServerList)
export const getUserWotScore = (tpk: string) => getWotGraph().get(tpk) || 0
export const deriveUserWotScore = (tpk: string) => derived(wotGraph, $g => $g.get(tpk) || 0)
-101
View File
@@ -1,101 +0,0 @@
import {derived, writable} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled, getter} from "@welshman/store"
import {pubkey} from "./session.js"
import {followLists, getFollowListsByPubkey, getFollowList} from "./follows.js"
import {muteLists, getMuteList} from "./mutes.js"
export const getFollows = (pubkey: string) => getPubkeyTagValues(getListTags(getFollowList(pubkey)))
export const getMutes = (pubkey: string) => getPubkeyTagValues(getListTags(getMuteList(pubkey)))
export const getNetwork = (pubkey: string) => {
const pubkeys = new Set(getFollows(pubkey))
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of getFollows(follow)) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
}
}
return Array.from(network)
}
export const followersByPubkey = derived(throttled(1000, followLists), lists => {
const $followersByPubkey = new Map<string, Set<string>>()
for (const list of lists) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
}
}
return $followersByPubkey
})
export const getFollowersByPubkey = getter(followersByPubkey)
export const mutersByPubkey = derived(throttled(1000, muteLists), lists => {
const $mutersByPubkey = new Map<string, Set<string>>()
for (const list of lists) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
}
}
return $mutersByPubkey
})
export const getMutersByPubkey = getter(mutersByPubkey)
export const getFollowers = (pubkey: string) => Array.from(getFollowersByPubkey().get(pubkey) || [])
export const getMuters = (pubkey: string) => Array.from(getMutersByPubkey().get(pubkey) || [])
export const getFollowsWhoFollow = (pubkey: string, target: string) =>
getFollows(pubkey).filter(other => getFollows(other).includes(target))
export const getFollowsWhoMute = (pubkey: string, target: string) =>
getFollows(pubkey).filter(other => getMutes(other).includes(target))
export const wotGraph = writable(new Map<string, number>())
export const getWotGraph = getter(wotGraph)
export const maxWot = derived(wotGraph, $g => max(Array.from($g.values())))
export const getMaxWot = getter(maxWot)
const buildGraph = throttle(1000, () => {
const $pubkey = pubkey.get()
const $graph = new Map<string, number>()
const $follows = $pubkey ? getFollows($pubkey) : getFollowListsByPubkey().keys()
for (const follow of $follows) {
for (const pubkey of getFollows(follow)) {
$graph.set(pubkey, inc($graph.get(pubkey)))
}
for (const pubkey of getMutes(follow)) {
$graph.set(pubkey, dec($graph.get(pubkey)))
}
}
wotGraph.set($graph)
})
pubkey.subscribe(buildGraph)
followLists.subscribe(buildGraph)
muteLists.subscribe(buildGraph)
export const getWotScore = (pubkey: string, target: string) => {
const follows = pubkey ? getFollowsWhoFollow(pubkey, target) : getFollowers(target)
const mutes = pubkey ? getFollowsWhoMute(pubkey, target) : getMuters(target)
return follows.length - mutes.length
}
-156
View File
@@ -1,156 +0,0 @@
import {writable, Subscriber} from "svelte/store"
import {Zapper, TrustedEvent, Zap, getTagValues, zapFromEvent} from "@welshman/util"
import {
removeUndefined,
fetchJson,
bech32ToHex,
hexToBech32,
tryCatch,
batcher,
postJson,
} from "@welshman/lib"
import {
getter,
deriveItems,
deriveDeduplicated,
makeForceLoadItem,
makeLoadItem,
makeDeriveItem,
} from "@welshman/store"
import {deriveProfile, loadProfile} from "./profiles.js"
import {appContext} from "./context.js"
export const zappersByLnurl = writable(new Map<string, Zapper>())
export const zappers = deriveItems(zappersByLnurl)
export const getZappersByLnurl = getter(zappersByLnurl)
export const getZapper = (lnurl: string) => getZappersByLnurl().get(lnurl)
export const zapperSubscribers: Subscriber<Zapper>[] = []
export const notifyZapper = (zapper: Zapper) => zapperSubscribers.forEach(sub => sub(zapper))
export const onZapper = (sub: (zapper: Zapper) => void) => {
zapperSubscribers.push(sub)
return () => {
const i = zapperSubscribers.findIndex(s => s === sub)
if (i !== -1) zapperSubscribers.splice(i, 1)
}
}
export const fetchZapper = batcher(800, async (lnurls: string[]) => {
const base = appContext.dufflepudUrl
const result = new Map<string, Zapper>()
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
const addZapper = (lnurl: string, info: any) => {
if (info) {
try {
result.set(lnurl, {...info, lnurl})
} catch (e) {
// pass
}
}
}
// Use dufflepud if we it's set up to protect user privacy, otherwise fetch directly
if (base) {
const hexUrls = valid.map(bech32ToHex)
const res = await tryCatch(() => postJson(`${base}/zapper/info`, {lnurls: hexUrls}))
for (const {lnurl, info} of res?.data || []) {
addZapper(hexToBech32("lnurl", lnurl), info)
}
} else {
await Promise.all(
valid.map(async lnurl => {
addZapper(lnurl, await tryCatch(() => fetchJson(bech32ToHex(lnurl))))
}),
)
}
if (result.size > 0) {
zappersByLnurl.update($zappersByLnurl => {
for (const [lnurl, zapper] of result) {
$zappersByLnurl.set(lnurl, zapper)
}
return $zappersByLnurl
})
for (const zapper of result.values()) {
notifyZapper(zapper)
}
}
return lnurls.map(lnurl => result.get(lnurl))
})
export const forceLoadZapper = makeForceLoadItem(fetchZapper, getZapper)
export const loadZapper = makeLoadItem(fetchZapper, getZapper)
export const deriveZapper = makeDeriveItem(zappersByLnurl, loadZapper)
export const loadZapperForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await loadProfile(pubkey, relays)
return $profile?.lnurl ? loadZapper($profile.lnurl) : undefined
}
export const deriveZapperForPubkey = (pubkey: string, relays: string[] = []) => {
loadZapperForPubkey(pubkey, relays)
return deriveDeduplicated(
[zappersByLnurl, deriveProfile(pubkey, relays)],
([$zappersByLnurl, $profile]) => {
return $profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined
},
)
}
export const getLnUrlsForEvent = async (event: TrustedEvent) => {
const pubkeys = getTagValues("zap", event.tags)
if (pubkeys.length > 0) {
const profiles = await Promise.all(pubkeys.map(pubkey => loadProfile(pubkey)))
const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
if (lnurls.length > 0) {
return lnurls
}
}
const profile = await loadProfile(event.pubkey)
return removeUndefined([profile?.lnurl])
}
export const getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
const lnurls = await getLnUrlsForEvent(parent)
return lnurls.length > 0 ? loadZapper(lnurls[0]) : undefined
}
export const getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
const zapper = await getZapperForZap(zap, parent)
return zapper ? zapFromEvent(zap, zapper) : undefined
}
export const getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) =>
removeUndefined(await Promise.all(zaps.map(zap => getValidZap(zap, parent))))
export const deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => {
const store = writable<Zap[]>([])
getValidZaps(zaps, parent).then(validZaps => {
store.set(validZaps)
})
return store
}
+2
View File
@@ -4,9 +4,11 @@
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"paths": { "paths": {
"@welshman/domain": ["../domain/src/index.js"],
"@welshman/feeds": ["../feeds/src/index.js"], "@welshman/feeds": ["../feeds/src/index.js"],
"@welshman/lib": ["../lib/src/index.js"], "@welshman/lib": ["../lib/src/index.js"],
"@welshman/net": ["../net/src/index.js"], "@welshman/net": ["../net/src/index.js"],
"@welshman/router": ["../router/src/index.js"],
"@welshman/signer": ["../signer/src/index.js"], "@welshman/signer": ["../signer/src/index.js"],
"@welshman/store": ["../store/src/index.js"], "@welshman/store": ["../store/src/index.js"],
"@welshman/util": ["../util/src/index.js"] "@welshman/util": ["../util/src/index.js"]
+2
View File
@@ -0,0 +1,2 @@
build
__tests__
@@ -0,0 +1,110 @@
import {describe, it, expect} from "vitest"
import {makeSecret, BLOCKED_RELAYS, NOTE, getTagValues, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {BlockedRelayList, BlockedRelayListBuilder} from "../src/kinds/BlockedRelayList"
const signer = new Nip01Signer(makeSecret())
const pubkey = "ee".repeat(32)
const r1 = "wss://relay.one.example/"
const r2 = "wss://relay.two.example/"
const r3 = "wss://relay.three.example/"
const makeEvent = (o: Partial<TrustedEvent> = {}): TrustedEvent =>
({
id: "ff".repeat(32),
pubkey,
created_at: 0,
kind: BLOCKED_RELAYS,
tags: [],
content: "",
sig: "00".repeat(64),
...o,
}) as TrustedEvent
describe("BlockedRelayList", () => {
it("reads relay urls from relay tags", async () => {
const event = makeEvent({
tags: [
["relay", r1],
["relay", r2],
["alt", "x"],
],
})
const list = await BlockedRelayList.fromEvent(event)
expect(list.urls().sort()).toEqual([r1, r2].sort())
expect(list.includes(r1)).toBe(true)
expect(list.includes(r3)).toBe(false)
})
it("round-trips without duplicating tags and preserves passthrough", async () => {
const event = makeEvent({
tags: [
["relay", r1],
["relay", r2],
["alt", "x"],
],
})
const list = await BlockedRelayList.fromEvent(event)
const tmpl = await list.builder().toTemplate(signer)
expect(tmpl.kind).toBe(BLOCKED_RELAYS)
expect(tmpl.tags.filter(t => t[0] === "relay").length).toBe(2)
expect(tmpl.tags).toContainEqual(["alt", "x"])
})
it("builds from a fresh builder and normalizes urls", async () => {
const tmpl = await new BlockedRelayListBuilder()
.addUrl("wss://relay.one.example")
.toTemplate(signer)
expect(getTagValues("relay", tmpl.tags)).toEqual([normalizeRelayUrl("wss://relay.one.example")])
})
it("setRelays replaces existing relays", async () => {
const event = makeEvent({tags: [["relay", r1]]})
const list = await BlockedRelayList.fromEvent(event)
const tmpl = await list.builder().setUrls([r2, r3]).toTemplate(signer)
expect(getTagValues("relay", tmpl.tags).sort()).toEqual([r2, r3].sort())
})
it("round-trips public and private entries through encryption", async () => {
const event = await new BlockedRelayListBuilder()
.addUrl(r1)
.addPrivate(["relay", r2])
.toEvent(signer)
expect(event.kind).toBe(BLOCKED_RELAYS)
expect(getTagValues("relay", event.tags)).toEqual([r1])
expect(event.content).not.toBe("")
const decrypted = await BlockedRelayList.fromEvent(event, signer)
expect(decrypted.decrypted).toBe(true)
expect(decrypted.urls().sort()).toEqual([r1, r2].sort())
const publicOnly = await BlockedRelayList.fromEvent(event)
expect(publicOnly.decrypted).toBe(false)
expect(publicOnly.urls()).toEqual([r1])
})
it("preserves undecrypted ciphertext on pass-through", async () => {
const event = await new BlockedRelayListBuilder().addPrivate(["relay", r2]).toEvent(signer)
const undecrypted = await BlockedRelayList.fromEvent(event)
const tmpl = await undecrypted.builder().toTemplate(signer)
expect(tmpl.content).toBe(event.content)
})
it("throws on the wrong kind", async () => {
await expect(BlockedRelayList.fromEvent(makeEvent({kind: NOTE}))).rejects.toThrow()
})
})

Some files were not shown because too many files have changed in this diff Show More