diff --git a/docs/lib/tools.md b/docs/lib/tools.md index b2d4fea..2282004 100644 --- a/docs/lib/tools.md +++ b/docs/lib/tools.md @@ -535,3 +535,13 @@ export declare const hexToBytes: (hex: string) => Uint8Array; export declare const sha256: (data: ArrayBuffer | Uint8Array) => Promise; ``` +## Text encoding + +```typescript +// TextEncoder instance for encoding strings to bytes +export declare const textEncoder: TextEncoder; + +// TextDecoder instance for decoding bytes to strings +export declare const textDecoder: TextDecoder; +``` + diff --git a/docs/util/blossom.md b/docs/util/blossom.md new file mode 100644 index 0000000..66ce9d1 --- /dev/null +++ b/docs/util/blossom.md @@ -0,0 +1,94 @@ +# Blossom + +Client library for interacting with Blossom media servers. Provides utilities for authentication, blob operations, and file encryption. + +## Types + +```typescript +export type BlossomAuthAction = "get" | "upload" | "list" | "delete" + +export type BlossomAuthEventOpts = { + action: BlossomAuthAction + server: string + hashes?: string[] + expiration?: number + content?: string +} + +export type BlossomServer = { + url: string + pubkey?: string +} + +export type BlossomErrorResponse = { + message: string + reason?: string +} + +export interface EncryptedFile { + key: string + nonce: string + ciphertext: Uint8Array + algorithm: string +} +``` + +## Authentication + +```typescript +// Creates a Blossom auth event for server operations +export declare const makeBlossomAuthEvent: (opts: BlossomAuthEventOpts) => Event +``` + +## Blob Operations + +```typescript +// Builds URL for accessing a blob +export declare const buildBlobUrl: (server: string, sha256: string, extension?: string) => string + +// Checks if a blob exists on server +export declare const checkBlobExists: (server: string, sha256: string, options?: { authEvent?: SignedEvent }) => Promise<{exists: boolean; size?: number}> + +// Downloads blob from server +export declare const getBlob: (server: string, sha256: string, options?: { authEvent?: SignedEvent; range?: {start: number; end?: number} }) => Promise + +// Uploads blob to server +export declare const uploadBlob: (server: string, blob: Blob | ArrayBuffer, options?: { authEvent?: SignedEvent }) => Promise + +// Deletes blob from server +export declare const deleteBlob: (server: string, sha256: string, options?: { authEvent?: SignedEvent }) => Promise + +// Lists blobs for a pubkey +export declare const listBlobs: (server: string, pubkey: string, options?: { authEvent?: SignedEvent; since?: number; until?: number }) => Promise +``` + +## File Encryption + +```typescript +// Encrypts a file using AES-GCM +export declare function encryptFile(file: Blob): Promise + +// Decrypts an encrypted file +export declare function decryptFile(encryptedFile: EncryptedFile): Promise +``` + +## Example + +```typescript +import { uploadBlob, makeBlossomAuthEvent } from '@welshman/util' + +// Create auth event for upload +const authEvent = makeBlossomAuthEvent({ + action: "upload", + server: "https://blossom.example.com" +}) + +// Sign the auth event with your signer +const signedAuthEvent = await signer.signEvent(authEvent) + +// Upload a file +const file = new File(["Hello world"], "hello.txt", { type: "text/plain" }) +const response = await uploadBlob("https://blossom.example.com", file, { + authEvent: signedAuthEvent +}) +``` diff --git a/docs/util/nip86.md b/docs/util/nip86.md new file mode 100644 index 0000000..81c5581 --- /dev/null +++ b/docs/util/nip86.md @@ -0,0 +1,57 @@ +# NIP-86 Relay Management + +Implementation of NIP-86 for managing Nostr relays through authenticated RPC requests. + +## Types + +```typescript +export enum ManagementMethod { + SupportedMethods = "supportedmethods", + BanPubkey = "banpubkey", + AllowPubkey = "allowpubkey", + ListBannedPubkeys = "listbannedpubkeys", + ListAllowedPubkeys = "listallowedpubkeys", + ListEventsNeedingModeration = "listeventsneedingmoderation", + AllowEvent = "allowevent", + BanEvent = "banevent", + ListBannedEvents = "listbannedevents", + ChangeRelayName = "changerelayname", + ChangeRelayDescription = "changerelaydescription", + ChangeRelayIcon = "changerelayicon", + AllowKind = "allowkind", + DisallowKind = "disallowkind", + ListAllowedKinds = "listallowedkinds", + BlockIp = "blockip", + UnblockIp = "unblockip", + ListBlockedIps = "listblockedips", +} + +export type ManagementRequest = { + method: ManagementMethod + params: string[] +} +``` + +## Functions + +```typescript +// Sends a management request to a relay +export declare const sendManagementRequest: (url: string, request: ManagementRequest, authEvent: SignedEvent) => Promise +``` + +## Example + +```typescript +import { sendManagementRequest, ManagementMethod, makeHttpAuth } from '@welshman/util' + +// Set up our url and params +const url = "https://relay.example.com/" +const payload = {method: ManagementMethod.SupportedMethods, params: []} + +// Create auth event for the management endpoint +const authEvent = await makeHttpAuth(url, "POST", JSON.stringify(payload)) +const signedAuthEvent = await signer.signEvent(authEvent) + +// Get a list of supported methods +const response = await sendManagementRequest(url, payload, signedAuthEvent) +``` diff --git a/docs/util/nip98.md b/docs/util/nip98.md new file mode 100644 index 0000000..6aa8947 --- /dev/null +++ b/docs/util/nip98.md @@ -0,0 +1,42 @@ +# NIP-98 HTTP Auth + +Implementation of NIP-98 HTTP Authentication for authenticating HTTP requests with Nostr events. + +## Functions + +```typescript +// Creates an HTTP auth event for authenticating requests +export declare const makeHttpAuth: (url: string, method?: string, body?: string) => Promise + +// Creates Authorization header from signed HTTP auth event +export declare const makeHttpAuthHeader: (event: SignedEvent) => string +``` + +## Example + +```typescript +import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util' + +const url = "https://api.example.com/upload" +const method = "POST" +const body = {data: "example"} + +// Create HTTP auth event +const authEvent = await makeHttpAuth(url, method, JSON.stringify(body)) + +// Sign the auth event +const signedEvent = await signer.signEvent(authEvent) + +// Create Authorization header +const authHeader = makeHttpAuthHeader(signedEvent) + +// Use in fetch request +const response = await fetch(url, { + body, + method, + headers: { + "Authorization": authHeader, + "Content-Type": "application/json" + }, +}) +``` diff --git a/packages/app/src/commands.ts b/packages/app/src/commands.ts index d4f6a54..65cff3b 100644 --- a/packages/app/src/commands.ts +++ b/packages/app/src/commands.ts @@ -1,9 +1,12 @@ import {get} from "svelte/store" import {uniq, nthNe, removeNil, nthEq} from "@welshman/lib" import { + sendManagementRequest, + ManagementRequest, addToListPublicly, EventTemplate, removeFromList, + makeHttpAuth, getListTags, getRelayTags, makeList, @@ -141,3 +144,12 @@ export const sendWrapped = async ({template, pubkeys, ...options}: SendWrappedOp ), ) } + +export const manageRelay = async (url: string, request: ManagementRequest) => { + url = url.replace(/^ws/, "http") + + const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request)) + const authEvent = await signer.get()!.sign(authTemplate) + + return sendManagementRequest(url, request, authEvent) +} diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index 64bef8e..0abeb65 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -1321,7 +1321,10 @@ export const postJson = async (url: string, data: T, opts: FetchOpts = {}) => opts.headers = {} } - opts.headers["Content-Type"] = "application/json" + if (!opts.headers["Content-Type"]) { + opts.headers["Content-Type"] = "application/json" + } + opts.body = JSON.stringify(data) return fetchJson(url, opts) @@ -1562,6 +1565,10 @@ export const hexToBech32 = (prefix: string, hex: string) => export const bech32ToHex = (b32: string) => utf8.encode(bech32.fromWords(bech32.decode(b32 as any, false).words)) +// ---------------------------------------------------------------------------- +// Bytes <-> hex encoding +// ---------------------------------------------------------------------------- + /** * Converts an array buffer or Uint8Array to hex format * @param buffer - ArrayBuffer or Uint8Array to convert @@ -1585,6 +1592,14 @@ export const bytesToHex = (buffer: ArrayBuffer | Uint8Array) => { export const hexToBytes = (hex: string) => new Uint8Array(hex.match(/.{2}/g)!.map(hex => parseInt(hex, 16))) +// ---------------------------------------------------------------------------- +// Binary data +// ---------------------------------------------------------------------------- + +export const textEncoder = new TextEncoder() + +export const textDecoder = new TextDecoder() + /** * Computes SHA-256 hash of binary data * @param data - Binary data to hash diff --git a/packages/signer/src/signers/nip46.ts b/packages/signer/src/signers/nip46.ts index 2f04929..203887f 100644 --- a/packages/signer/src/signers/nip46.ts +++ b/packages/signer/src/signers/nip46.ts @@ -1,6 +1,6 @@ import {Emitter, throttle, makePromise, defer, sleep, tryCatch, randomId} from "@welshman/lib" import { - createEvent, + makeEvent, normalizeRelayUrl, TrustedEvent, StampedEvent, @@ -163,7 +163,7 @@ export class Nip46Sender extends Emitter { const payload = JSON.stringify({id, method, params}) const content = await this.signer[algorithm].encrypt(signerPubkey, payload) - const template = createEvent(NOSTR_CONNECT, {content, tags: [["p", signerPubkey]]}) + const template = makeEvent(NOSTR_CONNECT, {content, tags: [["p", signerPubkey]]}) const event = await this.signer.sign(template) publish({relays, event, context}) diff --git a/packages/util/__tests__/Events.test.ts b/packages/util/__tests__/Events.test.ts index 4ae7f15..65f646a 100644 --- a/packages/util/__tests__/Events.test.ts +++ b/packages/util/__tests__/Events.test.ts @@ -57,9 +57,9 @@ describe("Events", () => { ], }) - describe("createEvent", () => { + describe("makeEvent", () => { it("should create event with defaults", () => { - const event = Events.createEvent(1, {}) + const event = Events.makeEvent(1, {}) expect(event.kind).toBe(1) expect(event.content).toBe("") expect(event.tags).toEqual([]) @@ -67,7 +67,7 @@ describe("Events", () => { }) it("should create event with provided values", () => { - const event = Events.createEvent(1, { + const event = Events.makeEvent(1, { content: "Hello Nostr!", tags: [["p", pubkey]], created_at: currentTime, diff --git a/packages/util/src/Blossom.ts b/packages/util/src/Blossom.ts index ed6fd68..697cf00 100644 --- a/packages/util/src/Blossom.ts +++ b/packages/util/src/Blossom.ts @@ -1,7 +1,7 @@ -import {Base64} from "js-base64" import {now, bytesToHex, hexToBytes} from "@welshman/lib" import {BLOSSOM_AUTH} from "./Kinds.js" import {makeEvent, SignedEvent} from "./Events.js" +import {makeHttpAuthHeader} from "./Nip98.js" export type BlossomAuthAction = "get" | "upload" | "list" | "delete" @@ -48,10 +48,6 @@ export const makeBlossomAuthEvent = ({ return makeEvent(BLOSSOM_AUTH, {content, tags}) } -export const createAuthorizationHeader = (event: SignedEvent): string => { - return `Nostr ${Base64.encode(JSON.stringify(event))}` -} - export const buildBlobUrl = (server: string, sha256: string, extension?: string): string => { const url = new URL(server) const filename = extension ? `${sha256}.${extension}` : sha256 @@ -69,7 +65,7 @@ export const checkBlobExists = async ( const headers: Record = {} if (options.authEvent) { - headers.Authorization = createAuthorizationHeader(options.authEvent) + headers.Authorization = makeHttpAuthHeader(options.authEvent) } try { @@ -101,7 +97,7 @@ export const getBlob = async ( const headers: Record = {} if (options.authEvent) { - headers.Authorization = createAuthorizationHeader(options.authEvent) + headers.Authorization = makeHttpAuthHeader(options.authEvent) } if (options.range) { @@ -126,7 +122,7 @@ export const uploadBlob = async ( const headers: Record = {} if (options.authEvent) { - headers.Authorization = createAuthorizationHeader(options.authEvent) + headers.Authorization = makeHttpAuthHeader(options.authEvent) } return fetch(uploadUrl, {method: "PUT", headers, body}) @@ -144,7 +140,7 @@ export const deleteBlob = async ( const headers: Record = {} if (options.authEvent) { - headers.Authorization = createAuthorizationHeader(options.authEvent) + headers.Authorization = makeHttpAuthHeader(options.authEvent) } return fetch(url, {method: "DELETE", headers}) @@ -175,7 +171,7 @@ export const listBlobs = async ( const headers: Record = {} if (options.authEvent) { - headers.Authorization = createAuthorizationHeader(options.authEvent) + headers.Authorization = makeHttpAuthHeader(options.authEvent) } return fetch(fullUrl, {headers}) diff --git a/packages/util/src/Events.ts b/packages/util/src/Events.ts index 3f59108..2881038 100644 --- a/packages/util/src/Events.ts +++ b/packages/util/src/Events.ts @@ -54,12 +54,14 @@ export type MakeEventOpts = { created_at?: number } +// Event template creation + export const makeEvent = ( kind: number, {content = "", tags = [], created_at = now()}: MakeEventOpts = {}, ) => ({kind, content, tags, created_at}) -export const createEvent = makeEvent +// Event signature verification export const verifyEvent = (() => { let verify = verifyEventPure @@ -80,6 +82,8 @@ export const verifyEvent = (() => { Boolean(event.sig && (event[verifiedSymbol] || verify(event as SignedEvent))) })() +// Type guards + export const isEventTemplate = (e: EventTemplate): e is EventTemplate => Boolean(typeof e.kind === "number" && Array.isArray(e.tags) && typeof e.content === "string") @@ -100,6 +104,8 @@ export const isUnwrappedEvent = (e: TrustedEvent): e is UnwrappedEvent => export const isTrustedEvent = (e: TrustedEvent): e is TrustedEvent => isSignedEvent(e) || isUnwrappedEvent(e) +// Type coercion and attribute stripping + export const asEventTemplate = (e: EventTemplate): EventTemplate => pick(["kind", "tags", "content"], e) @@ -121,6 +127,8 @@ export const asUnwrappedEvent = (e: UnwrappedEvent): UnwrappedEvent => export const asTrustedEvent = (e: TrustedEvent): TrustedEvent => pick(["kind", "tags", "content", "created_at", "pubkey", "id", "sig", "wrap"], e) +// Utilities for working with events + export const getIdentifier = (e: EventTemplate) => e.tags.find(t => t[0] === "d")?.[1] export const getIdOrAddress = (e: HashedEvent) => (isReplaceable(e) ? getAddress(e) : e.id) diff --git a/packages/util/src/Nip86.ts b/packages/util/src/Nip86.ts new file mode 100644 index 0000000..2e82f3c --- /dev/null +++ b/packages/util/src/Nip86.ts @@ -0,0 +1,41 @@ +import {postJson} from "@welshman/lib" +import {SignedEvent} from "./Events.js" +import {makeHttpAuthHeader} from "./Nip98.js" + +export enum ManagementMethod { + SupportedMethods = "supportedmethods", + BanPubkey = "banpubkey", + AllowPubkey = "allowpubkey", + ListBannedPubkeys = "listbannedpubkeys", + ListAllowedPubkeys = "listallowedpubkeys", + ListEventsNeedingModeration = "listeventsneedingmoderation", + AllowEvent = "allowevent", + BanEvent = "banevent", + ListBannedEvents = "listbannedevents", + ChangeRelayName = "changerelayname", + ChangeRelayDescription = "changerelaydescription", + ChangeRelayIcon = "changerelayicon", + AllowKind = "allowkind", + DisallowKind = "disallowkind", + ListAllowedKinds = "listallowedkinds", + BlockIp = "blockip", + UnblockIp = "unblockip", + ListBlockedIps = "listblockedips", +} + +export type ManagementRequest = { + method: ManagementMethod + params: string[] +} + +export const sendManagementRequest = ( + url: string, + request: ManagementRequest, + authEvent: SignedEvent, +) => + postJson(url, request, { + headers: { + "Content-Type": "application/nostr+json+rpc", + Authorization: makeHttpAuthHeader(authEvent), + }, + }) diff --git a/packages/util/src/Nip98.ts b/packages/util/src/Nip98.ts new file mode 100644 index 0000000..ba814c6 --- /dev/null +++ b/packages/util/src/Nip98.ts @@ -0,0 +1,20 @@ +import {Base64} from "js-base64" +import {sha256, textEncoder} from "@welshman/lib" +import {makeEvent, SignedEvent} from "./Events.js" +import {HTTP_AUTH} from "./Kinds.js" + +export const makeHttpAuth = async (url: string, method = "GET", body?: string) => { + const tags = [ + ["u", url], + ["method", method], + ] + + if (body) { + tags.push(["payload", await sha256(textEncoder.encode(body))]) + } + + return makeEvent(HTTP_AUTH, {tags}) +} + +export const makeHttpAuthHeader = (event: SignedEvent) => + `Nostr ${Base64.encode(JSON.stringify(event))}` diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 5d0752a..77143b0 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -7,6 +7,8 @@ export * from "./Handler.js" export * from "./Kinds.js" export * from "./Links.js" export * from "./List.js" +export * from "./Nip86.js" +export * from "./Nip98.js" export * from "./Profile.js" export * from "./Relay.js" export * from "./Tags.js"