rename client, update docs/skills
tests / tests (push) Failing after 5m4s

This commit is contained in:
2026-06-18 19:31:14 +00:00
parent dfeb7a747b
commit fe5c11b00f
92 changed files with 1811 additions and 5268 deletions
+8 -9
View File
@@ -24,16 +24,15 @@ export default defineConfig({
text: "@welshman/app",
link: "/app/",
items: [
{text: "Session Management", link: "/app/session"},
{text: "Relay Selection", link: "/app/relay-selection"},
{text: "Making Requests", link: "/app/making-requests"},
{text: "Publishing Events", link: "/app/publishing-events"},
{text: "Tag utilities", link: "/app/tags"},
{text: "The App", link: "/app/app"},
{text: "User & Sessions", link: "/app/user"},
{text: "Plugin Architecture", link: "/app/plugins"},
{text: "Data Plugins", link: "/app/data"},
{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: "Storage", link: "/app/storage"},
{text: "Context", link: "/app/context"},
{text: "Commands", link: "/app/commands"},
{text: "User", link: "/app/user"},
{text: "Feeds & Search", link: "/app/feeds-and-search"},
],
},
{
+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(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
```
+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") // 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
View File
@@ -2,82 +2,93 @@
[![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
- **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.
-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
// 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
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
- **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).