This commit is contained in:
+173
@@ -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`).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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(profile) // build & publish a profile event (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<List>>
|
||||
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<PublishedList>>
|
||||
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)`, `addRelay`, `removeRelay`, `setRelays`):
|
||||
|
||||
| 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) // Projection<string>
|
||||
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
|
||||
```
|
||||
@@ -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") // PublishedProfile[]
|
||||
profileSearch.getOption(pubkey) // PublishedProfile | 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
@@ -2,82 +2,93 @@
|
||||
|
||||
[](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
|
||||
- **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
|
||||
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.
|
||||
|
||||
```typescript
|
||||
import {getNip07} from '@welshman/signer'
|
||||
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'
|
||||
import {createApp} from "@welshman/app"
|
||||
|
||||
// Log in via NIP 07
|
||||
addSession({method: 'nip07', pubkey: await getNip07().getPubkey()})
|
||||
// A batteries-included app (event ingestion, relay stats, gift-wrap
|
||||
// unwrapping, and NIP-42 auth are all wired up by default policies)
|
||||
const app = createApp()
|
||||
```
|
||||
|
||||
// Enable automatic authentication to relays
|
||||
defaultSocketPolicies.push(
|
||||
makeSocketPolicyAuth({
|
||||
sign: (event: StampedEvent) => signer.get()?.sign(event),
|
||||
shouldAuth: (socket: Socket) => true,
|
||||
}),
|
||||
)
|
||||
Features are exposed as **plugins** — lazily-constructed singletons resolved through `app.use(...)`:
|
||||
|
||||
// This will fetch the user's profile automatically, and return a store that updates
|
||||
// automatically. Several different stores exist that are ready to go, including handles,
|
||||
// zappers, relayLists, relays, follows, mutes.
|
||||
const profile = deriveProfile(pubkey.get())
|
||||
```typescript
|
||||
import {createApp, Profiles, RelayLists, Thunks} from "@welshman/app"
|
||||
|
||||
// Publish is done using thunks, which optimistically publish to the local database, deferring
|
||||
// signing and publishing for instant user feedback. Progress is reported as relays accept/reject the event
|
||||
// Events are automatically signed using the current session
|
||||
const thunk = publishThunk({
|
||||
relays: Router.get().FromUser().getUrls(),
|
||||
const app = createApp()
|
||||
|
||||
// Each plugin is constructed once per app and memoized
|
||||
const profiles = app.use(Profiles)
|
||||
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"}),
|
||||
delay: 3000,
|
||||
delay: 3000, // soft-undo window
|
||||
})
|
||||
|
||||
// Thunks can be aborted until after `delay`, allowing for soft-undo
|
||||
thunk.controller.abort()
|
||||
// Abort before `delay` elapses to undo
|
||||
// thunk.abort()
|
||||
await thunk.waitForCompletion()
|
||||
|
||||
// Some commands are included
|
||||
const thunk = follow(['p', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'])
|
||||
|
||||
// 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()
|
||||
// 5. Tear it all down
|
||||
app.cleanup()
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)`
|
||||
@@ -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})
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}),
|
||||
})
|
||||
```
|
||||
@@ -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)`.
|
||||
@@ -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
@@ -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
|
||||
// User profile
|
||||
export const userProfile: Store<Profile | undefined>
|
||||
class User {
|
||||
constructor(readonly pubkey: string, readonly signer: ISigner)
|
||||
|
||||
// User follows list
|
||||
export const userFollowList: Store<List | undefined>
|
||||
static fromSigner(signer: ISigner): Promise<User>
|
||||
static fromSession(session: Session): Promise<User | undefined>
|
||||
static require(app: IApp): User
|
||||
|
||||
// User mutes list
|
||||
export const userMuteList: Store<List | undefined>
|
||||
|
||||
// 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>
|
||||
sign(event: StampedEvent): Promise<SignedEvent>
|
||||
nip44EncryptToSelf(payload: string): Promise<string>
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
// Load user profile
|
||||
function loadUserProfile(relays?: string[]): Promise<void>
|
||||
import {User} from "@welshman/app"
|
||||
import {getNip07} from "@welshman/signer"
|
||||
|
||||
// Load user follows
|
||||
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>
|
||||
const user = await User.fromSigner(getNip07())
|
||||
```
|
||||
|
||||
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
|
||||
function forceLoadUserProfile(relays?: string[]): Promise<void>
|
||||
function forceLoadUserFollowList(relays?: string[]): Promise<void>
|
||||
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>
|
||||
const user = User.require(app)
|
||||
const signed = await user.sign(stampedEvent)
|
||||
```
|
||||
|
||||
## 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
|
||||
import { userProfile, userFollowList } from '@welshman/app'
|
||||
type Session<M extends string = string, D = unknown> = {method: M; data: D}
|
||||
```
|
||||
|
||||
// Subscribe to user profile changes
|
||||
userProfile.subscribe(profile => {
|
||||
if (profile) {
|
||||
console.log('User profile:', profile)
|
||||
}
|
||||
### Building sessions
|
||||
|
||||
Build a typed session from a handler with `toSession`:
|
||||
|
||||
```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
|
||||
const follows = userFollowList.get()
|
||||
registerSessionHandler(myHandler)
|
||||
// later: unregisterSessionHandler(myHandler)
|
||||
```
|
||||
|
||||
### Manual Loading
|
||||
### Resolving signers directly
|
||||
|
||||
```typescript
|
||||
import { loadUserMuteList, forceLoadUserRelayList } from '@welshman/app'
|
||||
|
||||
// Load user mutes from specific relays
|
||||
await loadUserMuteList(['wss://relay1.com', 'wss://relay2.com'])
|
||||
|
||||
// Force refresh user relay selections
|
||||
await forceLoadUserRelayList([])
|
||||
|
||||
// Load from default relays
|
||||
await loadUserProfile()
|
||||
getSignerFromSession(session: Session): MaybeAsync<ISigner> | undefined
|
||||
```
|
||||
|
||||
Returns the signer for a session, or `undefined` if no handler is registered for its method. `User.fromSession` is a thin wrapper over this.
|
||||
|
||||
## A complete login flow
|
||||
|
||||
```typescript
|
||||
import {createApp, User, toSession, nip07} from "@welshman/app"
|
||||
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
@@ -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
|
||||
- **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
|
||||
## Aggregate projections
|
||||
|
||||
## API Reference
|
||||
|
||||
### Social Graph Navigation
|
||||
Each returns a [`Projection`](./plugins#projection-t) (`.get()` / `.$`):
|
||||
|
||||
```typescript
|
||||
// Get users followed by a specific pubkey
|
||||
getFollows(pubkey: string): string[]
|
||||
const wot = app.use(Wot)
|
||||
|
||||
// Get users who have muted a specific pubkey
|
||||
getMutes(pubkey: string): string[]
|
||||
|
||||
// Get followers of a specific pubkey
|
||||
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[]
|
||||
wot.graph // Projection<Map<string, number>> — score per pubkey
|
||||
wot.max // Projection<number | undefined> — highest score in the graph
|
||||
wot.followersByPubkey // Projection<Map<string, Set<string>>>
|
||||
wot.mutersByPubkey // Projection<Map<string, Set<string>>>
|
||||
```
|
||||
|
||||
### Trust Analysis
|
||||
## Per-pubkey queries
|
||||
|
||||
```typescript
|
||||
// Get follows of a user who also follow a target
|
||||
getFollowsWhoFollow(pubkey: string, target: string): string[]
|
||||
wot.follows(pubkey) // Projection<string[]> — who pubkey follows
|
||||
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
|
||||
getFollowsWhoMute(pubkey: string, target: string): string[]
|
||||
|
||||
// Calculate trust score between users
|
||||
getWotScore(pubkey: string, target: string): number
|
||||
wot.followsWhoFollow(pubkey, target) // Projection<string[]>
|
||||
wot.followsWhoMute(pubkey, target) // Projection<string[]>
|
||||
wot.wotScore(pubkey, target) // Projection<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
|
||||
// Map of follower lists by pubkey
|
||||
followersByPubkey: Readable<Map<string, Set<string>>>
|
||||
const wot = app.use(Wot)
|
||||
|
||||
// Map of muter lists by pubkey
|
||||
mutersByPubkey: Readable<Map<string, Set<string>>>
|
||||
// Sort a list of pubkeys by trust, descending
|
||||
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)
|
||||
wotGraph: Writable<Map<string, number>>
|
||||
// Reactive trust score between me and someone else
|
||||
const score$ = wot.wotScore(myPubkey, theirPubkey).$
|
||||
|
||||
// The maximum WOT score in the graph
|
||||
maxWot: Readable<number>
|
||||
|
||||
// Derive the WOT score for a specific user
|
||||
deriveUserWotScore(targetPubkey: string): Readable<number>
|
||||
// Discover the extended network for a "follows of follows" feed
|
||||
const network = wot.network(myPubkey).get()
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user