Update docs for app session
This commit is contained in:
+13
-12
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user