mirror of
https://github.com/nostr-connect/connect.git
synced 2025-12-17 05:04:20 +01:00
add ConnectURI class
This commit is contained in:
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
import { NostrRPC } from '../src/request';
|
import { NostrRPC } from '../src/rpc';
|
||||||
|
|
||||||
|
|
||||||
class Server extends NostrRPC {
|
class Server extends NostrRPC {
|
||||||
|
|||||||
269
src/connect.ts
269
src/connect.ts
@@ -1,142 +1,183 @@
|
|||||||
import { Event, nip04, relayInit } from 'nostr-tools';
|
import { getPublicKey, Event, nip04 } from 'nostr-tools';
|
||||||
import { prepareRequest } from './event';
|
import { isValidRequest, NostrRPC } from './rpc';
|
||||||
import { Session, SessionStatus } from './session';
|
import EventEmitter from 'events';
|
||||||
|
|
||||||
export interface ConnectMessage {
|
export interface Metadata {
|
||||||
type: ConnectMessageType;
|
name: string;
|
||||||
value?: any;
|
url: string;
|
||||||
requestID?: string;
|
description?: string;
|
||||||
|
icons?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ConnectMessageType {
|
export enum SessionStatus {
|
||||||
PAIRED = 'paired',
|
Paired = 'paired',
|
||||||
UNPAIRED = 'unpaired',
|
Unpaired = 'unpaired',
|
||||||
GET_PUBLIC_KEY_REQUEST = 'getPublicKeyRequest',
|
|
||||||
GET_PUBLIC_KEY_RESPONSE = 'getPublicKeyResponse',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairingACK extends ConnectMessage {
|
export class ConnectURI {
|
||||||
type: ConnectMessageType.PAIRED;
|
status: SessionStatus = SessionStatus.Unpaired;
|
||||||
value: {
|
target: string;
|
||||||
pubkey: string;
|
metadata: Metadata;
|
||||||
};
|
relayURL: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface PairingNACK extends ConnectMessage {
|
static fromURI(uri: string): ConnectURI {
|
||||||
type: ConnectMessageType.UNPAIRED;
|
const url = new URL(uri);
|
||||||
}
|
const target = url.searchParams.get('target');
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('Invalid connect URI: missing target');
|
||||||
|
}
|
||||||
|
const relay = url.searchParams.get('relay');
|
||||||
|
if (!relay) {
|
||||||
|
throw new Error('Invalid connect URI: missing relay');
|
||||||
|
}
|
||||||
|
const metadata = url.searchParams.get('metadata');
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error('Invalid connect URI: missing metadata');
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetPublicKeyRequest extends ConnectMessage {
|
try {
|
||||||
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST;
|
const md = JSON.parse(metadata);
|
||||||
}
|
return new ConnectURI({ target: target, metadata: md, relayURL: relay });
|
||||||
|
} catch (ignore) {
|
||||||
export interface GetPublicKeyResponse extends ConnectMessage {
|
throw new Error('Invalid connect URI: metadata is not valid JSON');
|
||||||
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
|
}
|
||||||
value: {
|
|
||||||
pubkey: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function responseTypeForRequestType(
|
|
||||||
type: ConnectMessageType
|
|
||||||
): ConnectMessageType {
|
|
||||||
switch (type) {
|
|
||||||
case ConnectMessageType.GET_PUBLIC_KEY_REQUEST:
|
|
||||||
return ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
|
|
||||||
default:
|
|
||||||
throw new Error('Invalid request type');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
target,
|
||||||
|
metadata,
|
||||||
|
relayURL,
|
||||||
|
}: {
|
||||||
|
target: string;
|
||||||
|
metadata: Metadata;
|
||||||
|
relayURL: string;
|
||||||
|
}) {
|
||||||
|
this.target = target;
|
||||||
|
this.metadata = metadata;
|
||||||
|
this.relayURL = relayURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `nostr://connect?target=${this.target}&metadata=${JSON.stringify(
|
||||||
|
this.metadata
|
||||||
|
)}&relay=${this.relayURL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(secretKey: string): Promise<void> {
|
||||||
|
const rpc = new NostrRPC({
|
||||||
|
relay: this.relayURL,
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
const response = await rpc.call({
|
||||||
|
target: this.target,
|
||||||
|
request: {
|
||||||
|
method: 'connect',
|
||||||
|
params: [getPublicKey(secretKey)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response) throw new Error('Invalid response from remote');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(secretKey: string): Promise<void> {
|
||||||
|
const rpc = new NostrRPC({
|
||||||
|
relay: this.relayURL,
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
const response = await rpc.call({
|
||||||
|
target: this.target,
|
||||||
|
request: {
|
||||||
|
method: 'disconnect',
|
||||||
|
params: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response) throw new Error('Invalid response from remote');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ConnectStatus {
|
||||||
|
Connected = 'connected',
|
||||||
|
Disconnected = 'disconnected',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Connect {
|
export class Connect {
|
||||||
session: Session;
|
rpc: NostrRPC;
|
||||||
private targetPrivateKey: string;
|
target?: string;
|
||||||
|
events = new EventEmitter();
|
||||||
|
status = ConnectStatus.Disconnected;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
session,
|
target,
|
||||||
targetPrivateKey,
|
relay,
|
||||||
|
secretKey,
|
||||||
}: {
|
}: {
|
||||||
session: Session;
|
secretKey: string;
|
||||||
targetPrivateKey: string;
|
target?: string;
|
||||||
|
relay?: string;
|
||||||
}) {
|
}) {
|
||||||
this.session = session;
|
this.rpc = new NostrRPC({ relay, secretKey });
|
||||||
this.targetPrivateKey = targetPrivateKey;
|
if (target) {
|
||||||
|
this.target = target;
|
||||||
|
this.status = ConnectStatus.Connected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(message: ConnectMessage): Promise<ConnectMessage> {
|
async init() {
|
||||||
if (this.session.status !== SessionStatus.PAIRED)
|
const sub = await this.rpc.listen();
|
||||||
throw new Error('Session is not paired');
|
sub.on('event', async (event: Event) => {
|
||||||
if (!this.session.target) throw new Error('Target is required');
|
let payload;
|
||||||
if (!this.session.remote) throw new Error('Remote is required');
|
try {
|
||||||
|
|
||||||
const { target, remote } = this.session;
|
|
||||||
|
|
||||||
// send request to remote
|
|
||||||
const { event, requestID } = await prepareRequest(
|
|
||||||
target,
|
|
||||||
remote,
|
|
||||||
message,
|
|
||||||
this.targetPrivateKey
|
|
||||||
);
|
|
||||||
console.log(`sending message ${message.type} with requestID ${requestID}`);
|
|
||||||
const id = await this.session.sendEvent(event, this.targetPrivateKey);
|
|
||||||
if (!id) throw new Error('Failed to send message ' + message.type);
|
|
||||||
console.log('sent message with nostr id', id);
|
|
||||||
|
|
||||||
const relay = relayInit(this.session.relay);
|
|
||||||
await relay.connect();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
relay.on('error', () => {
|
|
||||||
reject(`failed to connect to ${relay.url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// waiting for response from remote
|
|
||||||
let sub = relay.sub([
|
|
||||||
{
|
|
||||||
kinds: [4],
|
|
||||||
authors: [remote],
|
|
||||||
//since: now,
|
|
||||||
'#p': [target],
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
sub.on('event', async (event: Event) => {
|
|
||||||
const plaintext = await nip04.decrypt(
|
const plaintext = await nip04.decrypt(
|
||||||
this.targetPrivateKey,
|
this.rpc.self.secret,
|
||||||
event.pubkey,
|
event.pubkey,
|
||||||
event.content
|
event.content
|
||||||
);
|
);
|
||||||
console.log('plaintext', plaintext);
|
if (!plaintext) throw new Error('failed to decrypt event');
|
||||||
console.log('requestID', requestID);
|
payload = JSON.parse(plaintext);
|
||||||
const payload = JSON.parse(plaintext);
|
} catch (ignore) {
|
||||||
if (!payload) return;
|
return;
|
||||||
if (
|
}
|
||||||
!Object.keys(payload).includes('requestID') ||
|
// ignore all the events that are not NostrRPCRequest events
|
||||||
!Object.keys(payload).includes('message')
|
if (!isValidRequest(payload)) return;
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (payload.requestID !== requestID) return;
|
|
||||||
const msg = payload.message as ConnectMessage;
|
|
||||||
const responseType = responseTypeForRequestType(msg.type);
|
|
||||||
if (msg.type !== responseType) return;
|
|
||||||
resolve(msg);
|
|
||||||
});
|
|
||||||
|
|
||||||
sub.on('eose', () => {
|
// ignore all the request that are not connect
|
||||||
sub.unsub();
|
if (payload.method !== 'connect') return;
|
||||||
});
|
|
||||||
|
// ignore all the request that are not for us
|
||||||
|
if (!payload.params || payload.params.length !== 1) return;
|
||||||
|
const [pubkey] = payload.params;
|
||||||
|
|
||||||
|
this.status = ConnectStatus.Connected;
|
||||||
|
this.target = pubkey;
|
||||||
|
this.events.emit('connect', pubkey);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
on(evt: 'connect' | 'disconnect', cb: (...args: any[]) => void) {
|
||||||
|
this.events.on(evt, cb);
|
||||||
|
}
|
||||||
|
off(evt: 'connect' | 'disconnect', cb: (...args: any[]) => void) {
|
||||||
|
this.events.off(evt, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isConnected() {
|
||||||
|
return this.status === ConnectStatus.Connected;
|
||||||
|
}
|
||||||
|
|
||||||
async getPublicKey(): Promise<string> {
|
async getPublicKey(): Promise<string> {
|
||||||
const response: ConnectMessage = await this.sendMessage({
|
if (!this.target || !this.isConnected()) throw new Error('Not connected');
|
||||||
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST,
|
|
||||||
|
const response = await this.rpc.call({
|
||||||
|
target: this.target,
|
||||||
|
request: {
|
||||||
|
method: 'get_public_key',
|
||||||
|
params: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (response.type !== ConnectMessageType.GET_PUBLIC_KEY_RESPONSE)
|
return response as string;
|
||||||
throw new Error('Invalid response type');
|
|
||||||
return response.value.pubkey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async signEvent(_event: Event): Promise<Event> {
|
async signEvent(_event: Event): Promise<Event> {
|
||||||
@@ -157,8 +198,4 @@ export class Connect {
|
|||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async request(_opts: { method: string; params: any }): Promise<any> {
|
|
||||||
throw new Error('Not implemented');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/event.ts
56
src/event.ts
@@ -1,56 +0,0 @@
|
|||||||
import { nip04, Event } from 'nostr-tools';
|
|
||||||
import { ConnectMessage } from './connect';
|
|
||||||
|
|
||||||
export async function prepareRequest(
|
|
||||||
from: string,
|
|
||||||
to: string,
|
|
||||||
request: ConnectMessage,
|
|
||||||
fromSecretKey: string
|
|
||||||
) {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const requestID = Math.random()
|
|
||||||
.toString()
|
|
||||||
.slice(2);
|
|
||||||
const cipherText = await nip04.encrypt(
|
|
||||||
fromSecretKey,
|
|
||||||
to,
|
|
||||||
JSON.stringify({
|
|
||||||
requestID: requestID,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const event: Event = {
|
|
||||||
kind: 4,
|
|
||||||
created_at: now,
|
|
||||||
pubkey: from,
|
|
||||||
tags: [['p', to]],
|
|
||||||
content: cipherText,
|
|
||||||
};
|
|
||||||
return { event, requestID };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prepareResponse(
|
|
||||||
requestID: string,
|
|
||||||
from: string,
|
|
||||||
to: string,
|
|
||||||
response: ConnectMessage,
|
|
||||||
fromSecretKey: string
|
|
||||||
) {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const cipherText = await nip04.encrypt(
|
|
||||||
fromSecretKey,
|
|
||||||
to,
|
|
||||||
JSON.stringify({
|
|
||||||
requestID: requestID,
|
|
||||||
response,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const event: Event = {
|
|
||||||
kind: 4,
|
|
||||||
created_at: now,
|
|
||||||
pubkey: from,
|
|
||||||
tags: [['p', to]],
|
|
||||||
content: cipherText,
|
|
||||||
};
|
|
||||||
return { event, requestID };
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './session';
|
|
||||||
export * from './connect';
|
export * from './connect';
|
||||||
|
export * from './rpc';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class NostrRPC {
|
|||||||
secret: opts.secretKey,
|
secret: opts.secretKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async call({
|
async call({
|
||||||
target,
|
target,
|
||||||
request: { id = randomID(), method, params = [] },
|
request: { id = randomID(), method, params = [] },
|
||||||
@@ -46,28 +47,32 @@ export class NostrRPC {
|
|||||||
params?: any[];
|
params?: any[];
|
||||||
};
|
};
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
|
// prepare request to be sent
|
||||||
|
const request = prepareRequest(id, method, params);
|
||||||
|
const event = await prepareEvent(this.self.secret, target, request);
|
||||||
|
|
||||||
// connect to relay
|
// connect to relay
|
||||||
await this.relay.connect();
|
await this.relay.connect();
|
||||||
this.relay.on('error', () => {
|
|
||||||
throw new Error(`failed to connect to ${this.relay.url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// prepare request to be sent
|
|
||||||
const body = prepareRequest(id, method, params);
|
|
||||||
const event = await prepareEvent(this.self.secret, target, body);
|
|
||||||
|
|
||||||
// send request via relay
|
// send request via relay
|
||||||
await new Promise<void>((resolve, reject) => {
|
try {
|
||||||
const pub = this.relay.publish(event);
|
await new Promise<void>(async (resolve, reject) => {
|
||||||
pub.on('failed', (reason: any) => {
|
this.relay.on('error', () => {
|
||||||
reject(reason);
|
reject(`failed to connect to ${this.relay.url}`);
|
||||||
|
});
|
||||||
|
const pub = this.relay.publish(event);
|
||||||
|
pub.on('failed', (reason: any) => {
|
||||||
|
reject(reason);
|
||||||
|
});
|
||||||
|
pub.on('seen', () => {
|
||||||
|
console.log(`seen`, event.id, request);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
pub.on('seen', () => {
|
} catch (err) {
|
||||||
resolve();
|
throw err;
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: reject after a timeout
|
|
||||||
// waiting for response from remote
|
// waiting for response from remote
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const queries = [
|
const queries = [
|
||||||
@@ -75,10 +80,9 @@ export class NostrRPC {
|
|||||||
kinds: [4],
|
kinds: [4],
|
||||||
authors: [target],
|
authors: [target],
|
||||||
'#p': [this.self.pubkey],
|
'#p': [this.self.pubkey],
|
||||||
since: event.created_at - 1,
|
since: event.created_at,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let sub = this.relay.sub(queries);
|
let sub = this.relay.sub(queries);
|
||||||
sub.on('event', async (event: Event) => {
|
sub.on('event', async (event: Event) => {
|
||||||
let payload;
|
let payload;
|
||||||
@@ -100,8 +104,7 @@ export class NostrRPC {
|
|||||||
// ignore all the events that are not for this request
|
// ignore all the events that are not for this request
|
||||||
if (payload.id !== id) return;
|
if (payload.id !== id) return;
|
||||||
|
|
||||||
// unsubscribe from the stream
|
console.log(`response`, event.id, payload);
|
||||||
sub.unsub();
|
|
||||||
|
|
||||||
// if the response is an error, reject the promise
|
// if the response is an error, reject the promise
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
@@ -113,25 +116,25 @@ export class NostrRPC {
|
|||||||
resolve(payload.result);
|
resolve(payload.result);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sub.on('eose', () => {
|
|
||||||
sub.unsub();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listen(): Promise<Sub> {
|
async listen(): Promise<Sub> {
|
||||||
await this.relay.connect();
|
await this.relay.connect();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
this.relay.on('connect', resolve);
|
this.relay.on('connect', () => {
|
||||||
this.relay.on('error', reject);
|
resolve();
|
||||||
|
});
|
||||||
|
this.relay.on('error', () => {
|
||||||
|
reject(`not possible to connect to ${this.relay.url}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let sub = this.relay.sub([
|
let sub = this.relay.sub([
|
||||||
{
|
{
|
||||||
kinds: [4],
|
kinds: [4],
|
||||||
'#p': [this.self.pubkey],
|
'#p': [this.self.pubkey],
|
||||||
since: now(),
|
since: now() - 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -153,12 +156,15 @@ export class NostrRPC {
|
|||||||
if (!isValidRequest(payload)) return;
|
if (!isValidRequest(payload)) return;
|
||||||
|
|
||||||
// handle request
|
// handle request
|
||||||
|
if (!this.hasOwnProperty(payload.method)) return;
|
||||||
const response = await this.handleRequest(payload);
|
const response = await this.handleRequest(payload);
|
||||||
|
|
||||||
const body = prepareResponse(
|
const body = prepareResponse(
|
||||||
response.id,
|
response.id,
|
||||||
response.result,
|
response.result,
|
||||||
response.error
|
response.error
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseEvent = await prepareEvent(
|
const responseEvent = await prepareEvent(
|
||||||
this.self.secret,
|
this.self.secret,
|
||||||
event.pubkey,
|
event.pubkey,
|
||||||
@@ -166,8 +172,15 @@ export class NostrRPC {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// send response via relay
|
// send response via relay
|
||||||
this.relay.publish(responseEvent);
|
await new Promise<void>((resolve, reject) => {
|
||||||
// TODO: handle errors when event is not seen
|
const pub = this.relay.publish(responseEvent);
|
||||||
|
pub.on('failed', (reason: any) => {
|
||||||
|
reject(reason);
|
||||||
|
});
|
||||||
|
pub.on('seen', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
@@ -250,7 +263,7 @@ export async function prepareEvent(
|
|||||||
return signedEvent;
|
return signedEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidRequest(payload: any): boolean {
|
export function isValidRequest(payload: any): boolean {
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
|
|
||||||
const keys = Object.keys(payload);
|
const keys = Object.keys(payload);
|
||||||
@@ -264,7 +277,7 @@ function isValidRequest(payload: any): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidResponse(payload: any): boolean {
|
export function isValidResponse(payload: any): boolean {
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
|
|
||||||
const keys = Object.keys(payload);
|
const keys = Object.keys(payload);
|
||||||
228
src/session.ts
228
src/session.ts
@@ -1,228 +0,0 @@
|
|||||||
import {
|
|
||||||
validateEvent,
|
|
||||||
verifySignature,
|
|
||||||
signEvent,
|
|
||||||
getEventHash,
|
|
||||||
Event,
|
|
||||||
relayInit,
|
|
||||||
nip04,
|
|
||||||
getPublicKey,
|
|
||||||
} from 'nostr-tools';
|
|
||||||
import { ConnectMessage, ConnectMessageType, PairingACK } from './connect';
|
|
||||||
import { prepareResponse } from './event';
|
|
||||||
|
|
||||||
export interface Metadata {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
description?: string;
|
|
||||||
icons?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SessionStatus {
|
|
||||||
PROPOSED = 'PROPOSED',
|
|
||||||
PAIRED = 'PAIRED',
|
|
||||||
UNPAIRED = 'UNPAIRED',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Session {
|
|
||||||
metadata: Metadata;
|
|
||||||
relay: string;
|
|
||||||
target: string;
|
|
||||||
remote?: string;
|
|
||||||
connectURI: string;
|
|
||||||
status: SessionStatus = SessionStatus.PROPOSED;
|
|
||||||
listeners: Record<string, { [type: string]: Array<(value: any) => any> }>;
|
|
||||||
|
|
||||||
static fromConnectURI(uri: string): Session {
|
|
||||||
const url = new URL(uri);
|
|
||||||
const target = url.searchParams.get('target');
|
|
||||||
if (!target) {
|
|
||||||
throw new Error('Invalid connect URI: missing target');
|
|
||||||
}
|
|
||||||
const relay = url.searchParams.get('relay');
|
|
||||||
if (!relay) {
|
|
||||||
throw new Error('Invalid connect URI: missing relay');
|
|
||||||
}
|
|
||||||
const metadata = url.searchParams.get('metadata');
|
|
||||||
if (!metadata) {
|
|
||||||
throw new Error('Invalid connect URI: missing metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const md = JSON.parse(metadata);
|
|
||||||
return new Session({ target: target, metadata: md, relay });
|
|
||||||
} catch (ignore) {
|
|
||||||
throw new Error('Invalid connect URI: metadata is not valid JSON');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
target,
|
|
||||||
metadata,
|
|
||||||
relay,
|
|
||||||
}: {
|
|
||||||
target: string;
|
|
||||||
relay: string;
|
|
||||||
metadata: Metadata;
|
|
||||||
}) {
|
|
||||||
this.listeners = {};
|
|
||||||
this.target = target;
|
|
||||||
this.metadata = metadata;
|
|
||||||
this.relay = relay;
|
|
||||||
this.connectURI = `nostr://connect?target=${
|
|
||||||
this.target
|
|
||||||
}&metadata=${JSON.stringify(this.metadata)}&relay=${this.relay}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
on(type: ConnectMessageType, cb: (value: any) => any): void {
|
|
||||||
const id = Math.random()
|
|
||||||
.toString()
|
|
||||||
.slice(2);
|
|
||||||
this.listeners[id] = this.listeners[id] || emptyListeners();
|
|
||||||
this.listeners[id][type].push(cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
off(type: ConnectMessageType, cb: (value: any) => any): void {
|
|
||||||
for (const id in this.listeners) {
|
|
||||||
const idx = this.listeners[id][type].indexOf(cb);
|
|
||||||
if (idx > -1) {
|
|
||||||
this.listeners[id][type].splice(idx, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is used to process messages to the given key and emit to listeners
|
|
||||||
async listen(secretKey: string): Promise<void> {
|
|
||||||
if (!secretKey) throw new Error('secret key is required');
|
|
||||||
const pubkey = getPublicKey(secretKey);
|
|
||||||
|
|
||||||
const relay = relayInit(this.relay);
|
|
||||||
await relay.connect();
|
|
||||||
relay.on('connect', () => {
|
|
||||||
console.log(`connected to ${relay.url}`);
|
|
||||||
});
|
|
||||||
relay.on('error', () => {
|
|
||||||
console.error(`failed to connect to ${relay.url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
let sub = relay.sub([
|
|
||||||
{
|
|
||||||
kinds: [4],
|
|
||||||
'#p': [pubkey],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
sub.on('event', async (event: Event) => {
|
|
||||||
const plaintext = await nip04.decrypt(
|
|
||||||
secretKey,
|
|
||||||
event.pubkey,
|
|
||||||
event.content
|
|
||||||
);
|
|
||||||
const payload = JSON.parse(plaintext);
|
|
||||||
if (!payload) return;
|
|
||||||
if (
|
|
||||||
!Object.keys(payload).includes('requestID') ||
|
|
||||||
!Object.keys(payload).includes('message')
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const msg = payload.message as ConnectMessage;
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case ConnectMessageType.PAIRED: {
|
|
||||||
if (this.status === SessionStatus.PAIRED) return;
|
|
||||||
if (!msg.value) return;
|
|
||||||
if (!Object.keys(msg.value).includes('pubkey')) return;
|
|
||||||
|
|
||||||
this.status = SessionStatus.PAIRED;
|
|
||||||
const remote = msg.value.pubkey;
|
|
||||||
this.remote = remote;
|
|
||||||
this.emit(ConnectMessageType.PAIRED, msg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ConnectMessageType.UNPAIRED: {
|
|
||||||
if (this.status !== SessionStatus.PAIRED) return;
|
|
||||||
this.status = SessionStatus.UNPAIRED;
|
|
||||||
this.emit(ConnectMessageType.UNPAIRED);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ConnectMessageType.GET_PUBLIC_KEY_REQUEST: {
|
|
||||||
if (this.status !== SessionStatus.PAIRED) return;
|
|
||||||
this.emit(ConnectMessageType.GET_PUBLIC_KEY_REQUEST, msg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ConnectMessageType.GET_PUBLIC_KEY_RESPONSE: {
|
|
||||||
this.emit(ConnectMessageType.GET_PUBLIC_KEY_RESPONSE, msg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sub.on('eose', () => {
|
|
||||||
sub.unsub();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(type: ConnectMessageType, value?: any): void {
|
|
||||||
Object.values(this.listeners).forEach(listeners => {
|
|
||||||
if (listeners[type]) {
|
|
||||||
listeners[type].forEach(cb => cb(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async pair(remoteSignerPrivateKey: string): Promise<void> {
|
|
||||||
if (!remoteSignerPrivateKey)
|
|
||||||
throw new Error('Signer private key is required');
|
|
||||||
const remoteSignerPubKey = getPublicKey(remoteSignerPrivateKey);
|
|
||||||
this.remote = remoteSignerPubKey;
|
|
||||||
|
|
||||||
const message: PairingACK = {
|
|
||||||
type: ConnectMessageType.PAIRED,
|
|
||||||
value: { pubkey: this.remote },
|
|
||||||
};
|
|
||||||
const randomID = Math.random()
|
|
||||||
.toString()
|
|
||||||
.slice(2);
|
|
||||||
const { event } = await prepareResponse(
|
|
||||||
randomID,
|
|
||||||
this.remote,
|
|
||||||
this.target,
|
|
||||||
message,
|
|
||||||
remoteSignerPrivateKey
|
|
||||||
);
|
|
||||||
const id = await this.sendEvent(event, remoteSignerPrivateKey);
|
|
||||||
console.log('sent pairing response from mobile', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendEvent(event: Event, secretKey: string): Promise<string> {
|
|
||||||
const id = getEventHash(event);
|
|
||||||
const sig = signEvent(event, secretKey);
|
|
||||||
|
|
||||||
const signedEvent = { ...event, id, sig };
|
|
||||||
let ok = validateEvent(signedEvent);
|
|
||||||
let veryOk = verifySignature(signedEvent);
|
|
||||||
if (!ok || !veryOk) {
|
|
||||||
throw new Error('Event is not valid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const relay = relayInit(this.relay);
|
|
||||||
await relay.connect();
|
|
||||||
relay.on('error', () => {
|
|
||||||
throw new Error(`failed to connect to ${relay.url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const pub = relay.publish(signedEvent);
|
|
||||||
pub.on('failed', (reason: any) => reject(reason));
|
|
||||||
pub.on('seen', () => resolve(id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyListeners(): {} {
|
|
||||||
let data: any = {};
|
|
||||||
Object.values(ConnectMessageType).forEach(type => {
|
|
||||||
data[type] = [];
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,42 @@
|
|||||||
import { getPublicKey } from 'nostr-tools';
|
import { getPublicKey } from 'nostr-tools';
|
||||||
import {
|
import { Connect, ConnectURI, NostrRPC } from '../src';
|
||||||
Connect,
|
import { sleep } from './utils';
|
||||||
ConnectMessageType,
|
|
||||||
GetPublicKeyResponse,
|
|
||||||
Session,
|
|
||||||
} from '../src/index';
|
|
||||||
|
|
||||||
jest.setTimeout(5000);
|
jest.setTimeout(8000);
|
||||||
|
|
||||||
|
// web app (this is ephemeral and represents the currention session)
|
||||||
|
const webSK =
|
||||||
|
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3';
|
||||||
|
const webPK = getPublicKey(webSK);
|
||||||
|
console.log('webPk', webPK);
|
||||||
|
|
||||||
|
// mobile app with keys with the nostr identity
|
||||||
|
const mobileSK =
|
||||||
|
'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c';
|
||||||
|
const mobilePK = getPublicKey(mobileSK);
|
||||||
|
console.log('mobilePK', mobilePK);
|
||||||
|
|
||||||
|
class MobileHandler extends NostrRPC {
|
||||||
|
async get_public_key(): Promise<string> {
|
||||||
|
return getPublicKey(this.self.secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('Nostr Connect', () => {
|
describe('Nostr Connect', () => {
|
||||||
it('connect', async () => {
|
it('connect', async () => {
|
||||||
let resolvePaired: (arg0: boolean) => void;
|
const testHandler = jest.fn();
|
||||||
let resolveGetPublicKey: (arg0: boolean) => void;
|
|
||||||
// web app (this is ephemeral and represents the currention session)
|
|
||||||
const webSK =
|
|
||||||
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3';
|
|
||||||
const webPK = getPublicKey(webSK);
|
|
||||||
console.log('webPk', webPK);
|
|
||||||
|
|
||||||
const sessionWeb = new Session({
|
// start listening for connect messages on the web app
|
||||||
|
const connect = new Connect({ secretKey: webSK });
|
||||||
|
connect.events.on('connect', testHandler);
|
||||||
|
await connect.init();
|
||||||
|
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// send the connect message to the web app from the mobile
|
||||||
|
const connectURI = new ConnectURI({
|
||||||
target: webPK,
|
target: webPK,
|
||||||
relay: 'wss://nostr.vulpem.com',
|
relayURL: 'wss://nostr.vulpem.com',
|
||||||
metadata: {
|
metadata: {
|
||||||
name: 'My Website',
|
name: 'My Website',
|
||||||
description: 'lorem ipsum dolor sit amet',
|
description: 'lorem ipsum dolor sit amet',
|
||||||
@@ -28,60 +44,34 @@ describe('Nostr Connect', () => {
|
|||||||
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
|
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => {
|
await connectURI.approve(mobileSK);
|
||||||
expect(msg).toBeDefined();
|
|
||||||
resolvePaired(true);
|
|
||||||
});
|
|
||||||
await sessionWeb.listen(webSK);
|
|
||||||
|
|
||||||
// mobile app (this can be a child key)
|
expect(testHandler).toBeCalledTimes(1);
|
||||||
const sessionMobile = Session.fromConnectURI(sessionWeb.connectURI); // 'nostr://connect?target=...&metadata=...'
|
});
|
||||||
const mobileSK =
|
|
||||||
'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c';
|
|
||||||
const mobilePK = getPublicKey(mobileSK);
|
|
||||||
console.log('mobilePK', mobilePK);
|
|
||||||
await sessionMobile.pair(mobileSK);
|
|
||||||
|
|
||||||
// we define the behavior of the mobile app for each requests
|
it('returns pubkey', async () => {
|
||||||
sessionMobile.on(ConnectMessageType.GET_PUBLIC_KEY_REQUEST, async () => {
|
// start listening for connect messages on the mobile app
|
||||||
const message: GetPublicKeyResponse = {
|
const remoteHandler = new MobileHandler({ secretKey: mobileSK });
|
||||||
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE,
|
await remoteHandler.listen();
|
||||||
value: {
|
|
||||||
pubkey: mobilePK,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const event = await sessionMobile.eventToBeSentToTarget(
|
|
||||||
message,
|
|
||||||
mobileSK
|
|
||||||
);
|
|
||||||
await sessionMobile.sendEvent(event, mobileSK);
|
|
||||||
resolveGetPublicKey(true);
|
|
||||||
});
|
|
||||||
await sessionMobile.listen(mobileSK);
|
|
||||||
|
|
||||||
// The WebApp send the request and wait for the response
|
await sleep(1000);
|
||||||
// The WebApp fetch the public key sending request via session
|
|
||||||
|
// start listening for connect messages on the web app
|
||||||
const connect = new Connect({
|
const connect = new Connect({
|
||||||
session: sessionWeb,
|
secretKey: webSK,
|
||||||
targetPrivateKey: webSK,
|
target: mobilePK,
|
||||||
});
|
});
|
||||||
const response = await connect.sendMessage({
|
await connect.init();
|
||||||
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST,
|
|
||||||
});
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
|
|
||||||
return expect(
|
await sleep(1000);
|
||||||
Promise.all([
|
|
||||||
new Promise(resolve => {
|
|
||||||
resolvePaired = resolve;
|
|
||||||
}),
|
|
||||||
new Promise(resolve => {
|
|
||||||
resolveGetPublicKey = resolve;
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
).resolves.toEqual([true, true]);
|
|
||||||
|
|
||||||
/*
|
// send the get_public_key message to the mobile app from the web
|
||||||
|
const pubkey = await connect.getPublicKey();
|
||||||
|
expect(pubkey).toBe(webPK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
expect(handler).toBeCalledTimes(1);
|
expect(handler).toBeCalledTimes(1);
|
||||||
expect(handler).toBeCalledWith({
|
expect(handler).toBeCalledWith({
|
||||||
type: ConnectMessageType.PAIRED,
|
type: ConnectMessageType.PAIRED,
|
||||||
@@ -114,5 +104,3 @@ describe('Nostr Connect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
*/
|
*/
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NostrRPC } from '../src/request';
|
import { NostrRPC } from '../src/rpc';
|
||||||
|
import { sleep } from './utils';
|
||||||
|
|
||||||
class Server extends NostrRPC {
|
class Server extends NostrRPC {
|
||||||
async ping(): Promise<string> {
|
async ping(): Promise<string> {
|
||||||
@@ -6,7 +7,7 @@ class Server extends NostrRPC {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jest.setTimeout(10000);
|
jest.setTimeout(5000);
|
||||||
|
|
||||||
describe('Nostr RPC', () => {
|
describe('Nostr RPC', () => {
|
||||||
it('starts a server', async () => {
|
it('starts a server', async () => {
|
||||||
@@ -20,7 +21,6 @@ describe('Nostr RPC', () => {
|
|||||||
secretKey:
|
secretKey:
|
||||||
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3',
|
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3',
|
||||||
});
|
});
|
||||||
console.log(`from: ` + client.self.pubkey, `to: ` + server.self.pubkey);
|
|
||||||
|
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
@@ -28,10 +28,6 @@ describe('Nostr RPC', () => {
|
|||||||
target: server.self.pubkey,
|
target: server.self.pubkey,
|
||||||
request: { method: 'ping' },
|
request: { method: 'ping' },
|
||||||
});
|
});
|
||||||
console.log(result);
|
expect(result).toBe('pong');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function sleep(ms: number) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
3
test/utils.ts
Normal file
3
test/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user