nostr-package-delegation #549
@ -3,7 +3,7 @@ module.exports = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
root: true,
|
||||
ignorePatterns: ["dist/", "src/legacy"],
|
||||
ignorePatterns: ["dist/", "src/legacy", "webpack.config.js"],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { NostrError, parseJson } from "../common"
|
||||
import { SubscriptionId } from "."
|
||||
import { EventId, RawEvent } from "../event"
|
||||
import { EventId, EventProps } from "../event"
|
||||
import WebSocket from "isomorphic-ws"
|
||||
import { Filters } from "../filters"
|
||||
|
||||
@ -127,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth"
|
||||
export interface IncomingEvent {
|
||||
kind: "event"
|
||||
subscriptionId: SubscriptionId
|
||||
event: RawEvent
|
||||
event: EventProps
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription"
|
||||
*/
|
||||
export interface OutgoingEvent {
|
||||
kind: "event"
|
||||
event: RawEvent
|
||||
event: EventProps
|
||||
}
|
||||
|
||||
/**
|
||||
@ -304,7 +304,7 @@ function parseIncomingMessage(data: string): IncomingMessage {
|
||||
throw new NostrError(`unknown incoming message: ${data}`)
|
||||
}
|
||||
|
||||
function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
||||
function parseEventData(json: { [key: string]: unknown }): EventProps {
|
||||
if (
|
||||
typeof json["id"] !== "string" ||
|
||||
typeof json["pubkey"] !== "string" ||
|
||||
@ -319,5 +319,5 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
||||
) {
|
||||
throw new NostrError(`invalid event: ${JSON.stringify(json)}`)
|
||||
}
|
||||
return json as unknown as RawEvent
|
||||
return json as unknown as EventProps
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Base from "events"
|
||||
import { Nostr, SubscriptionId } from "."
|
||||
import { Event, EventId } from "../event"
|
||||
import { EventId, Event } from "../event"
|
||||
|
||||
/**
|
||||
* Overrides providing better types for EventEmitter methods.
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { NostrError } from "../common"
|
||||
import { RawEvent, parseEvent } from "../event"
|
||||
import { EventProps, verifySignature } from "../event"
|
||||
import { Conn } from "./conn"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
import { EventEmitter } from "./emitter"
|
||||
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
|
||||
import { Filters } from "../filters"
|
||||
import { parseEvent } from "../event"
|
||||
import { verifyDelegation } from "../event/delegation"
|
||||
|
||||
/**
|
||||
* A nostr client.
|
||||
@ -82,10 +84,14 @@ export class Nostr extends EventEmitter {
|
||||
// Handle messages on this connection.
|
||||
onMessage: async (msg) => {
|
||||
if (msg.kind === "event") {
|
||||
await Promise.all([
|
||||
verifySignature(msg.event),
|
||||
verifyDelegation(msg.event),
|
||||
])
|
||||
this.emit(
|
||||
"event",
|
||||
{
|
||||
event: await parseEvent(msg.event),
|
||||
event: parseEvent(msg.event),
|
||||
subscriptionId: msg.subscriptionId,
|
||||
},
|
||||
this
|
||||
@ -270,7 +276,8 @@ export class Nostr extends EventEmitter {
|
||||
/**
|
||||
* Publish an event.
|
||||
*/
|
||||
publish(event: RawEvent): void {
|
||||
async publish(event: EventProps): Promise<void> {
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
for (const { conn, write } of this.#conns.values()) {
|
||||
if (!write) {
|
||||
continue
|
||||
|
@ -40,3 +40,15 @@ export class NostrError extends Error {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive readonly type.
|
||||
*/
|
||||
export type DeepReadonly<T> = T extends (
|
||||
this: unknown,
|
||||
...args: unknown[]
|
||||
) => unknown
|
||||
? T
|
||||
: {
|
||||
readonly [P in keyof T]: DeepReadonly<T[P]>
|
||||
}
|
||||
|
53
packages/nostr/src/event/common.ts
Normal file
53
packages/nostr/src/event/common.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { EventProps, EventKind } from "."
|
||||
import { Timestamp } from "../common"
|
||||
import { PublicKey } from "../crypto"
|
||||
import { Delegation, DelegationConditions, getDelegation } from "./delegation"
|
||||
|
||||
/**
|
||||
* The base class for all event classes.
|
||||
*/
|
||||
export class EventCommon implements EventProps {
|
||||
readonly id: string
|
||||
readonly pubkey: PublicKey
|
||||
readonly created_at: Timestamp
|
||||
readonly sig: string
|
||||
readonly kind: EventKind
|
||||
readonly tags: readonly (readonly string[])[]
|
||||
readonly content: string
|
||||
|
||||
constructor(props: EventProps) {
|
||||
// TODO Check the event format, lowercase hex, anything else?
|
||||
// TODO Check that each tag has at least one element (right?)
|
||||
this.id = props.id
|
||||
this.pubkey = props.pubkey
|
||||
this.created_at = props.created_at
|
||||
this.sig = props.sig
|
||||
this.kind = props.kind
|
||||
this.tags = structuredClone(props.tags)
|
||||
this.content = props.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event author. If the event is delegated, returns the delegator. Otherwise, returns the
|
||||
* pubkey field of the event.
|
||||
*/
|
||||
get author(): PublicKey {
|
||||
return this.delegation?.delegator ?? this.pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the delegation from the event. If the event is not delegated, return undefined.
|
||||
*/
|
||||
get delegation():
|
||||
| Delegation<
|
||||
DelegationConditions & {
|
||||
/**
|
||||
* The raw delegation string as it was specified in the event.
|
||||
*/
|
||||
str: string
|
||||
}
|
||||
>
|
||||
| undefined {
|
||||
return getDelegation(this)
|
||||
}
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
import { EventKind, RawEvent, signEvent } from "."
|
||||
import { NostrError } from "../common"
|
||||
import { HexOrBechPrivateKey, 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[],
|
||||
priv?: HexOrBechPrivateKey
|
||||
): Promise<ContactList> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.ContactList,
|
||||
tags: contacts.map((contact) => [
|
||||
"p",
|
||||
contact.pubkey,
|
||||
contact.relay?.toString() ?? "",
|
||||
contact.petname ?? "",
|
||||
]),
|
||||
content: "",
|
||||
getContacts,
|
||||
},
|
||||
priv
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
239
packages/nostr/src/event/delegation.ts
Normal file
239
packages/nostr/src/event/delegation.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { NostrError, DeepReadonly, Timestamp } from "../common"
|
||||
import {
|
||||
getPublicKey,
|
||||
HexOrBechPrivateKey,
|
||||
HexOrBechPublicKey,
|
||||
parsePrivateKey,
|
||||
parsePublicKey,
|
||||
PublicKey,
|
||||
schnorrSign,
|
||||
sha256,
|
||||
} from "../crypto"
|
||||
import * as secp256k1 from "@noble/secp256k1"
|
||||
import { EventKind } from "../legacy"
|
||||
import { EventProps } from "."
|
||||
|
||||
/**
|
||||
* Event delegation.
|
||||
* TODO Write a lot more detail about the delegation process, and link other functions
|
||||
* back to this documentation.
|
||||
*
|
||||
* Related NIPs: NIP-26.
|
||||
*/
|
||||
export interface Delegation<C extends string | DelegationConditions> {
|
||||
delegator: PublicKey
|
||||
conditions: C
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The conditions for a delegation.
|
||||
* TODO Describe the string format
|
||||
*/
|
||||
export interface DelegationConditions {
|
||||
/**
|
||||
* The kinds of events that can be published with this delegation token.
|
||||
*/
|
||||
kinds: EventKind[]
|
||||
/**
|
||||
* The time before which events can be published with this delegation token.
|
||||
*/
|
||||
before?: Timestamp
|
||||
/**
|
||||
* The time after which events can be published with this delegation token.
|
||||
*/
|
||||
after?: Timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a delegation that allows delegatee to publish events in the name of priv.
|
||||
* If no conditions are specified, the delegation will allow the delegatee to publish
|
||||
* any event.
|
||||
*/
|
||||
export async function createDelegation(
|
||||
delegatee: HexOrBechPublicKey,
|
||||
priv: HexOrBechPrivateKey,
|
||||
conditions?: DeepReadonly<Partial<DelegationConditions>>
|
||||
): Promise<Delegation<string>> {
|
||||
delegatee = parsePublicKey(delegatee)
|
||||
priv = parsePrivateKey(priv)
|
||||
const delegationConditions = formatDelegationConditions(conditions ?? {})
|
||||
const token = `nostr:delegation:${delegatee}:${delegationConditions}`
|
||||
return {
|
||||
delegator: getPublicKey(priv),
|
||||
conditions: delegationConditions,
|
||||
token: await schnorrSign(
|
||||
await sha256(new TextEncoder().encode(token)),
|
||||
priv
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the delegation conditions string.
|
||||
*/
|
||||
export function parseDelegationConditions(
|
||||
conditions: string
|
||||
): DelegationConditions & { str: string } {
|
||||
let before: number | undefined
|
||||
let after: number | undefined
|
||||
const kinds: EventKind[] = []
|
||||
if (conditions !== "") {
|
||||
for (let condition of conditions.split("&")) {
|
||||
if (condition.startsWith("kind=")) {
|
||||
condition = condition.replace("kind=", "")
|
||||
const kind = parseInt(condition)
|
||||
if (Number.isNaN(kind)) {
|
||||
throw new NostrError("invalid delegation condition")
|
||||
}
|
||||
kinds.push(kind)
|
||||
} else if (condition.startsWith("created_at<")) {
|
||||
if (before !== undefined) {
|
||||
throw new NostrError(
|
||||
`invalid delegation condition ${condition}: created_at< already specified`
|
||||
)
|
||||
}
|
||||
condition = condition.replace("created_at<", "")
|
||||
const timestamp = parseInt(condition)
|
||||
if (Number.isNaN(timestamp)) {
|
||||
throw new NostrError(
|
||||
`invalid delegation condition ${condition}: invalid timestamp`
|
||||
)
|
||||
}
|
||||
before = timestamp
|
||||
} else if (condition.startsWith("created_at>")) {
|
||||
if (after !== undefined) {
|
||||
throw new NostrError(
|
||||
`invalid delegation condition ${condition}: created_at> already specified`
|
||||
)
|
||||
}
|
||||
condition = condition.replace("created_at>", "")
|
||||
const timestamp = parseInt(condition)
|
||||
if (Number.isNaN(timestamp)) {
|
||||
throw new NostrError(
|
||||
`invalid delegation condition ${condition}: invalid timestamp`
|
||||
)
|
||||
}
|
||||
after = timestamp
|
||||
} else {
|
||||
throw new NostrError(
|
||||
`invalid delegation condition ${condition}: unknown field`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
kinds,
|
||||
before,
|
||||
after,
|
||||
str: conditions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the delegation conditions into a string.
|
||||
*/
|
||||
export function formatDelegationConditions(
|
||||
conditions: DeepReadonly<Partial<DelegationConditions>>
|
||||
): string {
|
||||
const pieces = (conditions.kinds ?? []).map((k) => `kind=${k}`)
|
||||
if (conditions.before !== undefined) {
|
||||
pieces.push(`created_at<${conditions.before}`)
|
||||
}
|
||||
if (conditions.after !== undefined) {
|
||||
pieces.push(`created_at>${conditions.after}`)
|
||||
}
|
||||
return pieces.join("&")
|
||||
}
|
||||
|
||||
// TODO Not exposed to the user
|
||||
/**
|
||||
* Get the delegation from an event.
|
||||
*/
|
||||
export function getDelegation(
|
||||
event: EventProps
|
||||
): Delegation<DelegationConditions & { str: string }> | undefined {
|
||||
// Get the delegation tag.
|
||||
const delegations = event.tags.filter((t) => t[0] === "delegation")
|
||||
if (delegations.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (delegations.length > 1) {
|
||||
throw new NostrError("multiple delegations")
|
||||
}
|
||||
const delegation = delegations[0]
|
||||
|
||||
// TODO Validate the length, field types, hex keys, check the length of the delegation token
|
||||
|
||||
return {
|
||||
delegator: delegation[1],
|
||||
conditions: parseDelegationConditions(delegation[2]),
|
||||
token: delegation[3],
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Not exposed to the user
|
||||
/**
|
||||
* Create an event tag which represents the delegation.
|
||||
*/
|
||||
export function delegationTag(delegation: Delegation<string>): string[] {
|
||||
return [
|
||||
"delegation",
|
||||
delegation.delegator,
|
||||
delegation.conditions,
|
||||
delegation.token,
|
||||
]
|
||||
}
|
||||
|
||||
// TODO Not exposed to the user
|
||||
/**
|
||||
* Verify that the delegation of an event is valid. This includes checking the
|
||||
* signature in the delegation token, and checking that the conditions are met.
|
||||
*/
|
||||
export async function verifyDelegation(event: EventProps): Promise<void> {
|
||||
const delegation = getDelegation(event)
|
||||
if (delegation === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check the Schnorr signature inside the delegation token.
|
||||
if (
|
||||
!(await secp256k1.schnorr.verify(
|
||||
delegation.token,
|
||||
await sha256(
|
||||
new TextEncoder().encode(
|
||||
`nostr:delegation:${event.pubkey}:${delegation.conditions.str}`
|
||||
)
|
||||
),
|
||||
delegation.delegator
|
||||
))
|
||||
) {
|
||||
throw new NostrError("invalid delegation token: invalid schnorr signature")
|
||||
}
|
||||
|
||||
// Check the delegation conditions.
|
||||
if (
|
||||
delegation.conditions.kinds.length > 0 &&
|
||||
!delegation.conditions.kinds.includes(event.kind)
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid delegation: event kind ${event.kind} not allowed`
|
||||
)
|
||||
}
|
||||
if (
|
||||
delegation.conditions.before !== undefined &&
|
||||
event.created_at >= delegation.conditions.before
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid delegation: event.created_at ${event.created_at} is not before ${before}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
delegation.conditions.after !== undefined &&
|
||||
event.created_at <= delegation.conditions.after
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid delegation: event.created_at ${event.created_at} is not after ${after}`
|
||||
)
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { EventId, EventKind, RawEvent, signEvent } from "."
|
||||
import { NostrError } from "../common"
|
||||
import { HexOrBechPrivateKey } from "../crypto"
|
||||
|
||||
/**
|
||||
* A deletion event. Used for marking published events as deleted.
|
||||
*
|
||||
* Related NIPs: NIP-09.
|
||||
*/
|
||||
export interface Deletion extends RawEvent {
|
||||
kind: EventKind.Deletion
|
||||
|
||||
/**
|
||||
* The IDs of events to delete.
|
||||
*/
|
||||
getEvents(): EventId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deletion event.
|
||||
*/
|
||||
export function createDeletion(
|
||||
{ events, content }: { events: EventId[]; content?: string },
|
||||
priv?: HexOrBechPrivateKey
|
||||
): Promise<Deletion> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.Deletion,
|
||||
tags: events.map((id) => ["e", id]),
|
||||
content: content ?? "",
|
||||
getEvents,
|
||||
},
|
||||
priv
|
||||
)
|
||||
}
|
||||
|
||||
export function getEvents(this: Deletion): EventId[] {
|
||||
return this.tags
|
||||
.filter((tag) => tag[0] === "e")
|
||||
.map((tag) => {
|
||||
if (tag[1] === undefined) {
|
||||
throw new NostrError(
|
||||
`invalid deletion event tag: ${JSON.stringify(tag)}`
|
||||
)
|
||||
}
|
||||
return tag[1]
|
||||
})
|
||||
}
|
@ -1,138 +0,0 @@
|
||||
import { EventId, EventKind, RawEvent, signEvent } from "."
|
||||
import { 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) {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.nostr?.nip04?.encrypt === undefined
|
||||
) {
|
||||
throw new NostrError("private key not specified")
|
||||
}
|
||||
const content = await window.nostr.nip04.encrypt(recipient, message)
|
||||
return await signEvent(
|
||||
{
|
||||
kind: EventKind.DirectMessage,
|
||||
tags: [["p", recipient]],
|
||||
content,
|
||||
getMessage,
|
||||
getRecipient,
|
||||
getPrevious,
|
||||
},
|
||||
priv
|
||||
)
|
||||
} 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: 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) {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.nostr?.nip04?.decrypt === undefined
|
||||
) {
|
||||
throw new NostrError("private key not specified")
|
||||
}
|
||||
return await window.nostr.nip04.decrypt(this.pubkey, this.content)
|
||||
} else if (getPublicKey(priv) === this.getRecipient()) {
|
||||
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getRecipient(this: DirectMessage): 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: DirectMessage): 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,38 +1,25 @@
|
||||
import { DeepReadonly, NostrError, Timestamp, unixTimestamp } from "../common"
|
||||
import {
|
||||
PublicKey,
|
||||
sha256,
|
||||
schnorrSign,
|
||||
schnorrVerify,
|
||||
getPublicKey,
|
||||
HexOrBechPrivateKey,
|
||||
parsePrivateKey,
|
||||
PublicKey,
|
||||
schnorrSign,
|
||||
schnorrVerify,
|
||||
sha256,
|
||||
} from "../crypto"
|
||||
import { Timestamp, unixTimestamp, NostrError } from "../common"
|
||||
import { TextNote } from "./text"
|
||||
import {
|
||||
getUserMetadata,
|
||||
SetMetadata,
|
||||
verifyInternetIdentifier,
|
||||
} from "./set-metadata"
|
||||
import {
|
||||
DirectMessage,
|
||||
getMessage,
|
||||
getPrevious,
|
||||
getRecipient,
|
||||
} from "./direct-message"
|
||||
import { ContactList, getContacts } from "./contact-list"
|
||||
import { Deletion, getEvents } from "./deletion"
|
||||
import { ContactList } from "./kind/contact-list"
|
||||
import { Deletion } from "./kind/deletion"
|
||||
import { DirectMessage } from "./kind/direct-message"
|
||||
import { SetMetadata } from "./kind/set-metadata"
|
||||
import { TextNote } from "./kind/text-note"
|
||||
import { Unknown } from "./kind/unknown"
|
||||
import "../nostr-object"
|
||||
|
||||
// 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
|
||||
|
||||
/**
|
||||
* The enumeration of all known event kinds. Used to discriminate between different
|
||||
* event types.
|
||||
*/
|
||||
export enum EventKind {
|
||||
SetMetadata = 0, // NIP-01
|
||||
TextNote = 1, // NIP-01
|
||||
@ -52,31 +39,52 @@ export enum EventKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* A nostr event in the format that's sent across the wire.
|
||||
* For an explanation of the fields, see @see EventProps.
|
||||
*/
|
||||
export interface RawEvent {
|
||||
id: string
|
||||
pubkey: PublicKey
|
||||
created_at: Timestamp
|
||||
export interface InputEventProps {
|
||||
tags?: string[][]
|
||||
content?: string
|
||||
created_at?: Timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields of an unsigned event. These can be used when building a nonstandard
|
||||
* event.
|
||||
*
|
||||
* TODO Document the fields
|
||||
*/
|
||||
export interface UnsignedEventProps {
|
||||
kind: EventKind
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig: string
|
||||
|
||||
[key: string]: unknown
|
||||
created_at?: Timestamp
|
||||
}
|
||||
|
||||
export interface Unknown extends RawEvent {
|
||||
kind: Exclude<
|
||||
EventKind,
|
||||
| EventKind.SetMetadata
|
||||
| EventKind.TextNote
|
||||
| EventKind.ContactList
|
||||
| EventKind.DirectMessage
|
||||
| EventKind.Deletion
|
||||
>
|
||||
/**
|
||||
* The fields of a signed event.
|
||||
*
|
||||
* This type is strictly readonly because it's signed. Changing any of the fields would
|
||||
* invalidate the signature.
|
||||
*
|
||||
* TODO Document the fields
|
||||
*/
|
||||
export interface EventProps extends DeepReadonly<UnsignedEventProps> {
|
||||
readonly id: string
|
||||
readonly pubkey: PublicKey
|
||||
readonly sig: string
|
||||
readonly created_at: Timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* An event. The `kind` field can be used to determine the event type at compile
|
||||
* time. The event signature and delegation token are verified when the event is
|
||||
* received or published. The event format is verified at construction time.
|
||||
*
|
||||
* Events are immutable because they're signed. Changing them after singing would
|
||||
* invalidate the signature.
|
||||
*
|
||||
* TODO Document how to work with unsigned events and why one would want to do that.
|
||||
*/
|
||||
export type Event =
|
||||
| SetMetadata
|
||||
| TextNote
|
||||
@ -91,143 +99,92 @@ export type Event =
|
||||
export type EventId = string
|
||||
|
||||
/**
|
||||
* An unsigned event.
|
||||
* Calculate the id, sig, and pubkey fields of the event. Set created_at to the current timestamp,
|
||||
* if missing. Return a signed clone of the event.
|
||||
*/
|
||||
export type Unsigned<T extends Event | RawEvent> = {
|
||||
[Property in keyof UnsignedWithPubkey<T> as Exclude<
|
||||
Property,
|
||||
"pubkey"
|
||||
>]: T[Property]
|
||||
} & {
|
||||
pubkey?: PublicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as @see {@link Unsigned}, but with the pubkey field.
|
||||
*/
|
||||
type UnsignedWithPubkey<T extends Event | RawEvent> = {
|
||||
[Property in keyof T as Exclude<
|
||||
Property,
|
||||
"id" | "sig" | "created_at"
|
||||
>]: T[Property]
|
||||
} & {
|
||||
id?: EventId
|
||||
sig?: string
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "id," "sig," and "pubkey" fields to the event. Set "created_at" to the current timestamp
|
||||
* if missing. Return the event.
|
||||
*/
|
||||
export async function signEvent<T extends RawEvent>(
|
||||
event: Unsigned<T>,
|
||||
export async function signEvent(
|
||||
event: DeepReadonly<UnsignedEventProps>,
|
||||
priv?: HexOrBechPrivateKey
|
||||
): Promise<T> {
|
||||
event.created_at ??= unixTimestamp()
|
||||
): Promise<EventProps> {
|
||||
const createdAt = event.created_at ?? unixTimestamp()
|
||||
if (priv !== undefined) {
|
||||
priv = parsePrivateKey(priv)
|
||||
event.pubkey = getPublicKey(priv)
|
||||
const id = await serializeEventId(
|
||||
// This conversion is safe because the pubkey field is set above.
|
||||
event as unknown as UnsignedWithPubkey<T>
|
||||
)
|
||||
event.id = id
|
||||
event.sig = await schnorrSign(id, priv)
|
||||
return event as T
|
||||
const pubkey = getPublicKey(priv)
|
||||
const id = await serializeEventId(event, pubkey, createdAt)
|
||||
const sig = await schnorrSign(id, priv)
|
||||
return {
|
||||
...structuredClone(event),
|
||||
id,
|
||||
sig,
|
||||
pubkey,
|
||||
created_at: createdAt,
|
||||
}
|
||||
} else {
|
||||
if (typeof window === "undefined" || window.nostr === undefined) {
|
||||
throw new NostrError("no private key provided")
|
||||
}
|
||||
// Extensions like nos2x expect to receive only the event data, without any of the methods.
|
||||
const methods: { [key: string]: unknown } = {}
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
if (typeof value === "function") {
|
||||
methods[key] = value
|
||||
delete event[key]
|
||||
}
|
||||
throw new NostrError(
|
||||
"no private key provided and window.nostr is not available"
|
||||
)
|
||||
}
|
||||
const signed = await window.nostr.signEvent(event)
|
||||
return {
|
||||
...signed,
|
||||
...methods,
|
||||
}
|
||||
return structuredClone(signed)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Shouldn't be exposed to the user
|
||||
/**
|
||||
* Parse an event from its raw format.
|
||||
* Parse an event from its raw fields.
|
||||
*/
|
||||
export async function parseEvent(event: RawEvent): Promise<Event> {
|
||||
if (event.id !== (await serializeEventId(event))) {
|
||||
export function parseEvent(event: EventProps): Event {
|
||||
if (event.kind === EventKind.TextNote) {
|
||||
return new TextNote(event)
|
||||
}
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
return new SetMetadata(event)
|
||||
}
|
||||
if (event.kind === EventKind.DirectMessage) {
|
||||
return new DirectMessage(event)
|
||||
}
|
||||
if (event.kind === EventKind.ContactList) {
|
||||
return new ContactList(event)
|
||||
}
|
||||
if (event.kind === EventKind.Deletion) {
|
||||
return new Deletion(event)
|
||||
}
|
||||
return new Unknown(event)
|
||||
}
|
||||
|
||||
// TODO Probably shouldn't be exposed to the user
|
||||
/**
|
||||
* Verify the signature of the event. If the signature is invalid, throw @see NostrError.
|
||||
*/
|
||||
export async function verifySignature(event: EventProps): Promise<void> {
|
||||
if (
|
||||
event.id !== (await serializeEventId(event, event.pubkey, event.created_at))
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid id ${event.id} for event ${JSON.stringify(
|
||||
event
|
||||
)}, expected ${await serializeEventId(event)}`
|
||||
)}, expected ${await serializeEventId(
|
||||
event,
|
||||
event.pubkey,
|
||||
event.created_at
|
||||
)}`
|
||||
)
|
||||
}
|
||||
if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) {
|
||||
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) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.TextNote,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.SetMetadata,
|
||||
getUserMetadata,
|
||||
verifyInternetIdentifier,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.DirectMessage) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.DirectMessage,
|
||||
getMessage,
|
||||
getRecipient,
|
||||
getPrevious,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.ContactList) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.ContactList,
|
||||
getContacts,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === EventKind.Deletion) {
|
||||
return {
|
||||
...event,
|
||||
kind: EventKind.Deletion,
|
||||
getEvents,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
kind: event.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function serializeEventId(
|
||||
event: UnsignedWithPubkey<RawEvent>
|
||||
event: DeepReadonly<UnsignedEventProps>,
|
||||
pubkey: PublicKey,
|
||||
createdAt: Timestamp
|
||||
): Promise<EventId> {
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
pubkey,
|
||||
createdAt,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
|
115
packages/nostr/src/event/kind/contact-list.ts
Normal file
115
packages/nostr/src/event/kind/contact-list.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import {
|
||||
EventKind,
|
||||
EventProps,
|
||||
signEvent,
|
||||
InputEventProps,
|
||||
verifySignature,
|
||||
} from ".."
|
||||
import { DeepReadonly, NostrError } from "../../common"
|
||||
import { HexOrBechPrivateKey, PublicKey } from "../../crypto"
|
||||
import { EventCommon } from "../common"
|
||||
import { Delegation, delegationTag, verifyDelegation } from "../delegation"
|
||||
|
||||
/**
|
||||
* A contact from the contact list.
|
||||
*/
|
||||
export interface Contact {
|
||||
pubkey: PublicKey
|
||||
relay?: URL
|
||||
petname?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact list event.
|
||||
*
|
||||
* Related NIPs: NIP-02.
|
||||
*/
|
||||
export class ContactList extends EventCommon {
|
||||
override readonly kind: EventKind.ContactList
|
||||
|
||||
constructor(props: EventProps) {
|
||||
super(props)
|
||||
if (props.kind !== EventKind.ContactList) {
|
||||
throw new NostrError("invalid event kind")
|
||||
}
|
||||
this.kind = props.kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a contact list event.
|
||||
*/
|
||||
static async create(
|
||||
opts: DeepReadonly<{
|
||||
contacts: Contact[]
|
||||
base?: InputEventProps
|
||||
priv?: HexOrBechPrivateKey
|
||||
delegation?: Delegation<string>
|
||||
}>
|
||||
): Promise<ContactList> {
|
||||
const tags = structuredClone((opts.base?.tags as string[][]) ?? [])
|
||||
if (opts.delegation !== undefined) {
|
||||
tags.push(delegationTag(opts.delegation))
|
||||
}
|
||||
tags.push(
|
||||
...opts.contacts.map((contact) => [
|
||||
"p",
|
||||
contact.pubkey,
|
||||
contact.relay?.toString() ?? "",
|
||||
contact.petname ?? "",
|
||||
])
|
||||
)
|
||||
const base = {
|
||||
kind: EventKind.ContactList,
|
||||
tags,
|
||||
content: opts.base?.content ?? "",
|
||||
created_at: opts.base?.created_at,
|
||||
}
|
||||
const event = new ContactList(await signEvent(base, opts.priv))
|
||||
// Verify the delegation for correctness and verify the signature as a sanity check.
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contacts from the contact list.
|
||||
*/
|
||||
get contacts(): 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
74
packages/nostr/src/event/kind/deletion.ts
Normal file
74
packages/nostr/src/event/kind/deletion.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {
|
||||
EventKind,
|
||||
EventProps,
|
||||
EventId,
|
||||
InputEventProps,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
} from ".."
|
||||
import { DeepReadonly, NostrError } from "../../common"
|
||||
import { HexOrBechPrivateKey } from "../../crypto"
|
||||
import { EventCommon } from "../common"
|
||||
import { Delegation, delegationTag, verifyDelegation } from "../delegation"
|
||||
|
||||
/**
|
||||
* A deletion event. Used for marking published events as deleted.
|
||||
*
|
||||
* Related NIPs: NIP-09.
|
||||
*/
|
||||
export class Deletion extends EventCommon {
|
||||
override readonly kind: EventKind.Deletion
|
||||
|
||||
constructor(props: EventProps) {
|
||||
super(props)
|
||||
if (props.kind !== EventKind.Deletion) {
|
||||
throw new NostrError("invalid event kind")
|
||||
}
|
||||
this.kind = props.kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a deletion event.
|
||||
*/
|
||||
static async create(
|
||||
opts: DeepReadonly<{
|
||||
events: EventId[]
|
||||
content?: string
|
||||
base?: InputEventProps
|
||||
priv?: HexOrBechPrivateKey
|
||||
delegation?: Delegation<string>
|
||||
}>
|
||||
): Promise<Deletion> {
|
||||
const tags = structuredClone((opts.base?.tags as string[][]) ?? [])
|
||||
if (opts.delegation !== undefined) {
|
||||
tags.push(delegationTag(opts.delegation))
|
||||
}
|
||||
tags.push(...opts.events.map((id) => ["e", id]))
|
||||
const base = {
|
||||
kind: EventKind.Deletion,
|
||||
tags,
|
||||
content: opts.base?.content ?? "",
|
||||
created_at: opts.base?.created_at,
|
||||
}
|
||||
const event = new Deletion(await signEvent(base, opts.priv))
|
||||
// Verify the delegation for correctness and verify the signature as a sanity check.
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* The IDs of events to delete.
|
||||
*/
|
||||
get deletedEvents(): EventId[] {
|
||||
return this.tags
|
||||
.filter((tag) => tag[0] === "e")
|
||||
.map((tag) => {
|
||||
if (tag[1] === undefined) {
|
||||
throw new NostrError(
|
||||
`invalid deletion event tag: ${JSON.stringify(tag)}`
|
||||
)
|
||||
}
|
||||
return tag[1]
|
||||
})
|
||||
}
|
||||
}
|
170
packages/nostr/src/event/kind/direct-message.ts
Normal file
170
packages/nostr/src/event/kind/direct-message.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import {
|
||||
EventKind,
|
||||
EventProps,
|
||||
EventId,
|
||||
InputEventProps,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
} from ".."
|
||||
import { DeepReadonly, NostrError } from "../../common"
|
||||
import {
|
||||
PublicKey,
|
||||
PrivateKey,
|
||||
parsePublicKey,
|
||||
parsePrivateKey,
|
||||
aesEncryptBase64,
|
||||
HexOrBechPrivateKey,
|
||||
aesDecryptBase64,
|
||||
getPublicKey,
|
||||
} from "../../crypto"
|
||||
import { EventCommon } from "../common"
|
||||
import { Delegation, delegationTag, verifyDelegation } from "../delegation"
|
||||
|
||||
/**
|
||||
* An encrypted direct message event.
|
||||
*
|
||||
* Related NIPs: NIP-04.
|
||||
*/
|
||||
export class DirectMessage extends EventCommon {
|
||||
override readonly kind: EventKind.DirectMessage
|
||||
|
||||
constructor(props: EventProps) {
|
||||
super(props)
|
||||
if (props.kind !== EventKind.DirectMessage) {
|
||||
throw new NostrError("invalid event kind")
|
||||
}
|
||||
this.kind = props.kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an encrypted direct message event.
|
||||
*/
|
||||
static async create(
|
||||
opts: DeepReadonly<{
|
||||
message: string
|
||||
recipient: PublicKey
|
||||
previous?: EventId
|
||||
base?: InputEventProps
|
||||
priv?: PrivateKey
|
||||
delegation?: Delegation<string>
|
||||
}>
|
||||
): Promise<DirectMessage> {
|
||||
const recipient = parsePublicKey(opts.recipient)
|
||||
const tags = structuredClone((opts.base?.tags as string[][]) ?? [])
|
||||
|
||||
// The user may not specify any "p" or "e" tags in the base event, since those are
|
||||
// reserved for the recipient pubkey and previous event ID.
|
||||
if (tags.some((tag) => tag[0] === "p")) {
|
||||
throw new NostrError("cannot specify recipient pubkey in base tags")
|
||||
}
|
||||
if (tags.some((tag) => tag[0] === "e")) {
|
||||
throw new NostrError("cannot specify previous event ID in base tags")
|
||||
}
|
||||
|
||||
// Build the tags.
|
||||
if (opts.delegation !== undefined) {
|
||||
tags.push(delegationTag(opts.delegation))
|
||||
}
|
||||
tags.push(["p", recipient])
|
||||
if (opts.previous !== undefined) {
|
||||
tags.push(["e", opts.previous])
|
||||
}
|
||||
|
||||
let event: EventProps
|
||||
if (opts.priv === undefined) {
|
||||
// Encrypt the message using window.nostr.nip04.
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.nostr?.nip04?.encrypt === undefined
|
||||
) {
|
||||
throw new NostrError("private key not specified")
|
||||
}
|
||||
const content = await window.nostr.nip04.encrypt(recipient, opts.message)
|
||||
event = await signEvent({
|
||||
kind: EventKind.DirectMessage,
|
||||
tags,
|
||||
content,
|
||||
created_at: opts.base?.created_at,
|
||||
})
|
||||
} else {
|
||||
// Encrypt the message using the provided private key.
|
||||
const priv = parsePrivateKey(opts.priv)
|
||||
const { data, iv } = await aesEncryptBase64(priv, recipient, opts.message)
|
||||
event = await signEvent(
|
||||
{
|
||||
kind: EventKind.DirectMessage,
|
||||
tags,
|
||||
content: `${data}?iv=${iv}`,
|
||||
created_at: opts.base?.created_at,
|
||||
},
|
||||
priv
|
||||
)
|
||||
}
|
||||
// Verify the delegation for correctness and verify the signature as a sanity check.
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
return new DirectMessage(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message plaintext, or undefined if you are not the recipient.
|
||||
*/
|
||||
async getMessage(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) {
|
||||
// Decrypt the message using window.nostr.nip04.
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.nostr?.nip04?.decrypt === undefined
|
||||
) {
|
||||
throw new NostrError("private key not specified")
|
||||
}
|
||||
if ((await window.nostr.getPublicKey()) !== this.recipient) {
|
||||
// The message is not intended for this user.
|
||||
return undefined
|
||||
}
|
||||
return await window.nostr.nip04.decrypt(this.pubkey, this.content)
|
||||
} else {
|
||||
if (getPublicKey(priv) !== this.recipient) {
|
||||
// The message is not intended for this user.
|
||||
return undefined
|
||||
}
|
||||
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recipient pubkey.
|
||||
*/
|
||||
get recipient(): PublicKey {
|
||||
const recipientTag = this.tags.find((tag) => tag[0] === "p")
|
||||
if (recipientTag?.[1] === undefined) {
|
||||
throw new NostrError(
|
||||
`expected "p" tag to be present with two elements in event ${JSON.stringify(
|
||||
this
|
||||
)}`
|
||||
)
|
||||
}
|
||||
return recipientTag[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event ID of the previous message.
|
||||
*/
|
||||
get previous(): EventId | undefined {
|
||||
const previousTag = this.tags.find((tag) => tag[0] === "e")
|
||||
if (previousTag?.[1] === undefined) {
|
||||
throw new NostrError(
|
||||
`expected "e" tag to be present with two elements in event ${JSON.stringify(
|
||||
this
|
||||
)}`
|
||||
)
|
||||
}
|
||||
return previousTag[1]
|
||||
}
|
||||
}
|
146
packages/nostr/src/event/kind/set-metadata.ts
Normal file
146
packages/nostr/src/event/kind/set-metadata.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import {
|
||||
EventKind,
|
||||
EventProps,
|
||||
InputEventProps,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
} from ".."
|
||||
import { DeepReadonly, NostrError, parseJson } from "../../common"
|
||||
import { HexOrBechPrivateKey } from "../../crypto"
|
||||
import { EventCommon } from "../common"
|
||||
import { Delegation, delegationTag, verifyDelegation } from "../delegation"
|
||||
|
||||
export interface UserMetadata {
|
||||
name: string
|
||||
about: string
|
||||
picture: string
|
||||
nip05?: string
|
||||
}
|
||||
|
||||
export interface InternetIdentifier {
|
||||
name: string
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export interface VerificationOptions {
|
||||
readonly https?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata event. Used for disseminating use profile information.
|
||||
*
|
||||
* Related NIPs: NIP-01.
|
||||
*/
|
||||
export class SetMetadata extends EventCommon {
|
||||
override readonly kind: EventKind.SetMetadata
|
||||
|
||||
constructor(event: EventProps) {
|
||||
super(event)
|
||||
if (event.kind !== EventKind.SetMetadata) {
|
||||
throw new NostrError("invalid event kind")
|
||||
}
|
||||
this.kind = event.kind
|
||||
}
|
||||
|
||||
static async create(
|
||||
opts: DeepReadonly<{
|
||||
userMetadata: UserMetadata
|
||||
base?: InputEventProps
|
||||
priv?: HexOrBechPrivateKey
|
||||
delegation?: Delegation<string>
|
||||
}>
|
||||
): Promise<SetMetadata> {
|
||||
const tags = structuredClone((opts.base?.tags as string[][]) ?? [])
|
||||
if (opts.delegation !== undefined) {
|
||||
tags.push(delegationTag(opts.delegation))
|
||||
}
|
||||
const event = await signEvent(
|
||||
{
|
||||
kind: EventKind.SetMetadata,
|
||||
tags,
|
||||
content: JSON.stringify(opts.userMetadata),
|
||||
created_at: opts.base?.created_at,
|
||||
},
|
||||
opts.priv
|
||||
)
|
||||
// Verify the delegation for correctness and verify the signature as a sanity check.
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
return new SetMetadata(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user metadata specified in this event.
|
||||
*/
|
||||
get userMetadata(): 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the NIP-05 DNS-based internet identifier associated with the user metadata.
|
||||
* Throws if the internet identifier is invalid or fails verification.
|
||||
*
|
||||
* @param pubkey The public key to use if the event does not specify a pubkey. If the event
|
||||
* does specify a pubkey
|
||||
* @return The internet identifier. `undefined` if there is no internet identifier.
|
||||
*
|
||||
* Related NIPs: NIP-05.
|
||||
*/
|
||||
async verifyInternetIdentifier(
|
||||
this: SetMetadata,
|
||||
opts?: VerificationOptions
|
||||
): Promise<InternetIdentifier | undefined> {
|
||||
const metadata = this.userMetadata
|
||||
if (metadata.nip05 === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const [name, domain] = metadata.nip05.split("@")
|
||||
if (
|
||||
name === undefined ||
|
||||
domain === undefined ||
|
||||
!/^[a-zA-Z0-9-_]+$/.test(name)
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid NIP-05 internet identifier: ${metadata.nip05}`
|
||||
)
|
||||
}
|
||||
const res = await fetch(
|
||||
`${
|
||||
opts?.https === false ? "http" : "https"
|
||||
}://${domain}/.well-known/nostr.json?name=${name}`,
|
||||
{ redirect: "error" }
|
||||
)
|
||||
const wellKnown = await res.json()
|
||||
// TODO How does this interact with delegation? Instead of using the pubkey field,
|
||||
// should it use getAuthor()?
|
||||
const pubkey = wellKnown.names?.[name]
|
||||
if (pubkey !== this.pubkey) {
|
||||
throw new NostrError(
|
||||
`invalid NIP-05 internet identifier: ${
|
||||
metadata.nip05
|
||||
} pubkey does not match, ${JSON.stringify(wellKnown)}`
|
||||
)
|
||||
}
|
||||
const relays = wellKnown.relays?.[pubkey]
|
||||
if (
|
||||
relays !== undefined &&
|
||||
(!(relays instanceof Array) ||
|
||||
relays.some((relay) => typeof relay !== "string"))
|
||||
) {
|
||||
throw new NostrError(`invalid NIP-05 relays: ${wellKnown}`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
relays,
|
||||
}
|
||||
}
|
||||
}
|
55
packages/nostr/src/event/kind/text-note.ts
Normal file
55
packages/nostr/src/event/kind/text-note.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { EventKind, EventProps, signEvent, verifySignature } from ".."
|
||||
import { DeepReadonly, NostrError, Timestamp } from "../../common"
|
||||
import { HexOrBechPrivateKey } from "../../crypto"
|
||||
import { EventCommon } from "../common"
|
||||
import { Delegation, delegationTag, verifyDelegation } from "../delegation"
|
||||
|
||||
/**
|
||||
* A text note event. Used for transmitting user posts.
|
||||
*
|
||||
* Related NIPs: NIP-01.
|
||||
*/
|
||||
export class TextNote extends EventCommon {
|
||||
override readonly kind: EventKind.TextNote
|
||||
|
||||
constructor(event: EventProps) {
|
||||
super(event)
|
||||
if (event.kind !== EventKind.TextNote) {
|
||||
throw new NostrError("invalid event kind")
|
||||
}
|
||||
this.kind = event.kind
|
||||
}
|
||||
|
||||
static async create(
|
||||
opts: DeepReadonly<{
|
||||
note: string
|
||||
base?: {
|
||||
tags?: string[][]
|
||||
created_at?: Timestamp
|
||||
}
|
||||
priv?: HexOrBechPrivateKey
|
||||
delegation?: Delegation<string>
|
||||
}>
|
||||
): Promise<TextNote> {
|
||||
const tags = structuredClone((opts.base?.tags as string[][]) ?? [])
|
||||
if (opts.delegation !== undefined) {
|
||||
tags.push(delegationTag(opts.delegation))
|
||||
}
|
||||
const event = await signEvent(
|
||||
{
|
||||
kind: EventKind.TextNote,
|
||||
tags,
|
||||
content: opts.note,
|
||||
created_at: opts.base?.created_at,
|
||||
},
|
||||
opts.priv
|
||||
)
|
||||
// Verify the delegation for correctness and verify the signature as a sanity check.
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
return new TextNote(event)
|
||||
}
|
||||
|
||||
get note(): string {
|
||||
return this.content
|
||||
}
|
||||
}
|
62
packages/nostr/src/event/kind/unknown.ts
Normal file
62
packages/nostr/src/event/kind/unknown.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import {
|
||||
EventKind,
|
||||
EventProps,
|
||||
InputEventProps,
|
||||
signEvent,
|
||||
verifySignature,
|
||||
} from ".."
|
||||
import { DeepReadonly } from "../../common"
|
||||
import { HexOrBechPrivateKey } from "../../crypto"
|
||||
import { EventCommon } from "../common"
|
||||
import { Delegation, delegationTag, verifyDelegation } from "../delegation"
|
||||
|
||||
export class Unknown extends EventCommon {
|
||||
override readonly kind: Exclude<
|
||||
EventKind,
|
||||
| EventKind.SetMetadata
|
||||
| EventKind.TextNote
|
||||
| EventKind.ContactList
|
||||
| EventKind.DirectMessage
|
||||
| EventKind.Deletion
|
||||
>
|
||||
|
||||
constructor(event: EventProps) {
|
||||
super(event)
|
||||
if (
|
||||
event.kind === EventKind.SetMetadata ||
|
||||
event.kind === EventKind.TextNote ||
|
||||
event.kind === EventKind.ContactList ||
|
||||
event.kind === EventKind.DirectMessage ||
|
||||
event.kind === EventKind.Deletion
|
||||
) {
|
||||
throw new Error("invalid event kind")
|
||||
}
|
||||
this.kind = event.kind
|
||||
}
|
||||
|
||||
static async create(
|
||||
opts: DeepReadonly<{
|
||||
kind: EventKind
|
||||
base?: InputEventProps
|
||||
priv?: HexOrBechPrivateKey
|
||||
delegation?: Delegation<string>
|
||||
}>
|
||||
): Promise<Unknown> {
|
||||
const tags = structuredClone((opts.base?.tags as string[][]) ?? [])
|
||||
if (opts.delegation !== undefined) {
|
||||
tags.push(delegationTag(opts.delegation))
|
||||
}
|
||||
const event = await signEvent(
|
||||
{
|
||||
kind: opts.kind,
|
||||
tags,
|
||||
content: opts.base?.content ?? "",
|
||||
created_at: opts.base?.created_at,
|
||||
},
|
||||
opts.priv
|
||||
)
|
||||
// Verify the delegation for correctness and verify the signature as a sanity check.
|
||||
await Promise.all([verifySignature(event), verifyDelegation(event)])
|
||||
return new Unknown(event)
|
||||
}
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
import { EventKind, RawEvent, signEvent } from "."
|
||||
import { NostrError, parseJson } from "../common"
|
||||
import { HexOrBechPrivateKey } from "../crypto"
|
||||
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Verify the NIP-05 DNS-based internet identifier associated with the user metadata.
|
||||
* Throws if the internet identifier is invalid or fails verification.
|
||||
* @param pubkey The public key to use if the event does not specify a pubkey. If the event
|
||||
* does specify a pubkey
|
||||
* @return The internet identifier. `undefined` if there is no internet identifier.
|
||||
*/
|
||||
verifyInternetIdentifier(
|
||||
opts?: VerificationOptions
|
||||
): Promise<InternetIdentifier | undefined>
|
||||
}
|
||||
|
||||
export interface UserMetadata {
|
||||
name: string
|
||||
about: string
|
||||
picture: string
|
||||
nip05?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a set metadata event.
|
||||
*/
|
||||
export function createSetMetadata(
|
||||
content: UserMetadata,
|
||||
priv?: HexOrBechPrivateKey
|
||||
): Promise<SetMetadata> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.SetMetadata,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
getUserMetadata,
|
||||
verifyInternetIdentifier,
|
||||
},
|
||||
priv
|
||||
)
|
||||
}
|
||||
|
||||
export function getUserMetadata(this: SetMetadata): 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
|
||||
}
|
||||
|
||||
export async function verifyInternetIdentifier(
|
||||
this: SetMetadata,
|
||||
opts?: VerificationOptions
|
||||
): Promise<InternetIdentifier | undefined> {
|
||||
const metadata = this.getUserMetadata()
|
||||
if (metadata.nip05 === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const [name, domain] = metadata.nip05.split("@")
|
||||
if (
|
||||
name === undefined ||
|
||||
domain === undefined ||
|
||||
!/^[a-zA-Z0-9-_]+$/.test(name)
|
||||
) {
|
||||
throw new NostrError(
|
||||
`invalid NIP-05 internet identifier: ${metadata.nip05}`
|
||||
)
|
||||
}
|
||||
const res = await fetch(
|
||||
`${
|
||||
opts?.https === false ? "http" : "https"
|
||||
}://${domain}/.well-known/nostr.json?name=${name}`,
|
||||
{ redirect: "error" }
|
||||
)
|
||||
const wellKnown = await res.json()
|
||||
const pubkey = wellKnown.names?.[name]
|
||||
if (pubkey !== this.pubkey) {
|
||||
throw new NostrError(
|
||||
`invalid NIP-05 internet identifier: ${
|
||||
metadata.nip05
|
||||
} pubkey does not match, ${JSON.stringify(wellKnown)}`
|
||||
)
|
||||
}
|
||||
const relays = wellKnown.relays?.[pubkey]
|
||||
if (
|
||||
relays !== undefined &&
|
||||
(!(relays instanceof Array) ||
|
||||
relays.some((relay) => typeof relay !== "string"))
|
||||
) {
|
||||
throw new NostrError(`invalid NIP-05 relays: ${wellKnown}`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
relays,
|
||||
}
|
||||
}
|
||||
|
||||
export interface InternetIdentifier {
|
||||
name: string
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export interface VerificationOptions {
|
||||
https?: boolean
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { EventKind, RawEvent, signEvent } from "."
|
||||
import { HexOrBechPrivateKey } from "../crypto"
|
||||
|
||||
/**
|
||||
* 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,
|
||||
priv?: HexOrBechPrivateKey
|
||||
): Promise<TextNote> {
|
||||
return signEvent(
|
||||
{
|
||||
kind: EventKind.TextNote,
|
||||
tags: [],
|
||||
content,
|
||||
},
|
||||
priv
|
||||
)
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
import { DeepReadonly } from "./common"
|
||||
import { PublicKey } from "./crypto"
|
||||
import { RawEvent, Unsigned } from "./event"
|
||||
import { EventProps, UnsignedEventProps } from "./event"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<PublicKey>
|
||||
signEvent: <T extends RawEvent>(event: Unsigned<T>) => Promise<T>
|
||||
signEvent: (
|
||||
event: DeepReadonly<UnsignedEventProps>
|
||||
) => Promise<EventProps>
|
||||
|
||||
getRelays?: () => Promise<
|
||||
Record<string, { read: boolean; write: boolean }>
|
||||
|
@ -8,8 +8,7 @@ import {
|
||||
parsePublicKey,
|
||||
PublicKey,
|
||||
} from "../src/crypto"
|
||||
import { RawEvent } from "../src"
|
||||
import { signEvent, Unsigned } from "../src/event"
|
||||
import { signEvent } from "../src/event"
|
||||
|
||||
export const relayUrl = new URL("ws://localhost:12648")
|
||||
|
||||
@ -46,8 +45,7 @@ export async function setup(
|
||||
// Mock the global window.nostr object for the publisher.
|
||||
window.nostr = {
|
||||
getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)),
|
||||
signEvent: <T extends RawEvent>(event: Unsigned<T>) =>
|
||||
signEvent(event, publisherSecret),
|
||||
signEvent: (event) => signEvent(event, publisherSecret),
|
||||
|
||||
getRelays: () => Promise.resolve({}),
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { createContactList } from "../src/event/contact-list"
|
||||
import { ContactList } from "../src/event/kind/contact-list"
|
||||
import { setup } from "./setup"
|
||||
|
||||
describe("contact-list", () => {
|
||||
@ -37,7 +37,7 @@ describe("contact-list", () => {
|
||||
assert.strictEqual(event.kind, EventKind.ContactList)
|
||||
assert.strictEqual(event.content, "")
|
||||
if (event.kind === EventKind.ContactList) {
|
||||
assert.deepStrictEqual(event.getContacts(), contacts)
|
||||
assert.deepStrictEqual(event.contacts, contacts)
|
||||
}
|
||||
done()
|
||||
})
|
||||
@ -47,7 +47,12 @@ describe("contact-list", () => {
|
||||
// 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 createContactList(contacts, subscriberSecret))
|
||||
await publisher.publish(
|
||||
await ContactList.create({
|
||||
contacts,
|
||||
priv: subscriberSecret,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
197
packages/nostr/test/test.delegation.ts
Normal file
197
packages/nostr/test/test.delegation.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { TextNote } from "../src/event/kind/text-note"
|
||||
import { createDelegation, DelegationConditions } from "../src/event/delegation"
|
||||
import { DeepReadonly, NostrError, unixTimestamp } from "../src/common"
|
||||
|
||||
const note = "hello world"
|
||||
|
||||
const timestamp = unixTimestamp()
|
||||
|
||||
const delegatorPubkey =
|
||||
"npub1h5hhn5420y6l3yeufv7m83c57x0n58mkx8aqfyetw2t323zc8cwssuj7ne"
|
||||
const delegatorSecret =
|
||||
"nsec1cym0pjpf3ev2h2gp3r0fkgr2jn5qudm89yajsk2j2n6scga36wnsxkwt47"
|
||||
|
||||
describe("delegation", () => {
|
||||
// Test that a text note can be published in the name of the delegator.
|
||||
it("valid delegation", (done) => valid(done, { kinds: [EventKind.TextNote] }))
|
||||
|
||||
// Test that a text note can be published in the name of the delegator in the case
|
||||
// where no delegation conditions specified.
|
||||
it("valid delegation: empty conditions", valid)
|
||||
|
||||
// Test that an invalid delegation token is rejected.
|
||||
it("invalid delegation: bad token", (done) =>
|
||||
invalid(
|
||||
done,
|
||||
{
|
||||
// Valid conditions...
|
||||
conditions: "kind=1",
|
||||
// ...but a bogus token.
|
||||
token:
|
||||
"e6bdbcf5c35f2d6b624e5aca7f23d521741c4bc541bda1f6e91f498d17743eebe9d7958fb563faf15780c3e2ef6682ae4a5369b0ef2de689f093505ee4e73c9d",
|
||||
},
|
||||
"signature"
|
||||
))
|
||||
|
||||
// Test that an event is rejected if the delegation kind conditions forbid it.
|
||||
it("invalid delegation: bad kind", (done) =>
|
||||
invalid(done, { kinds: [EventKind.SetMetadata] }, "event kind"))
|
||||
|
||||
// Test that an event is rejected if it's too far in the past because created_at conditions forbid it.
|
||||
it("invalid delegation: created_at> now", (done) =>
|
||||
invalid(
|
||||
done,
|
||||
{ kinds: [EventKind.TextNote], after: timestamp },
|
||||
"event.created_at"
|
||||
))
|
||||
|
||||
// Test that an event is rejected if it's too far in the past because created_at conditions forbid it.
|
||||
it("invalid delegation: created_at> now + 10", (done) =>
|
||||
invalid(
|
||||
done,
|
||||
{ kinds: [EventKind.TextNote], after: timestamp + 10 },
|
||||
"event.created_at"
|
||||
))
|
||||
|
||||
// Test that an event is accepted if it happens in the future.
|
||||
it("valid delegation: created_at> now - 10", (done) =>
|
||||
valid(done, { kinds: [EventKind.TextNote], after: timestamp - 10 }))
|
||||
|
||||
// Test that an event is accepted if it happens in the future, without any kind conditions.
|
||||
it("valid delegation: created_at> now - 10 with no kind conditions", (done) =>
|
||||
valid(done, { after: timestamp - 10 }))
|
||||
|
||||
// Test that an event is rejected if it's too far in the future because created_at conditions forbid it.
|
||||
it("invalid delegation: created_at< now", (done) =>
|
||||
invalid(
|
||||
done,
|
||||
{ kinds: [EventKind.TextNote], before: timestamp },
|
||||
"event.created_at"
|
||||
))
|
||||
|
||||
// Test that an event is rejected if it's too far in the future because created_at conditions forbid it.
|
||||
it("invalid delegation: created_at< now - 10", (done) =>
|
||||
invalid(
|
||||
done,
|
||||
{ kinds: [EventKind.TextNote], before: timestamp - 10 },
|
||||
"event.created_at"
|
||||
))
|
||||
|
||||
// Test that an event is accepted if it happens before the delegation deadline.
|
||||
it("valid delegation: created_at< now + 10", (done) =>
|
||||
valid(done, { kinds: [EventKind.TextNote], before: timestamp + 10 }))
|
||||
|
||||
// Test that an event is accepted if it happens within the time frame in the delegation conditions.
|
||||
it("valid delegation: created_at> now - 10 and created_at< now + 10", (done) =>
|
||||
valid(done, { after: timestamp - 10, before: timestamp + 10 }))
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that a text note can be published in the name of the delegator with the
|
||||
* specified conditions.
|
||||
*/
|
||||
function valid(
|
||||
done: jest.DoneCallback,
|
||||
conditions?: DeepReadonly<Partial<DelegationConditions>>
|
||||
) {
|
||||
setup(
|
||||
done,
|
||||
({ publisher, publisherSecret, publisherPubkey, subscriber, done }) => {
|
||||
// Expect the test event.
|
||||
subscriber.on(
|
||||
"event",
|
||||
({ event, subscriptionId: actualSubscriptionId }, nostr) => {
|
||||
// The author is delegated.
|
||||
assert.strictEqual(event.author, parsePublicKey(delegatorPubkey))
|
||||
assert.notStrictEqual(event.pubkey, event.author)
|
||||
|
||||
assert.strictEqual(nostr, subscriber)
|
||||
assert.strictEqual(event.kind, EventKind.TextNote)
|
||||
assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
|
||||
assert.strictEqual(event.created_at, timestamp)
|
||||
assert.strictEqual(event.content, note)
|
||||
assert.strictEqual(actualSubscriptionId, subscriptionId)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
|
||||
const subscriptionId = subscriber.subscribe([])
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async (id, nostr) => {
|
||||
assert.strictEqual(nostr, subscriber)
|
||||
assert.strictEqual(id, subscriptionId)
|
||||
|
||||
await publisher.publish(
|
||||
await TextNote.create({
|
||||
note,
|
||||
priv: publisherSecret,
|
||||
base: { created_at: timestamp },
|
||||
delegation: await createDelegation(
|
||||
publisherPubkey,
|
||||
delegatorSecret,
|
||||
conditions
|
||||
),
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function invalid(
|
||||
done: jest.DoneCallback,
|
||||
opts:
|
||||
| DeepReadonly<Partial<DelegationConditions>>
|
||||
| DeepReadonly<{ conditions: string; token: string }>,
|
||||
errorMsg: string
|
||||
) {
|
||||
setup(done, ({ publisherSecret, publisherPubkey, subscriber, done }) => {
|
||||
// Nothing should be published.
|
||||
subscriber.on("ok", () => {
|
||||
assert.fail("nothing should be published")
|
||||
})
|
||||
|
||||
subscriber.subscribe([])
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
const delegation =
|
||||
"conditions" in opts
|
||||
? { ...opts, delegator: publisherPubkey }
|
||||
: await createDelegation(publisherPubkey, delegatorSecret, opts)
|
||||
// Expect the event creation to fail, since the delegation is invalid.
|
||||
const err = await error(
|
||||
TextNote.create({
|
||||
note,
|
||||
priv: publisherSecret,
|
||||
base: { created_at: timestamp },
|
||||
delegation,
|
||||
})
|
||||
)
|
||||
|
||||
assert.instanceOf(err, NostrError)
|
||||
assert.include(err?.message, "invalid delegation")
|
||||
assert.include(err?.message, errorMsg)
|
||||
|
||||
done()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function error(promise: Promise<unknown>): Promise<Error | undefined> {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error
|
||||
}
|
||||
return error
|
||||
}
|
||||
return undefined
|
||||
}
|
@ -2,8 +2,8 @@ import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createTextNote } from "../src/event/text"
|
||||
import { createDeletion } from "../src/event/deletion"
|
||||
import { TextNote } from "../src/event/kind/text-note"
|
||||
import { Deletion } from "../src/event/kind/deletion"
|
||||
|
||||
describe("deletion", () => {
|
||||
// Test that a deletion event deletes existing events. Test that the deletion event
|
||||
@ -32,30 +32,35 @@ describe("deletion", () => {
|
||||
assert.strictEqual(event.created_at, timestamp)
|
||||
assert.strictEqual(event.content, "")
|
||||
if (event.kind === EventKind.Deletion) {
|
||||
assert.deepStrictEqual(event.getEvents(), [textNoteId])
|
||||
assert.deepStrictEqual(event.deletedEvents, [textNoteId])
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
createTextNote("hello world", publisherSecret).then((textNote) => {
|
||||
textNoteId = textNote.id
|
||||
publisher.publish({
|
||||
...textNote,
|
||||
TextNote.create({
|
||||
note: "hello world",
|
||||
priv: publisherSecret,
|
||||
base: {
|
||||
created_at: timestamp,
|
||||
})
|
||||
},
|
||||
})
|
||||
.then((textNote) => {
|
||||
textNoteId = textNote.id
|
||||
return publisher.publish(textNote)
|
||||
})
|
||||
.catch(done)
|
||||
|
||||
publisher.on("ok", async ({ eventId, ok }) => {
|
||||
assert.strictEqual(ok, true)
|
||||
|
||||
if (eventId === textNoteId) {
|
||||
// After the text note has been published, delete it.
|
||||
const deletion = await createDeletion(
|
||||
{ events: [textNoteId] },
|
||||
publisherSecret
|
||||
)
|
||||
const deletion = await Deletion.create({
|
||||
events: [textNoteId],
|
||||
priv: publisherSecret,
|
||||
})
|
||||
deletionId = deletion.id
|
||||
publisher.publish({
|
||||
await publisher.publish({
|
||||
...deletion,
|
||||
created_at: timestamp,
|
||||
})
|
||||
|
@ -2,7 +2,7 @@ import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createDirectMessage } from "../src/event/direct-message"
|
||||
import { DirectMessage } from "../src/event/kind/direct-message"
|
||||
|
||||
describe("direct-message", () => {
|
||||
const message = "for your eyes only"
|
||||
@ -33,7 +33,7 @@ describe("direct-message", () => {
|
||||
|
||||
if (event.kind === EventKind.DirectMessage) {
|
||||
assert.strictEqual(
|
||||
event.getRecipient(),
|
||||
event.recipient,
|
||||
parsePublicKey(subscriberPubkey)
|
||||
)
|
||||
assert.strictEqual(
|
||||
@ -49,14 +49,12 @@ describe("direct-message", () => {
|
||||
const subscriptionId = subscriber.subscribe([])
|
||||
|
||||
subscriber.on("eose", async () => {
|
||||
const event = await createDirectMessage(
|
||||
{
|
||||
message,
|
||||
recipient: subscriberPubkey,
|
||||
},
|
||||
publisherSecret
|
||||
)
|
||||
publisher.publish(event)
|
||||
const event = await DirectMessage.create({
|
||||
message,
|
||||
recipient: subscriberPubkey,
|
||||
priv: publisherSecret,
|
||||
})
|
||||
await publisher.publish(event)
|
||||
})
|
||||
}
|
||||
)
|
||||
@ -91,7 +89,7 @@ describe("direct-message", () => {
|
||||
|
||||
if (event.kind === EventKind.DirectMessage) {
|
||||
assert.strictEqual(
|
||||
event.getRecipient(),
|
||||
event.recipient,
|
||||
parsePublicKey(recipientPubkey)
|
||||
)
|
||||
assert.strictEqual(
|
||||
@ -111,14 +109,12 @@ describe("direct-message", () => {
|
||||
|
||||
subscriber.on("eose", async () => {
|
||||
// TODO No signEvent, do something more convenient
|
||||
const event = await createDirectMessage(
|
||||
{
|
||||
message,
|
||||
recipient: recipientPubkey,
|
||||
},
|
||||
publisherSecret
|
||||
)
|
||||
publisher.publish(event)
|
||||
const event = await DirectMessage.create({
|
||||
message,
|
||||
recipient: recipientPubkey,
|
||||
priv: publisherSecret,
|
||||
})
|
||||
await publisher.publish(event)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { assert } from "chai"
|
||||
import { defined } from "../src/common"
|
||||
import { EventKind } from "../src/event"
|
||||
import { createSetMetadata } from "../src/event/set-metadata"
|
||||
import { SetMetadata } from "../src/event/kind/set-metadata"
|
||||
import { setup } from "./setup"
|
||||
|
||||
describe("internet-identifier", () => {
|
||||
@ -26,17 +26,17 @@ describe("internet-identifier", () => {
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
publisher.publish({
|
||||
...(await createSetMetadata(
|
||||
{
|
||||
await publisher.publish(
|
||||
await SetMetadata.create({
|
||||
userMetadata: {
|
||||
about: "",
|
||||
name: "",
|
||||
picture: "",
|
||||
nip05: "bob@localhost:12647",
|
||||
},
|
||||
publisherSecret
|
||||
)),
|
||||
})
|
||||
priv: publisherSecret,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -59,16 +59,16 @@ describe("internet-identifier", () => {
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
publisher.publish({
|
||||
...(await createSetMetadata(
|
||||
{
|
||||
await publisher.publish(
|
||||
await SetMetadata.create({
|
||||
userMetadata: {
|
||||
about: "",
|
||||
name: "",
|
||||
picture: "",
|
||||
},
|
||||
publisherSecret
|
||||
)),
|
||||
})
|
||||
priv: publisherSecret,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -2,7 +2,7 @@ import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createSetMetadata } from "../src/event/set-metadata"
|
||||
import { SetMetadata } from "../src/event/kind/set-metadata"
|
||||
|
||||
describe("set metadata", () => {
|
||||
const name = "bob"
|
||||
@ -25,7 +25,7 @@ describe("set metadata", () => {
|
||||
subscriber.on("event", ({ event }) => {
|
||||
assert.strictEqual(event.kind, EventKind.SetMetadata)
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
const user = event.getUserMetadata()
|
||||
const user = event.userMetadata
|
||||
assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
|
||||
assert.strictEqual(event.created_at, timestamp)
|
||||
assert.strictEqual(event.tags.length, 0)
|
||||
@ -40,13 +40,15 @@ describe("set metadata", () => {
|
||||
|
||||
// After the subscription event sync is done, publish the test event.
|
||||
subscriber.on("eose", async () => {
|
||||
publisher.publish({
|
||||
...(await createSetMetadata(
|
||||
{ name, about, picture },
|
||||
publisherSecret
|
||||
)),
|
||||
created_at: timestamp,
|
||||
})
|
||||
await publisher.publish(
|
||||
await SetMetadata.create({
|
||||
userMetadata: { name, about, picture },
|
||||
priv: publisherSecret,
|
||||
base: {
|
||||
created_at: timestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
@ -2,7 +2,7 @@ import { assert } from "chai"
|
||||
import { EventKind } from "../src/event"
|
||||
import { parsePublicKey } from "../src/crypto"
|
||||
import { setup } from "./setup"
|
||||
import { createTextNote } from "../src/event/text"
|
||||
import { TextNote } from "../src/event/kind/text-note"
|
||||
|
||||
describe("text note", () => {
|
||||
const note = "hello world"
|
||||
@ -40,10 +40,15 @@ describe("text note", () => {
|
||||
assert.strictEqual(nostr, subscriber)
|
||||
assert.strictEqual(id, subscriptionId)
|
||||
|
||||
publisher.publish({
|
||||
...(await createTextNote(note, publisherSecret)),
|
||||
created_at: timestamp,
|
||||
})
|
||||
publisher.publish(
|
||||
await TextNote.create({
|
||||
note,
|
||||
priv: publisherSecret,
|
||||
base: {
|
||||
created_at: timestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@ -52,7 +57,7 @@ describe("text note", () => {
|
||||
// Test that a client interprets an "OK" message after publishing a text note.
|
||||
it("publish and ok", function (done) {
|
||||
setup(done, async ({ publisher, publisherSecret, url, done }) => {
|
||||
const event = await createTextNote(note, publisherSecret)
|
||||
const event = await TextNote.create({ note, priv: publisherSecret })
|
||||
publisher.on("ok", (params, nostr) => {
|
||||
assert.strictEqual(nostr, publisher)
|
||||
assert.strictEqual(params.eventId, event.id)
|
||||
|
Loading…
Reference in New Issue
Block a user