implement nip-02
This commit is contained in:
parent
dc5514bb74
commit
59c4b60a6a
@ -9,10 +9,10 @@ A strongly-typed nostr client for Node and the browser.
|
|||||||
The goal of the project is to have all of the following implemented
|
The goal of the project is to have all of the following implemented
|
||||||
and tested against a real-world relay implementation.
|
and tested against a real-world relay implementation.
|
||||||
|
|
||||||
_Progress: 6/34 (18%)._
|
_Progress: 7/34 (20%)._
|
||||||
|
|
||||||
- [X] NIP-01: Basic protocol flow description
|
- [X] NIP-01: Basic protocol flow description
|
||||||
- [ ] NIP-02: Contact List and Petnames
|
- [X] NIP-02: Contact List and Petnames
|
||||||
- [ ] NIP-03: OpenTimestamps Attestations for Events
|
- [ ] NIP-03: OpenTimestamps Attestations for Events
|
||||||
- [X] NIP-04: Encrypted Direct Message
|
- [X] NIP-04: Encrypted Direct Message
|
||||||
- [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
|
- [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { NostrError } from "../common"
|
import { NostrError, parseJson } from "../common"
|
||||||
import { SubscriptionId } from "."
|
import { SubscriptionId } from "."
|
||||||
import { EventId, RawEvent } from "../event"
|
import { EventId, RawEvent } from "../event"
|
||||||
import WebSocket from "isomorphic-ws"
|
import WebSocket from "isomorphic-ws"
|
||||||
@ -8,7 +8,7 @@ import { Filters } from "../filters"
|
|||||||
* The connection to a relay. This is the lowest layer of the nostr protocol.
|
* The connection to a relay. This is the lowest layer of the nostr protocol.
|
||||||
* The only responsibility of this type is to send and receive
|
* The only responsibility of this type is to send and receive
|
||||||
* well-formatted nostr messages on the underlying websocket. All other details of the protocol
|
* well-formatted nostr messages on the underlying websocket. All other details of the protocol
|
||||||
* are handled by `Nostr`.
|
* are handled by `Nostr`. This type does not know anything about event semantics.
|
||||||
*
|
*
|
||||||
* @see Nostr
|
* @see Nostr
|
||||||
*/
|
*/
|
||||||
@ -321,11 +321,3 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
|||||||
}
|
}
|
||||||
return json as unknown as RawEvent
|
return json as unknown as RawEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data)
|
|
||||||
} catch (e) {
|
|
||||||
throw new NostrError(`invalid event json: ${data}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -21,6 +21,17 @@ export function defined<T>(v: T | undefined | null): T {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the JSON and throw a @see {@link NostrError} in case of error.
|
||||||
|
*/
|
||||||
|
export function parseJson(data: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (e) {
|
||||||
|
throw new NostrError(`invalid json: ${e}: ${data}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The error thrown by this library.
|
* The error thrown by this library.
|
||||||
*/
|
*/
|
||||||
|
83
packages/nostr/src/event/contact-list.ts
Normal file
83
packages/nostr/src/event/contact-list.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { EventKind, RawEvent, Unsigned } from "."
|
||||||
|
import { NostrError } from "../common"
|
||||||
|
import { PublicKey } from "../crypto"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact list event.
|
||||||
|
*
|
||||||
|
* Related NIPs: NIP-02.
|
||||||
|
*/
|
||||||
|
export interface ContactList extends RawEvent {
|
||||||
|
kind: EventKind.ContactList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the contacts in from the contact list.
|
||||||
|
*/
|
||||||
|
getContacts(): Contact[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact from the contact list.
|
||||||
|
*/
|
||||||
|
export interface Contact {
|
||||||
|
pubkey: PublicKey
|
||||||
|
relay?: URL
|
||||||
|
petname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a contact list event.
|
||||||
|
*/
|
||||||
|
export function createContactList(contacts: Contact[]): Unsigned<ContactList> {
|
||||||
|
return {
|
||||||
|
kind: EventKind.ContactList,
|
||||||
|
tags: contacts.map((contact) => [
|
||||||
|
"p",
|
||||||
|
contact.pubkey,
|
||||||
|
contact.relay?.toString() ?? "",
|
||||||
|
contact.petname ?? "",
|
||||||
|
]),
|
||||||
|
content: "",
|
||||||
|
getContacts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContacts(this: ContactList): Contact[] {
|
||||||
|
return this.tags
|
||||||
|
.filter((tags) => tags[0] === "p")
|
||||||
|
.map((tags) => {
|
||||||
|
// The first element is the pubkey.
|
||||||
|
const pubkey = tags[1]
|
||||||
|
if (pubkey === undefined) {
|
||||||
|
throw new NostrError(
|
||||||
|
`missing contact pubkey for contact list event: ${JSON.stringify(
|
||||||
|
this
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second element is the optional relay URL.
|
||||||
|
let relay: URL | undefined
|
||||||
|
try {
|
||||||
|
if (tags[2] !== undefined && tags[2] !== "") {
|
||||||
|
relay = new URL(tags[2])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new NostrError(
|
||||||
|
`invalid relay URL for contact list event: ${JSON.stringify(this)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The third element is the optional petname.
|
||||||
|
let petname: string | undefined
|
||||||
|
if (tags[3] !== undefined && tags[3] !== "") {
|
||||||
|
petname = tags[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
relay,
|
||||||
|
petname,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
124
packages/nostr/src/event/direct-message.ts
Normal file
124
packages/nostr/src/event/direct-message.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
EventId,
|
||||||
|
EventKind,
|
||||||
|
RawEvent,
|
||||||
|
signEvent,
|
||||||
|
Unsigned,
|
||||||
|
UnsignedWithPubkey,
|
||||||
|
} from "."
|
||||||
|
import { defined, NostrError } from "../common"
|
||||||
|
import {
|
||||||
|
aesDecryptBase64,
|
||||||
|
aesEncryptBase64,
|
||||||
|
getPublicKey,
|
||||||
|
HexOrBechPrivateKey,
|
||||||
|
parsePrivateKey,
|
||||||
|
parsePublicKey,
|
||||||
|
PrivateKey,
|
||||||
|
PublicKey,
|
||||||
|
} from "../crypto"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An encrypted direct message event.
|
||||||
|
*
|
||||||
|
* Related NIPs: NIP-04.
|
||||||
|
*/
|
||||||
|
export interface DirectMessage extends RawEvent {
|
||||||
|
kind: EventKind.DirectMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the message plaintext, or undefined if you are not the recipient.
|
||||||
|
*/
|
||||||
|
getMessage(priv?: HexOrBechPrivateKey): Promise<string | undefined>
|
||||||
|
/**
|
||||||
|
* Get the recipient pubkey.
|
||||||
|
*/
|
||||||
|
getRecipient(): PublicKey
|
||||||
|
/**
|
||||||
|
* Get the event ID of the previous message.
|
||||||
|
*/
|
||||||
|
getPrevious(): EventId | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO Since you already require the private key, maybe this should return the message already signed?
|
||||||
|
// With NIP-07 the parameter will be optional, then what?
|
||||||
|
/**
|
||||||
|
* Create an encrypted direct message event.
|
||||||
|
*/
|
||||||
|
export async function createDirectMessage(
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
recipient,
|
||||||
|
}: {
|
||||||
|
message: string
|
||||||
|
recipient: PublicKey
|
||||||
|
},
|
||||||
|
priv?: PrivateKey
|
||||||
|
): Promise<DirectMessage> {
|
||||||
|
recipient = parsePublicKey(recipient)
|
||||||
|
if (priv === undefined) {
|
||||||
|
// TODO Use NIP-07
|
||||||
|
throw new NostrError("todo")
|
||||||
|
} else {
|
||||||
|
priv = parsePrivateKey(priv)
|
||||||
|
const { data, iv } = await aesEncryptBase64(priv, recipient, message)
|
||||||
|
return await signEvent(
|
||||||
|
{
|
||||||
|
kind: EventKind.DirectMessage,
|
||||||
|
tags: [["p", recipient]],
|
||||||
|
content: `${data}?iv=${iv}`,
|
||||||
|
getMessage,
|
||||||
|
getRecipient,
|
||||||
|
getPrevious,
|
||||||
|
},
|
||||||
|
priv
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessage(
|
||||||
|
this: UnsignedWithPubkey<DirectMessage>,
|
||||||
|
priv?: HexOrBechPrivateKey
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (priv !== undefined) {
|
||||||
|
priv = parsePrivateKey(priv)
|
||||||
|
}
|
||||||
|
const [data, iv] = this.content.split("?iv=")
|
||||||
|
if (data === undefined || iv === undefined) {
|
||||||
|
throw new NostrError(`invalid direct message content ${this.content}`)
|
||||||
|
}
|
||||||
|
if (priv === undefined) {
|
||||||
|
// TODO Try to use NIP-07
|
||||||
|
throw new NostrError("todo")
|
||||||
|
} else if (getPublicKey(priv) === this.getRecipient()) {
|
||||||
|
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipient(this: Unsigned<RawEvent>): PublicKey {
|
||||||
|
const recipientTag = this.tags.find((tag) => tag[0] === "p")
|
||||||
|
if (typeof recipientTag?.[1] !== "string") {
|
||||||
|
throw new NostrError(
|
||||||
|
`expected "p" tag to be of type string, but got ${
|
||||||
|
recipientTag?.[1]
|
||||||
|
} in ${JSON.stringify(this)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return recipientTag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrevious(this: Unsigned<RawEvent>): EventId | undefined {
|
||||||
|
const previousTag = this.tags.find((tag) => tag[0] === "e")
|
||||||
|
if (previousTag === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (typeof previousTag[1] !== "string") {
|
||||||
|
throw new NostrError(
|
||||||
|
`expected "e" tag to be of type string, but got ${
|
||||||
|
previousTag?.[1]
|
||||||
|
} in ${JSON.stringify(this)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return previousTag[1]
|
||||||
|
}
|
@ -1,20 +1,32 @@
|
|||||||
import {
|
import {
|
||||||
PublicKey,
|
PublicKey,
|
||||||
PrivateKey,
|
|
||||||
sha256,
|
sha256,
|
||||||
schnorrSign,
|
schnorrSign,
|
||||||
schnorrVerify,
|
schnorrVerify,
|
||||||
parsePublicKey,
|
|
||||||
aesDecryptBase64,
|
|
||||||
getPublicKey,
|
getPublicKey,
|
||||||
HexOrBechPrivateKey,
|
HexOrBechPrivateKey,
|
||||||
parsePrivateKey,
|
parsePrivateKey,
|
||||||
aesEncryptBase64,
|
} from "../crypto"
|
||||||
} from "./crypto"
|
import { Timestamp, unixTimestamp, NostrError } from "../common"
|
||||||
import { defined, Timestamp, unixTimestamp, NostrError } from "./common"
|
import { TextNote } from "./text"
|
||||||
|
import { getUserMetadata, SetMetadata } from "./set-metadata"
|
||||||
|
import {
|
||||||
|
DirectMessage,
|
||||||
|
getMessage,
|
||||||
|
getPrevious,
|
||||||
|
getRecipient,
|
||||||
|
} from "./direct-message"
|
||||||
|
import { ContactList, getContacts } from "./contact-list"
|
||||||
|
|
||||||
// TODO Add remaining event types
|
// TODO Add remaining event types
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
// Think about this more
|
||||||
|
// Perhaps the best option is for all these factory methods to have an overload which also accept a private
|
||||||
|
// key as last parameter and return the event already signed
|
||||||
|
// Or maybe opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07, setting
|
||||||
|
// it to a string will use that string as the private key
|
||||||
|
|
||||||
export enum EventKind {
|
export enum EventKind {
|
||||||
SetMetadata = 0, // NIP-01
|
SetMetadata = 0, // NIP-01
|
||||||
TextNote = 1, // NIP-01
|
TextNote = 1, // NIP-01
|
||||||
@ -48,50 +60,22 @@ export interface RawEvent {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetMetadata extends RawEvent {
|
|
||||||
kind: EventKind.SetMetadata
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user metadata specified in this event.
|
|
||||||
*/
|
|
||||||
getUserMetadata(): UserMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextNote extends RawEvent {
|
|
||||||
kind: EventKind.TextNote
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DirectMessage extends RawEvent {
|
|
||||||
kind: EventKind.DirectMessage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the message plaintext, or undefined if this client is not the recipient.
|
|
||||||
*/
|
|
||||||
getMessage(priv?: HexOrBechPrivateKey): Promise<string | undefined>
|
|
||||||
/**
|
|
||||||
* Get the recipient pubkey.
|
|
||||||
*/
|
|
||||||
getRecipient(): PublicKey
|
|
||||||
/**
|
|
||||||
* Get the event ID of the previous message.
|
|
||||||
*/
|
|
||||||
getPrevious(): EventId | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Unknown extends RawEvent {
|
export interface Unknown extends RawEvent {
|
||||||
kind: Exclude<
|
kind: Exclude<
|
||||||
EventKind,
|
EventKind,
|
||||||
EventKind.SetMetadata | EventKind.TextNote | EventKind.DirectMessage
|
| EventKind.SetMetadata
|
||||||
|
| EventKind.TextNote
|
||||||
|
| EventKind.DirectMessage
|
||||||
|
| EventKind.ContactList
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = SetMetadata | TextNote | DirectMessage | Unknown
|
export type Event =
|
||||||
|
| SetMetadata
|
||||||
export interface UserMetadata {
|
| TextNote
|
||||||
name: string
|
| ContactList
|
||||||
about: string
|
| DirectMessage
|
||||||
picture: string
|
| Unknown
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event ID encoded as hex.
|
* Event ID encoded as hex.
|
||||||
@ -110,10 +94,11 @@ export type Unsigned<T extends Event | RawEvent> = {
|
|||||||
pubkey?: PublicKey
|
pubkey?: PublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO This doesn't need to be exposed by the lib
|
||||||
/**
|
/**
|
||||||
* Same as @see {@link Unsigned}, but with the pubkey field.
|
* Same as @see {@link Unsigned}, but with the pubkey field.
|
||||||
*/
|
*/
|
||||||
type UnsignedWithPubkey<T extends Event | RawEvent> = {
|
export type UnsignedWithPubkey<T extends Event | RawEvent> = {
|
||||||
[Property in keyof T as Exclude<
|
[Property in keyof T as Exclude<
|
||||||
Property,
|
Property,
|
||||||
"id" | "sig" | "created_at"
|
"id" | "sig" | "created_at"
|
||||||
@ -149,59 +134,10 @@ export async function signEvent<T extends Event | RawEvent>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTextNote(content: string): Unsigned<TextNote> {
|
|
||||||
return {
|
|
||||||
kind: EventKind.TextNote,
|
|
||||||
tags: [],
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSetMetadata(
|
|
||||||
content: UserMetadata
|
|
||||||
): Unsigned<SetMetadata> {
|
|
||||||
return {
|
|
||||||
kind: EventKind.SetMetadata,
|
|
||||||
tags: [],
|
|
||||||
content: JSON.stringify(content),
|
|
||||||
getUserMetadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO This is incomplete
|
|
||||||
// TODO Since you already have the private key, maybe this should return the message already signed?
|
|
||||||
// Think about this more
|
|
||||||
// Perhaps the best option is for all these factory methods to have an overload which also accept a private
|
|
||||||
// key as last parameter and return the event already signed, whereas for this method that would be
|
|
||||||
// mandatory
|
|
||||||
// E.g. opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07
|
|
||||||
export async function createDirectMessage({
|
|
||||||
message,
|
|
||||||
recipient,
|
|
||||||
priv,
|
|
||||||
}: {
|
|
||||||
message: string
|
|
||||||
recipient: PublicKey
|
|
||||||
priv: PrivateKey
|
|
||||||
}): Promise<Unsigned<DirectMessage>> {
|
|
||||||
recipient = parsePublicKey(recipient)
|
|
||||||
priv = parsePrivateKey(priv)
|
|
||||||
const { data, iv } = await aesEncryptBase64(priv, recipient, message)
|
|
||||||
return {
|
|
||||||
kind: EventKind.DirectMessage,
|
|
||||||
tags: [["p", recipient]],
|
|
||||||
content: `${data}?iv=${iv}`,
|
|
||||||
getMessage,
|
|
||||||
getRecipient,
|
|
||||||
getPrevious,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an event from its raw format.
|
* Parse an event from its raw format.
|
||||||
*/
|
*/
|
||||||
export async function parseEvent(event: RawEvent): Promise<Event> {
|
export async function parseEvent(event: RawEvent): Promise<Event> {
|
||||||
// TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct.
|
|
||||||
if (event.id !== (await serializeEventId(event))) {
|
if (event.id !== (await serializeEventId(event))) {
|
||||||
throw new NostrError(
|
throw new NostrError(
|
||||||
`invalid id ${event.id} for event ${JSON.stringify(
|
`invalid id ${event.id} for event ${JSON.stringify(
|
||||||
@ -213,6 +149,9 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
|
|||||||
throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`)
|
throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct.
|
||||||
|
// TODO Also validate that tags have at least one element
|
||||||
|
|
||||||
if (event.kind === EventKind.TextNote) {
|
if (event.kind === EventKind.TextNote) {
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
@ -238,6 +177,14 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.kind === EventKind.ContactList) {
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
kind: EventKind.ContactList,
|
||||||
|
getContacts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
kind: event.kind,
|
kind: event.kind,
|
||||||
@ -258,72 +205,6 @@ async function serializeEventId(
|
|||||||
return await sha256(Uint8Array.from(charCodes(serialized)))
|
return await sha256(Uint8Array.from(charCodes(serialized)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserMetadata(this: Unsigned<RawEvent>): UserMetadata {
|
|
||||||
const userMetadata = parseJson(this.content)
|
|
||||||
if (
|
|
||||||
typeof userMetadata.name !== "string" ||
|
|
||||||
typeof userMetadata.about !== "string" ||
|
|
||||||
typeof userMetadata.picture !== "string"
|
|
||||||
) {
|
|
||||||
throw new NostrError(
|
|
||||||
`invalid user metadata ${userMetadata} in ${JSON.stringify(this)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return userMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMessage(
|
|
||||||
this: UnsignedWithPubkey<DirectMessage>,
|
|
||||||
priv?: HexOrBechPrivateKey
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
if (priv !== undefined) {
|
|
||||||
priv = parsePrivateKey(priv)
|
|
||||||
}
|
|
||||||
const [data, iv] = this.content.split("?iv=")
|
|
||||||
if (data === undefined || iv === undefined) {
|
|
||||||
throw new NostrError(`invalid direct message content ${this.content}`)
|
|
||||||
}
|
|
||||||
if (priv === undefined) {
|
|
||||||
// TODO Try to use NIP-07
|
|
||||||
throw new NostrError("todo")
|
|
||||||
} else if (getPublicKey(priv) === this.getRecipient()) {
|
|
||||||
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecipient(this: Unsigned<RawEvent>): PublicKey {
|
|
||||||
const recipientTag = this.tags.find((tag) => tag[0] === "p")
|
|
||||||
if (typeof recipientTag?.[1] !== "string") {
|
|
||||||
throw new NostrError(
|
|
||||||
`expected "p" tag to be of type string, but got ${
|
|
||||||
recipientTag?.[1]
|
|
||||||
} in ${JSON.stringify(this)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return recipientTag[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrevious(this: Unsigned<RawEvent>): EventId | undefined {
|
|
||||||
const previousTag = this.tags.find((tag) => tag[0] === "e")
|
|
||||||
if (typeof previousTag?.[1] !== "string") {
|
|
||||||
throw new NostrError(
|
|
||||||
`expected "e" tag to be of type string, but got ${
|
|
||||||
previousTag?.[1]
|
|
||||||
} in ${JSON.stringify(this)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return defined(previousTag?.[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJson(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data)
|
|
||||||
} catch (e) {
|
|
||||||
throw new NostrError(`invalid json: ${e}: ${data}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function* charCodes(data: string): Iterable<number> {
|
function* charCodes(data: string): Iterable<number> {
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
yield data.charCodeAt(i)
|
yield data.charCodeAt(i)
|
50
packages/nostr/src/event/set-metadata.ts
Normal file
50
packages/nostr/src/event/set-metadata.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { EventKind, RawEvent, Unsigned } from "."
|
||||||
|
import { NostrError, parseJson } from "../common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set metadata event. Used for disseminating use profile information.
|
||||||
|
*
|
||||||
|
* Related NIPs: NIP-01.
|
||||||
|
*/
|
||||||
|
export interface SetMetadata extends RawEvent {
|
||||||
|
kind: EventKind.SetMetadata
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user metadata specified in this event.
|
||||||
|
*/
|
||||||
|
getUserMetadata(): UserMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMetadata {
|
||||||
|
name: string
|
||||||
|
about: string
|
||||||
|
picture: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a set metadata event.
|
||||||
|
*/
|
||||||
|
export function createSetMetadata(
|
||||||
|
content: UserMetadata
|
||||||
|
): Unsigned<SetMetadata> {
|
||||||
|
return {
|
||||||
|
kind: EventKind.SetMetadata,
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
getUserMetadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserMetadata(this: Unsigned<RawEvent>): UserMetadata {
|
||||||
|
const userMetadata = parseJson(this.content)
|
||||||
|
if (
|
||||||
|
typeof userMetadata.name !== "string" ||
|
||||||
|
typeof userMetadata.about !== "string" ||
|
||||||
|
typeof userMetadata.picture !== "string"
|
||||||
|
) {
|
||||||
|
throw new NostrError(
|
||||||
|
`invalid user metadata ${userMetadata} in ${JSON.stringify(this)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return userMetadata
|
||||||
|
}
|
18
packages/nostr/src/event/text.ts
Normal file
18
packages/nostr/src/event/text.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { EventKind, RawEvent, Unsigned } from "."
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text note event. Used for transmitting user posts.
|
||||||
|
*
|
||||||
|
* Related NIPs: NIP-01.
|
||||||
|
*/
|
||||||
|
export interface TextNote extends RawEvent {
|
||||||
|
kind: EventKind.TextNote
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextNote(content: string): Unsigned<TextNote> {
|
||||||
|
return {
|
||||||
|
kind: EventKind.TextNote,
|
||||||
|
tags: [],
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
56
packages/nostr/test/contact-list.ts
Normal file
56
packages/nostr/test/contact-list.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import assert from "assert"
|
||||||
|
import { EventKind, signEvent } from "../src/event"
|
||||||
|
import { createContactList } from "../src/event/contact-list"
|
||||||
|
import { setup } from "./setup"
|
||||||
|
|
||||||
|
describe("contact-list", () => {
|
||||||
|
it("publish and receive the contact list", (done) => {
|
||||||
|
setup(done, ({ publisher, subscriber, subscriberSecret, done }) => {
|
||||||
|
const contacts = [
|
||||||
|
{
|
||||||
|
pubkey:
|
||||||
|
"db9df52f7fcaf30b2718ad17e4c5521058bb20b95073b5c4ff53221b36447c4f",
|
||||||
|
relay: undefined,
|
||||||
|
petname: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey:
|
||||||
|
"94d5ce4cb06f67cab69a2f6e28e0a795222a74ac6a1dd6223743913cc99eaf37",
|
||||||
|
relay: new URL("ws://example.com"),
|
||||||
|
petname: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey:
|
||||||
|
"e6e9a25dbf3e931c991f43c97378e294c25f59e88adc91eda11ed17249a00c20",
|
||||||
|
relay: undefined,
|
||||||
|
petname: "john",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pubkey:
|
||||||
|
"13d629a3a879f2157199491408711ff5e1450002a9f9d8b0ad750f1c6b96661d",
|
||||||
|
relay: new URL("ws://example2.com"),
|
||||||
|
petname: "jack",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
subscriber.on("event", ({ event }) => {
|
||||||
|
assert.strictEqual(event.kind, EventKind.ContactList)
|
||||||
|
assert.strictEqual(event.content, "")
|
||||||
|
if (event.kind === EventKind.ContactList) {
|
||||||
|
assert.deepStrictEqual(event.getContacts(), contacts)
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
subscriber.subscribe([])
|
||||||
|
|
||||||
|
// After the subscription event sync is done, publish the test event.
|
||||||
|
subscriber.on("eose", async () => {
|
||||||
|
// TODO No signEvent, have a convenient way to do this
|
||||||
|
publisher.publish(
|
||||||
|
await signEvent(createContactList(contacts), subscriberSecret)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -1,7 +1,8 @@
|
|||||||
import { createDirectMessage, EventKind, signEvent } from "../src/event"
|
import { EventKind, signEvent } from "../src/event"
|
||||||
import { parsePublicKey } from "../src/crypto"
|
import { parsePublicKey } from "../src/crypto"
|
||||||
import assert from "assert"
|
import assert from "assert"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
|
import { createDirectMessage } from "../src/event/direct-message"
|
||||||
|
|
||||||
describe("dm", () => {
|
describe("dm", () => {
|
||||||
const message = "for your eyes only"
|
const message = "for your eyes only"
|
||||||
@ -112,12 +113,11 @@ describe("dm", () => {
|
|||||||
|
|
||||||
subscriber.on("eose", async () => {
|
subscriber.on("eose", async () => {
|
||||||
// TODO No signEvent, do something more convenient
|
// TODO No signEvent, do something more convenient
|
||||||
const event = await signEvent(
|
const event = await createDirectMessage(
|
||||||
await createDirectMessage({
|
{
|
||||||
message,
|
message,
|
||||||
recipient: recipientPubkey,
|
recipient: recipientPubkey,
|
||||||
priv: publisherSecret,
|
},
|
||||||
}),
|
|
||||||
publisherSecret
|
publisherSecret
|
||||||
)
|
)
|
||||||
publisher.publish(event)
|
publisher.publish(event)
|
||||||
|
@ -1,13 +1,8 @@
|
|||||||
import {
|
import { EventKind, signEvent, Unsigned } from "../src/event"
|
||||||
createTextNote,
|
|
||||||
EventKind,
|
|
||||||
signEvent,
|
|
||||||
TextNote,
|
|
||||||
Unsigned,
|
|
||||||
} from "../src/event"
|
|
||||||
import { parsePublicKey } from "../src/crypto"
|
import { parsePublicKey } from "../src/crypto"
|
||||||
import assert from "assert"
|
import assert from "assert"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
|
import { createTextNote, TextNote } from "../src/event/text"
|
||||||
|
|
||||||
describe("text note", () => {
|
describe("text note", () => {
|
||||||
const note = "hello world"
|
const note = "hello world"
|
||||||
@ -41,18 +36,20 @@ describe("text note", () => {
|
|||||||
const subscriptionId = subscriber.subscribe([])
|
const subscriptionId = subscriber.subscribe([])
|
||||||
|
|
||||||
// After the subscription event sync is done, publish the test event.
|
// After the subscription event sync is done, publish the test event.
|
||||||
subscriber.on("eose", (id, nostr) => {
|
subscriber.on("eose", async (id, nostr) => {
|
||||||
assert.strictEqual(nostr, subscriber)
|
assert.strictEqual(nostr, subscriber)
|
||||||
assert.strictEqual(id, subscriptionId)
|
assert.strictEqual(id, subscriptionId)
|
||||||
|
|
||||||
// TODO No signEvent, have a convenient way to do this
|
// TODO No signEvent, have a convenient way to do this
|
||||||
signEvent(
|
publisher.publish(
|
||||||
{
|
await signEvent(
|
||||||
...createTextNote(note),
|
{
|
||||||
created_at: timestamp,
|
...createTextNote(note),
|
||||||
} as Unsigned<TextNote>,
|
created_at: timestamp,
|
||||||
publisherSecret
|
} as Unsigned<TextNote>,
|
||||||
).then((event) => publisher.publish(event))
|
publisherSecret
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user