nostr-package-delegation #549
@ -1,6 +1,6 @@
|
|||||||
import "./FollowButton.css";
|
import "./FollowButton.css";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey, RawEvent } from "@snort/nostr";
|
||||||
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { parseId } from "Util";
|
import { parseId } from "Util";
|
||||||
|
@ -2,7 +2,7 @@ import { ReactNode } from "react";
|
|||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey, RawEvent } from "@snort/nostr";
|
||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
@ -72,14 +72,14 @@ export class EventPublisher {
|
|||||||
return eb.pubKey(this.#pubKey).kind(k);
|
return eb.pubKey(this.#pubKey).kind(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #sign(eb: EventBuilder) {
|
async #sign(eb: EventBuilder): Promise<RawEvent> {
|
||||||
if (this.#hasNip07 && !this.#privateKey) {
|
if (this.#hasNip07 && !this.#privateKey) {
|
||||||
const nip7PubKey = await barrierNip07(() => unwrap(window.nostr).getPublicKey());
|
const nip7PubKey = await barrierNip07(() => unwrap(window.nostr).getPublicKey());
|
||||||
if (nip7PubKey !== this.#pubKey) {
|
if (nip7PubKey !== this.#pubKey) {
|
||||||
throw new Error("Can't sign event, NIP-07 pubkey does not match");
|
throw new Error("Can't sign event, NIP-07 pubkey does not match");
|
||||||
}
|
}
|
||||||
const ev = eb.build();
|
const ev = eb.build();
|
||||||
return await barrierNip07(() => unwrap(window.nostr).signEvent(ev));
|
return (await barrierNip07(() => unwrap(window.nostr).signEvent(ev))) as RawEvent;
|
||||||
} else if (this.#privateKey) {
|
} else if (this.#privateKey) {
|
||||||
return await eb.buildAndSign(this.#privateKey);
|
return await eb.buildAndSign(this.#privateKey);
|
||||||
} else {
|
} else {
|
||||||
|
@ -3,7 +3,7 @@ module.exports = {
|
|||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
plugins: ["@typescript-eslint"],
|
plugins: ["@typescript-eslint"],
|
||||||
root: true,
|
root: true,
|
||||||
ignorePatterns: ["dist/", "src/legacy"],
|
ignorePatterns: ["dist/", "src/legacy", "webpack.config.js"],
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
node: true,
|
node: true,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { NostrError, parseJson } from "../common"
|
import { NostrError, parseJson } from "../common"
|
||||||
import { SubscriptionId } from "."
|
import { SubscriptionId } from "."
|
||||||
import { EventId, RawEvent } from "../event"
|
import { EventId, EventProps } from "../event"
|
||||||
import WebSocket from "isomorphic-ws"
|
import WebSocket from "isomorphic-ws"
|
||||||
import { Filters } from "../filters"
|
import { Filters } from "../filters"
|
||||||
|
|
||||||
@ -127,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth"
|
|||||||
export interface IncomingEvent {
|
export interface IncomingEvent {
|
||||||
kind: "event"
|
kind: "event"
|
||||||
subscriptionId: SubscriptionId
|
subscriptionId: SubscriptionId
|
||||||
event: RawEvent
|
event: EventProps
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -178,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription"
|
|||||||
*/
|
*/
|
||||||
export interface OutgoingEvent {
|
export interface OutgoingEvent {
|
||||||
kind: "event"
|
kind: "event"
|
||||||
event: RawEvent
|
event: EventProps
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -304,7 +304,7 @@ function parseIncomingMessage(data: string): IncomingMessage {
|
|||||||
throw new NostrError(`unknown incoming message: ${data}`)
|
throw new NostrError(`unknown incoming message: ${data}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
function parseEventData(json: { [key: string]: unknown }): EventProps {
|
||||||
if (
|
if (
|
||||||
typeof json["id"] !== "string" ||
|
typeof json["id"] !== "string" ||
|
||||||
typeof json["pubkey"] !== "string" ||
|
typeof json["pubkey"] !== "string" ||
|
||||||
@ -319,5 +319,5 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
|||||||
) {
|
) {
|
||||||
throw new NostrError(`invalid event: ${JSON.stringify(json)}`)
|
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 Base from "events"
|
||||||
import { Nostr, SubscriptionId } from "."
|
import { Nostr, SubscriptionId } from "."
|
||||||
import { Event, EventId } from "../event"
|
import { EventId, Event } from "../event"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides providing better types for EventEmitter methods.
|
* Overrides providing better types for EventEmitter methods.
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { NostrError } from "../common"
|
import { NostrError } from "../common"
|
||||||
import { RawEvent, parseEvent } from "../event"
|
import { EventProps, verifySignature } from "../event"
|
||||||
import { Conn } from "./conn"
|
import { Conn } from "./conn"
|
||||||
import * as secp from "@noble/secp256k1"
|
import * as secp from "@noble/secp256k1"
|
||||||
import { EventEmitter } from "./emitter"
|
import { EventEmitter } from "./emitter"
|
||||||
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
|
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
|
||||||
import { Filters } from "../filters"
|
import { Filters } from "../filters"
|
||||||
|
import { parseEvent } from "../event"
|
||||||
|
import { verifyDelegation } from "../event/delegation"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A nostr client.
|
* A nostr client.
|
||||||
@ -82,10 +84,14 @@ export class Nostr extends EventEmitter {
|
|||||||
// Handle messages on this connection.
|
// Handle messages on this connection.
|
||||||
onMessage: async (msg) => {
|
onMessage: async (msg) => {
|
||||||
if (msg.kind === "event") {
|
if (msg.kind === "event") {
|
||||||
|
await Promise.all([
|
||||||
|
verifySignature(msg.event),
|
||||||
|
verifyDelegation(msg.event),
|
||||||
|
])
|
||||||
this.emit(
|
this.emit(
|
||||||
"event",
|
"event",
|
||||||
{
|
{
|
||||||
event: await parseEvent(msg.event),
|
event: parseEvent(msg.event),
|
||||||
subscriptionId: msg.subscriptionId,
|
subscriptionId: msg.subscriptionId,
|
||||||
},
|
},
|
||||||
this
|
this
|
||||||
@ -270,7 +276,8 @@ export class Nostr extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Publish an event.
|
* 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()) {
|
for (const { conn, write } of this.#conns.values()) {
|
||||||
if (!write) {
|
if (!write) {
|
||||||
continue
|
continue
|
||||||
|
@ -40,3 +40,15 @@ export class NostrError extends Error {
|
|||||||
super(message)
|
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 {
|
import {
|
||||||
PublicKey,
|
|
||||||
sha256,
|
|
||||||
schnorrSign,
|
|
||||||
schnorrVerify,
|
|
||||||
getPublicKey,
|
getPublicKey,
|
||||||
HexOrBechPrivateKey,
|
HexOrBechPrivateKey,
|
||||||
parsePrivateKey,
|
parsePrivateKey,
|
||||||
|
PublicKey,
|
||||||
|
schnorrSign,
|
||||||
|
schnorrVerify,
|
||||||
|
sha256,
|
||||||
} from "../crypto"
|
} from "../crypto"
|
||||||
import { Timestamp, unixTimestamp, NostrError } from "../common"
|
import { ContactList } from "./kind/contact-list"
|
||||||
import { TextNote } from "./text"
|
import { Deletion } from "./kind/deletion"
|
||||||
import {
|
import { DirectMessage } from "./kind/direct-message"
|
||||||
getUserMetadata,
|
import { SetMetadata } from "./kind/set-metadata"
|
||||||
SetMetadata,
|
import { TextNote } from "./kind/text-note"
|
||||||
verifyInternetIdentifier,
|
import { Unknown } from "./kind/unknown"
|
||||||
} from "./set-metadata"
|
|
||||||
import {
|
|
||||||
DirectMessage,
|
|
||||||
getMessage,
|
|
||||||
getPrevious,
|
|
||||||
getRecipient,
|
|
||||||
} from "./direct-message"
|
|
||||||
import { ContactList, getContacts } from "./contact-list"
|
|
||||||
import { Deletion, getEvents } from "./deletion"
|
|
||||||
import "../nostr-object"
|
import "../nostr-object"
|
||||||
|
|
||||||
// TODO Add remaining event types
|
/**
|
||||||
|
* The enumeration of all known event kinds. Used to discriminate between different
|
||||||
// TODO
|
* event types.
|
||||||
// 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
|
||||||
@ -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 {
|
export interface InputEventProps {
|
||||||
id: string
|
tags?: string[][]
|
||||||
pubkey: PublicKey
|
content?: string
|
||||||
created_at: Timestamp
|
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
|
kind: EventKind
|
||||||
tags: string[][]
|
tags: string[][]
|
||||||
content: string
|
content: string
|
||||||
sig: string
|
created_at?: Timestamp
|
||||||
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Unknown extends RawEvent {
|
/**
|
||||||
kind: Exclude<
|
* The fields of a signed event.
|
||||||
EventKind,
|
*
|
||||||
| EventKind.SetMetadata
|
* This type is strictly readonly because it's signed. Changing any of the fields would
|
||||||
| EventKind.TextNote
|
* invalidate the signature.
|
||||||
| EventKind.ContactList
|
*
|
||||||
| EventKind.DirectMessage
|
* TODO Document the fields
|
||||||
| EventKind.Deletion
|
*/
|
||||||
>
|
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 =
|
export type Event =
|
||||||
| SetMetadata
|
| SetMetadata
|
||||||
| TextNote
|
| TextNote
|
||||||
@ -91,143 +99,92 @@ export type Event =
|
|||||||
export type EventId = string
|
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> = {
|
export async function signEvent(
|
||||||
[Property in keyof UnsignedWithPubkey<T> as Exclude<
|
event: DeepReadonly<UnsignedEventProps>,
|
||||||
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>,
|
|
||||||
priv?: HexOrBechPrivateKey
|
priv?: HexOrBechPrivateKey
|
||||||
): Promise<T> {
|
): Promise<EventProps> {
|
||||||
event.created_at ??= unixTimestamp()
|
const createdAt = event.created_at ?? unixTimestamp()
|
||||||
if (priv !== undefined) {
|
if (priv !== undefined) {
|
||||||
priv = parsePrivateKey(priv)
|
priv = parsePrivateKey(priv)
|
||||||
event.pubkey = getPublicKey(priv)
|
const pubkey = getPublicKey(priv)
|
||||||
const id = await serializeEventId(
|
const id = await serializeEventId(event, pubkey, createdAt)
|
||||||
// This conversion is safe because the pubkey field is set above.
|
const sig = await schnorrSign(id, priv)
|
||||||
event as unknown as UnsignedWithPubkey<T>
|
return {
|
||||||
)
|
...structuredClone(event),
|
||||||
event.id = id
|
id,
|
||||||
event.sig = await schnorrSign(id, priv)
|
sig,
|
||||||
return event as T
|
pubkey,
|
||||||
|
created_at: createdAt,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (typeof window === "undefined" || window.nostr === undefined) {
|
if (typeof window === "undefined" || window.nostr === undefined) {
|
||||||
throw new NostrError("no private key provided")
|
throw new NostrError(
|
||||||
}
|
"no private key provided and window.nostr is not available"
|
||||||
// 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]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const signed = await window.nostr.signEvent(event)
|
const signed = await window.nostr.signEvent(event)
|
||||||
return {
|
return structuredClone(signed)
|
||||||
...signed,
|
|
||||||
...methods,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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> {
|
export function parseEvent(event: EventProps): Event {
|
||||||
if (event.id !== (await serializeEventId(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(
|
throw new NostrError(
|
||||||
`invalid id ${event.id} for event ${JSON.stringify(
|
`invalid id ${event.id} for event ${JSON.stringify(
|
||||||
event
|
event
|
||||||
)}, expected ${await serializeEventId(event)}`
|
)}, expected ${await serializeEventId(
|
||||||
|
event,
|
||||||
|
event.pubkey,
|
||||||
|
event.created_at
|
||||||
|
)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) {
|
if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) {
|
||||||
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) {
|
|
||||||
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(
|
async function serializeEventId(
|
||||||
event: UnsignedWithPubkey<RawEvent>
|
event: DeepReadonly<UnsignedEventProps>,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
createdAt: Timestamp
|
||||||
): Promise<EventId> {
|
): Promise<EventId> {
|
||||||
const serialized = JSON.stringify([
|
const serialized = JSON.stringify([
|
||||||
0,
|
0,
|
||||||
event.pubkey,
|
pubkey,
|
||||||
event.created_at,
|
createdAt,
|
||||||
event.kind,
|
event.kind,
|
||||||
event.tags,
|
event.tags,
|
||||||
event.content,
|
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 { PublicKey } from "./crypto"
|
||||||
import { RawEvent, Unsigned } from "./event"
|
import { EventProps, UnsignedEventProps } from "./event"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
nostr?: {
|
nostr?: {
|
||||||
getPublicKey: () => Promise<PublicKey>
|
getPublicKey: () => Promise<PublicKey>
|
||||||
signEvent: <T extends RawEvent>(event: Unsigned<T>) => Promise<T>
|
signEvent: (
|
||||||
|
event: DeepReadonly<UnsignedEventProps>
|
||||||
|
) => Promise<EventProps>
|
||||||
|
|
||||||
getRelays?: () => Promise<
|
getRelays?: () => Promise<
|
||||||
Record<string, { read: boolean; write: boolean }>
|
Record<string, { read: boolean; write: boolean }>
|
||||||
|
@ -8,8 +8,7 @@ import {
|
|||||||
parsePublicKey,
|
parsePublicKey,
|
||||||
PublicKey,
|
PublicKey,
|
||||||
} from "../src/crypto"
|
} from "../src/crypto"
|
||||||
import { RawEvent } from "../src"
|
import { signEvent } from "../src/event"
|
||||||
import { signEvent, Unsigned } from "../src/event"
|
|
||||||
|
|
||||||
export const relayUrl = new URL("ws://localhost:12648")
|
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.
|
// Mock the global window.nostr object for the publisher.
|
||||||
window.nostr = {
|
window.nostr = {
|
||||||
getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)),
|
getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)),
|
||||||
signEvent: <T extends RawEvent>(event: Unsigned<T>) =>
|
signEvent: (event) => signEvent(event, publisherSecret),
|
||||||
signEvent(event, publisherSecret),
|
|
||||||
|
|
||||||
getRelays: () => Promise.resolve({}),
|
getRelays: () => Promise.resolve({}),
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { assert } from "chai"
|
import { assert } from "chai"
|
||||||
import { EventKind } from "../src/event"
|
import { EventKind } from "../src/event"
|
||||||
import { createContactList } from "../src/event/contact-list"
|
import { ContactList } from "../src/event/kind/contact-list"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
|
|
||||||
describe("contact-list", () => {
|
describe("contact-list", () => {
|
||||||
@ -37,7 +37,7 @@ describe("contact-list", () => {
|
|||||||
assert.strictEqual(event.kind, EventKind.ContactList)
|
assert.strictEqual(event.kind, EventKind.ContactList)
|
||||||
assert.strictEqual(event.content, "")
|
assert.strictEqual(event.content, "")
|
||||||
if (event.kind === EventKind.ContactList) {
|
if (event.kind === EventKind.ContactList) {
|
||||||
assert.deepStrictEqual(event.getContacts(), contacts)
|
assert.deepStrictEqual(event.contacts, contacts)
|
||||||
}
|
}
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
@ -47,7 +47,12 @@ describe("contact-list", () => {
|
|||||||
// 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", async () => {
|
subscriber.on("eose", async () => {
|
||||||
// TODO No signEvent, have a convenient way to do this
|
// 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 { EventKind } from "../src/event"
|
||||||
import { parsePublicKey } from "../src/crypto"
|
import { parsePublicKey } from "../src/crypto"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
import { createTextNote } from "../src/event/text"
|
import { TextNote } from "../src/event/kind/text-note"
|
||||||
import { createDeletion } from "../src/event/deletion"
|
import { Deletion } from "../src/event/kind/deletion"
|
||||||
|
|
||||||
describe("deletion", () => {
|
describe("deletion", () => {
|
||||||
// Test that a deletion event deletes existing events. Test that the deletion event
|
// 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.created_at, timestamp)
|
||||||
assert.strictEqual(event.content, "")
|
assert.strictEqual(event.content, "")
|
||||||
if (event.kind === EventKind.Deletion) {
|
if (event.kind === EventKind.Deletion) {
|
||||||
assert.deepStrictEqual(event.getEvents(), [textNoteId])
|
assert.deepStrictEqual(event.deletedEvents, [textNoteId])
|
||||||
}
|
}
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
createTextNote("hello world", publisherSecret).then((textNote) => {
|
TextNote.create({
|
||||||
textNoteId = textNote.id
|
note: "hello world",
|
||||||
publisher.publish({
|
priv: publisherSecret,
|
||||||
...textNote,
|
base: {
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
})
|
},
|
||||||
})
|
})
|
||||||
|
.then((textNote) => {
|
||||||
|
textNoteId = textNote.id
|
||||||
|
return publisher.publish(textNote)
|
||||||
|
})
|
||||||
|
.catch(done)
|
||||||
|
|
||||||
publisher.on("ok", async ({ eventId, ok }) => {
|
publisher.on("ok", async ({ eventId, ok }) => {
|
||||||
assert.strictEqual(ok, true)
|
assert.strictEqual(ok, true)
|
||||||
|
|
||||||
if (eventId === textNoteId) {
|
if (eventId === textNoteId) {
|
||||||
// After the text note has been published, delete it.
|
// After the text note has been published, delete it.
|
||||||
const deletion = await createDeletion(
|
const deletion = await Deletion.create({
|
||||||
{ events: [textNoteId] },
|
events: [textNoteId],
|
||||||
publisherSecret
|
priv: publisherSecret,
|
||||||
)
|
})
|
||||||
deletionId = deletion.id
|
deletionId = deletion.id
|
||||||
publisher.publish({
|
await publisher.publish({
|
||||||
...deletion,
|
...deletion,
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
})
|
})
|
||||||
|
@ -2,7 +2,7 @@ import { assert } from "chai"
|
|||||||
import { EventKind } from "../src/event"
|
import { EventKind } from "../src/event"
|
||||||
import { parsePublicKey } from "../src/crypto"
|
import { parsePublicKey } from "../src/crypto"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
import { createDirectMessage } from "../src/event/direct-message"
|
import { DirectMessage } from "../src/event/kind/direct-message"
|
||||||
|
|
||||||
describe("direct-message", () => {
|
describe("direct-message", () => {
|
||||||
const message = "for your eyes only"
|
const message = "for your eyes only"
|
||||||
@ -33,7 +33,7 @@ describe("direct-message", () => {
|
|||||||
|
|
||||||
if (event.kind === EventKind.DirectMessage) {
|
if (event.kind === EventKind.DirectMessage) {
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
event.getRecipient(),
|
event.recipient,
|
||||||
parsePublicKey(subscriberPubkey)
|
parsePublicKey(subscriberPubkey)
|
||||||
)
|
)
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@ -49,14 +49,12 @@ describe("direct-message", () => {
|
|||||||
const subscriptionId = subscriber.subscribe([])
|
const subscriptionId = subscriber.subscribe([])
|
||||||
|
|
||||||
subscriber.on("eose", async () => {
|
subscriber.on("eose", async () => {
|
||||||
const event = await createDirectMessage(
|
const event = await DirectMessage.create({
|
||||||
{
|
message,
|
||||||
message,
|
recipient: subscriberPubkey,
|
||||||
recipient: subscriberPubkey,
|
priv: publisherSecret,
|
||||||
},
|
})
|
||||||
publisherSecret
|
await publisher.publish(event)
|
||||||
)
|
|
||||||
publisher.publish(event)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -91,7 +89,7 @@ describe("direct-message", () => {
|
|||||||
|
|
||||||
if (event.kind === EventKind.DirectMessage) {
|
if (event.kind === EventKind.DirectMessage) {
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
event.getRecipient(),
|
event.recipient,
|
||||||
parsePublicKey(recipientPubkey)
|
parsePublicKey(recipientPubkey)
|
||||||
)
|
)
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@ -111,14 +109,12 @@ describe("direct-message", () => {
|
|||||||
|
|
||||||
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 createDirectMessage(
|
const event = await DirectMessage.create({
|
||||||
{
|
message,
|
||||||
message,
|
recipient: recipientPubkey,
|
||||||
recipient: recipientPubkey,
|
priv: publisherSecret,
|
||||||
},
|
})
|
||||||
publisherSecret
|
await publisher.publish(event)
|
||||||
)
|
|
||||||
publisher.publish(event)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { assert } from "chai"
|
import { assert } from "chai"
|
||||||
import { defined } from "../src/common"
|
import { defined } from "../src/common"
|
||||||
import { EventKind } from "../src/event"
|
import { EventKind } from "../src/event"
|
||||||
import { createSetMetadata } from "../src/event/set-metadata"
|
import { SetMetadata } from "../src/event/kind/set-metadata"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
|
|
||||||
describe("internet-identifier", () => {
|
describe("internet-identifier", () => {
|
||||||
@ -26,17 +26,17 @@ describe("internet-identifier", () => {
|
|||||||
|
|
||||||
// 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", async () => {
|
subscriber.on("eose", async () => {
|
||||||
publisher.publish({
|
await publisher.publish(
|
||||||
...(await createSetMetadata(
|
await SetMetadata.create({
|
||||||
{
|
userMetadata: {
|
||||||
about: "",
|
about: "",
|
||||||
name: "",
|
name: "",
|
||||||
picture: "",
|
picture: "",
|
||||||
nip05: "bob@localhost:12647",
|
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.
|
// After the subscription event sync is done, publish the test event.
|
||||||
subscriber.on("eose", async () => {
|
subscriber.on("eose", async () => {
|
||||||
publisher.publish({
|
await publisher.publish(
|
||||||
...(await createSetMetadata(
|
await SetMetadata.create({
|
||||||
{
|
userMetadata: {
|
||||||
about: "",
|
about: "",
|
||||||
name: "",
|
name: "",
|
||||||
picture: "",
|
picture: "",
|
||||||
},
|
},
|
||||||
publisherSecret
|
priv: publisherSecret,
|
||||||
)),
|
})
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -2,7 +2,7 @@ import { assert } from "chai"
|
|||||||
import { EventKind } from "../src/event"
|
import { EventKind } from "../src/event"
|
||||||
import { parsePublicKey } from "../src/crypto"
|
import { parsePublicKey } from "../src/crypto"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
import { createSetMetadata } from "../src/event/set-metadata"
|
import { SetMetadata } from "../src/event/kind/set-metadata"
|
||||||
|
|
||||||
describe("set metadata", () => {
|
describe("set metadata", () => {
|
||||||
const name = "bob"
|
const name = "bob"
|
||||||
@ -25,7 +25,7 @@ describe("set metadata", () => {
|
|||||||
subscriber.on("event", ({ event }) => {
|
subscriber.on("event", ({ event }) => {
|
||||||
assert.strictEqual(event.kind, EventKind.SetMetadata)
|
assert.strictEqual(event.kind, EventKind.SetMetadata)
|
||||||
if (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.pubkey, parsePublicKey(publisherPubkey))
|
||||||
assert.strictEqual(event.created_at, timestamp)
|
assert.strictEqual(event.created_at, timestamp)
|
||||||
assert.strictEqual(event.tags.length, 0)
|
assert.strictEqual(event.tags.length, 0)
|
||||||
@ -40,13 +40,15 @@ describe("set metadata", () => {
|
|||||||
|
|
||||||
// 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", async () => {
|
subscriber.on("eose", async () => {
|
||||||
publisher.publish({
|
await publisher.publish(
|
||||||
...(await createSetMetadata(
|
await SetMetadata.create({
|
||||||
{ name, about, picture },
|
userMetadata: { name, about, picture },
|
||||||
publisherSecret
|
priv: publisherSecret,
|
||||||
)),
|
base: {
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,7 @@ import { assert } from "chai"
|
|||||||
import { EventKind } from "../src/event"
|
import { EventKind } from "../src/event"
|
||||||
import { parsePublicKey } from "../src/crypto"
|
import { parsePublicKey } from "../src/crypto"
|
||||||
import { setup } from "./setup"
|
import { setup } from "./setup"
|
||||||
import { createTextNote } from "../src/event/text"
|
import { TextNote } from "../src/event/kind/text-note"
|
||||||
|
|
||||||
describe("text note", () => {
|
describe("text note", () => {
|
||||||
const note = "hello world"
|
const note = "hello world"
|
||||||
@ -40,10 +40,15 @@ describe("text note", () => {
|
|||||||
assert.strictEqual(nostr, subscriber)
|
assert.strictEqual(nostr, subscriber)
|
||||||
assert.strictEqual(id, subscriptionId)
|
assert.strictEqual(id, subscriptionId)
|
||||||
|
|
||||||
publisher.publish({
|
publisher.publish(
|
||||||
...(await createTextNote(note, publisherSecret)),
|
await TextNote.create({
|
||||||
created_at: timestamp,
|
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.
|
// Test that a client interprets an "OK" message after publishing a text note.
|
||||||
it("publish and ok", function (done) {
|
it("publish and ok", function (done) {
|
||||||
setup(done, async ({ publisher, publisherSecret, url, 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) => {
|
publisher.on("ok", (params, nostr) => {
|
||||||
assert.strictEqual(nostr, publisher)
|
assert.strictEqual(nostr, publisher)
|
||||||
assert.strictEqual(params.eventId, event.id)
|
assert.strictEqual(params.eventId, event.id)
|
||||||
|
Loading…
Reference in New Issue
Block a user