Update docs for app session

This commit is contained in:
Jon Staab
2025-04-11 10:18:50 -07:00
parent 7e44fa414c
commit bdef37d404
5 changed files with 231 additions and 90 deletions
+13 -12
View File
@@ -6,13 +6,13 @@ A comprehensive framework for building nostr clients, powering production applic
## What's Included ## What's Included
- **Repository System** - Event storage and query capabilities - **Repository** - Event storage and query capabilities
- **Router** - Intelligent relay selection for optimal networking - **Router** - Intelligent relay selection for optimal network access
- **Feed Controller** - Manages feed creation and updates - **Feed Controller** - Manages feed loading
- **Session Management** - User identity and key management - **Session Management** - User identity and key management
- **Event Actions** - High-level operations like reacting, replying, etc. - **Event Actions** - High-level operations like reacting, replying, etc.
- **Profile Management** - User profile handling and metadata - **Profile Management** - User profile handling and metadata
- **Relay Directories** - Discovery and management of relays - **Relay Directory** - Discovery and management of relays
- **Web of Trust** - Utilities for building webs of trust - **Web of Trust** - Utilities for building webs of trust
## Quick Example ## Quick Example
@@ -61,18 +61,19 @@ const events = await load({
}]) }])
// Or use `request` for more fine-grained subscription control // Or use `request` for more fine-grained subscription control
const req = request({ const abortController = new AbortController()
request({
signal: abortController.signal,
relays: Router.get().ForUser().getUrls(), relays: Router.get().ForUser().getUrls(),
filters: [{kinds: [NOTE], filters: [{kinds: [NOTE],
onEvent: (event: TrustedEvent) => {
console.log(event)
},
}]) }])
// Listen for events // Close the request
req.on(RequestEvent.Event, (event: TrustedEvent) => { abortController.abort()
console.log(event)
})
// Close the req
req.close()
``` ```
## Installation ## Installation
+163 -66
View File
@@ -6,6 +6,7 @@ The session system provides a unified way to handle different authentication met
- NIP-07 via Browser Extension - NIP-07 via Browser Extension
- NIP-46 via Bunker URL or Nostrconnect - NIP-46 via Bunker URL or Nostrconnect
- NIP-55 via Android Signer Application - NIP-55 via Android Signer Application
- Read-only pubkey login
## Overview ## Overview
@@ -16,96 +17,192 @@ Sessions are stored in local storage and can be:
- Switched dynamically - Switched dynamically
- Backed by different signing methods - Backed by different signing methods
## Basic Usage ## 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 ```typescript
import {sessions, pubkey, addSession, dropSession} from '@welshman/app' import {makeSecret} from '@welshman/signer'
import {loginWithNip01} from '@welshman/app'
// Add multiple sessions loginWithNip01(makeSecret())
addSession({method: 'nip07', pubkey: 'abc...'})
addSession({method: 'nip46', pubkey: 'def...', secret: '123'})
// Switch between sessions
pubkey.set('abc...') // Activates that session
// Remove a session
dropSession('abc...')
// List all sessions
console.log(sessions.get())
``` ```
## NIP-46 (Bunker) Authentication ## 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 ```typescript
import {Nip46Broker, Nip46Signer} from '@welshman/signer' import {Nip07Signer} from '@welshman/signer'
import {addSession} from '@welshman/app' import {loginWithNip07} from '@welshman/app'
// Connect to a bunker const signer = new Nip07Signer()
const clientSecret = makeSecret()
const relays = ['wss://relay.damus.io']
const broker = Nip46Broker.get({relays, clientSecret})
// Generate nostrconnect URL for the bunker signer.getPubkey().then(pubkey => {
const connectUrl = await broker.makeNostrconnectUrl({ if (pubkey) {
name: "My App", loginWithNip07(pubkey)
url: "https://myapp.com" } else {
}) // User extension does not exist or did not respond
// Wait for user to approve in bunker
const response = await broker.waitForNostrconnect(connectUrl)
// Create session
addSession({
method: 'nip46',
pubkey: response.event.pubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays
} }
}) })
``` ```
## Using Session Signer ## 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 {Nip46Broker, makeSecret} 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 {Nip46Broker, makeSecret} 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/signer"
// Log in as hodlbod
loginWithPubkey("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
```
## Using the current session
```typescript ```typescript
import {signer, session} from '@welshman/app' import {signer, session} from '@welshman/app'
import {createEvent, NOTE} from '@welshman/util' 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 // Current session's signer is always ready to use
const event = await signer.get().sign( const event = await signer.get().sign(
createEvent(NOTE, {content: "Hello Nostr!"}) createEvent(NOTE, {content: "Hello Nostr!"})
) )
// hodlbod's pubkey
const otherPubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
// Encrypt content for private notes // Encrypt content for private notes
const encrypted = await signer.get().nip44.encrypt( const ciphertext = await signer.get().nip44.encrypt(otherPubkey, "Secret message")
pubkey,
"Secret message" // Decrypt automatically detects encryption version
) const plaintext = await decrypt(signer, otherPubkey, ciphertext)
``` ```
## Session Types ## Multiple sessions
```typescript 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)`.
type SessionNip07 = {
method: "nip07"
pubkey: string
}
type SessionNip46 = {
method: "nip46"
pubkey: string
secret: string
handler: {
pubkey: string
relays: string[]
}
}
type SessionNip01 = {
method: "nip01"
pubkey: string
secret: string
}
```
+3 -4
View File
@@ -5,10 +5,9 @@ outline: deep
# What is Welshman? # What is Welshman?
Welshman is a production-grade nostr toolkit powering [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). Welshman is a production-grade nostr toolkit powering [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social).
Built as independent, opt-in packages, it lets you choose exactly what you need - from basic event handling to complete feed management.
Built as independent, opt-in packages, it lets you choose exactly what you need - from basic utilities handling to a batteries-included application framework.
Need just a content parser? Grab @welshman/content. Building a complex client? Start with @welshman/app and add more packages as you grow. Need just a content parser? Grab @welshman/content. Building a complex client? Start with @welshman/app and add more packages as you grow.
Each module is battle-tested in production, designed to work together but never dependent on each other. Each module is battle-tested in production, and designed to work together.
Build your next Nostr client with the same tools that power today's leading Nostr applications.
+50 -6
View File
@@ -1,21 +1,29 @@
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {cached, hash, omit, equals, assoc} from "@welshman/lib" import {cached, hash, omit, equals, assoc} from "@welshman/lib"
import {withGetter, synced} from "@welshman/store" import {withGetter, synced} from "@welshman/store"
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer" import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer, getPubkey} from "@welshman/signer"
export enum SessionMethod {
Nip01 = 'nip01',
Nip07 = 'nip07',
Nip46 = 'nip46',
Nip55 = 'nip55',
Pubkey = 'pubkey',
}
export type SessionNip01 = { export type SessionNip01 = {
method: "nip01" method: SessionMethod.Nip01
pubkey: string pubkey: string
secret: string secret: string
} }
export type SessionNip07 = { export type SessionNip07 = {
method: "nip07" method: SessionMethod.Nip07
pubkey: string pubkey: string
} }
export type SessionNip46 = { export type SessionNip46 = {
method: "nip46" method: SessionMethod.Nip46
pubkey: string pubkey: string
secret: string secret: string
handler: { handler: {
@@ -25,13 +33,13 @@ export type SessionNip46 = {
} }
export type SessionNip55 = { export type SessionNip55 = {
method: "nip55" method: SessionMethod.Nip55
pubkey: string pubkey: string
signer: string signer: string
} }
export type SessionPubkey = { export type SessionPubkey = {
method: "pubkey" method: SessionMethod.Pubkey
pubkey: string pubkey: string
} }
@@ -71,6 +79,42 @@ export const updateSession = (pubkey: string, f: (session: Session) => Session)
export const dropSession = (pubkey: string) => export const dropSession = (pubkey: string) =>
sessions.update($sessions => omit([pubkey], $sessions)) sessions.update($sessions => omit([pubkey], $sessions))
// Session factories
export const makeNip01Session = (secret: string): SessionNip01 =>
({method: SessionMethod.Nip01, secret, pubkey: getPubkey(secret)})
export const makeNip07Session = (pubkey: string): SessionNip07 =>
({method: SessionMethod.Nip07, pubkey})
export const makeNip46Session = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]): SessionNip46 =>
({method: SessionMethod.Nip46, pubkey, secret: clientSecret, handler: {pubkey: signerPubkey, relays}})
export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 =>
({method: SessionMethod.Nip55, pubkey, signer})
export const makePubkeySession = (pubkey: string): SessionPubkey =>
({method: SessionMethod.Pubkey, pubkey})
// Login utilities
export const loginWithNip01 = (secret: string) =>
addSession(makeNip01Session(secret))
export const loginWithNip07 = (pubkey: string) =>
addSession(makeNip07Session(pubkey))
export const loginWithNip46 = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]) =>
addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
export const loginWithNip55 = (pubkey: string, signer: string) =>
addSession(makeNip55Session(pubkey, signer))
export const loginWithPubkey = (pubkey: string) =>
addSession(makePubkeySession(pubkey))
// Other stuff
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt" export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
export const getSigner = cached({ export const getSigner = cached({
+2 -2
View File
@@ -372,7 +372,7 @@ export class Nip46Broker extends Emitter {
return `nostrconnect://${clientPubkey}?${params.toString()}` return `nostrconnect://${clientPubkey}?${params.toString()}`
} }
waitForNostrconnect = (url: string, abort?: AbortController) => { waitForNostrconnect = (url: string, signal: AbortSignal) => {
const secret = new URL(url).searchParams.get("secret") const secret = new URL(url).searchParams.get("secret")
return makePromise<Nip46ResponseWithResult, Nip46Response | undefined>((resolve, reject) => { return makePromise<Nip46ResponseWithResult, Nip46Response | undefined>((resolve, reject) => {
@@ -401,7 +401,7 @@ export class Nip46Broker extends Emitter {
this.receiver.on(Nip46Event.Receive, onReceive) this.receiver.on(Nip46Event.Receive, onReceive)
this.receiver.start() this.receiver.start()
abort?.signal.addEventListener("abort", () => { signal.addEventListener("abort", () => {
reject(undefined) reject(undefined)
cleanup() cleanup()
}) })