mirror of
https://github.com/nostr-connect/connect.git
synced 2025-12-17 05:04:20 +01:00
add request & test
This commit is contained in:
171
README.md
171
README.md
@@ -1,160 +1,31 @@
|
||||
# TSDX React User Guide
|
||||
# Nostr Connect
|
||||
|
||||
Congrats! You just saved yourself hours of work by bootstrapping this project with TSDX. Let’s get you oriented with what’s here and how to use it.
|
||||
|
||||
> This TSDX setup is meant for developing React component libraries (not apps!) that can be published to NPM. If you’re looking to build a React-based app, you should use `create-react-app`, `razzle`, `nextjs`, `gatsby`, or `react-static`.
|
||||
## Nostr Connect
|
||||
PAIRING
|
||||
|
||||
> If you’re new to TypeScript and React, checkout [this handy cheatsheet](https://github.com/sw-yx/react-typescript-cheatsheet/)
|
||||
1. User clicks on "Connect" button on a website or scan it with a QR code
|
||||
2. It will show an URI to open a "nostr-connect" enabled Wallet
|
||||
3. In the URI there is a pubkey of the App ie. nc://<pubkey>
|
||||
4. The Wallet will send a kind 4 encrypted message to ACK the pairing request, along with his public key
|
||||
|
||||
## Commands
|
||||
|
||||
TSDX scaffolds your new library inside `/src`, and also sets up a [Parcel-based](https://parceljs.org) playground for it inside `/example`.
|
||||
ENABLE
|
||||
|
||||
The recommended workflow is to run TSDX in one terminal:
|
||||
1. The App will send a kind 4 encrypted message with metadata to the Wallet with a Enable Request
|
||||
2. The Wallet will show a popup to the user to enable the App to requesta data or remote siging requests
|
||||
3. The Wallet will send a kind 4 encrypted message to ACK the Enable Request or reject it
|
||||
4. All others subsequent Enabled Requests will be ACKed automatically
|
||||
|
||||
```bash
|
||||
npm start # or yarn start
|
||||
```
|
||||
DELEGATE
|
||||
|
||||
This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`.
|
||||
1. The App will send a kind 4 encrypted message with metadata to the Wallet with a Delegate Request
|
||||
2. The Wallet will show a popup to the user to delegate the App to sign with a child key
|
||||
3. The Wallet will send a kind 4 encrypted message to ACK the Delegate Request with the child private key or reject it
|
||||
4. All others subsequent Delegate Requests will be ACKed automatically
|
||||
|
||||
Then run the example inside another:
|
||||
REMOTE SIGNING
|
||||
|
||||
```bash
|
||||
cd example
|
||||
npm i # or yarn to install dependencies
|
||||
npm start # or yarn start
|
||||
```
|
||||
|
||||
The default example imports and live reloads whatever is in `/dist`, so if you are seeing an out of date component, make sure TSDX is running in watch mode like we recommend above. **No symlinking required**, we use [Parcel's aliasing](https://parceljs.org/module_resolution.html#aliases).
|
||||
|
||||
To do a one-off build, use `npm run build` or `yarn build`.
|
||||
|
||||
To run tests, use `npm test` or `yarn test`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Code quality is set up for you with `prettier`, `husky`, and `lint-staged`. Adjust the respective fields in `package.json` accordingly.
|
||||
|
||||
### Jest
|
||||
|
||||
Jest tests are set up to run with `npm test` or `yarn test`.
|
||||
|
||||
### Bundle analysis
|
||||
|
||||
Calculates the real cost of your library using [size-limit](https://github.com/ai/size-limit) with `npm run size` and visulize it with `npm run analyze`.
|
||||
|
||||
#### Setup Files
|
||||
|
||||
This is the folder structure we set up for you:
|
||||
|
||||
```txt
|
||||
/example
|
||||
index.html
|
||||
index.tsx # test your component here in a demo app
|
||||
package.json
|
||||
tsconfig.json
|
||||
/src
|
||||
index.tsx # EDIT THIS
|
||||
/test
|
||||
blah.test.tsx # EDIT THIS
|
||||
.gitignore
|
||||
package.json
|
||||
README.md # EDIT THIS
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
#### React Testing Library
|
||||
|
||||
We do not set up `react-testing-library` for you yet, we welcome contributions and documentation on this.
|
||||
|
||||
### Rollup
|
||||
|
||||
TSDX uses [Rollup](https://rollupjs.org) as a bundler and generates multiple rollup configs for various module formats and build settings. See [Optimizations](#optimizations) for details.
|
||||
|
||||
### TypeScript
|
||||
|
||||
`tsconfig.json` is set up to interpret `dom` and `esnext` types, as well as `react` for `jsx`. Adjust according to your needs.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Two actions are added by default:
|
||||
|
||||
- `main` which installs deps w/ cache, lints, tests, and builds on all pushes against a Node and OS matrix
|
||||
- `size` which comments cost comparison of your library on every pull request using [`size-limit`](https://github.com/ai/size-limit)
|
||||
|
||||
## Optimizations
|
||||
|
||||
Please see the main `tsdx` [optimizations docs](https://github.com/palmerhq/tsdx#optimizations). In particular, know that you can take advantage of development-only optimizations:
|
||||
|
||||
```js
|
||||
// ./types/index.d.ts
|
||||
declare var __DEV__: boolean;
|
||||
|
||||
// inside your code...
|
||||
if (__DEV__) {
|
||||
console.log('foo');
|
||||
}
|
||||
```
|
||||
|
||||
You can also choose to install and use [invariant](https://github.com/palmerhq/tsdx#invariant) and [warning](https://github.com/palmerhq/tsdx#warning) functions.
|
||||
|
||||
## Module Formats
|
||||
|
||||
CJS, ESModules, and UMD module formats are supported.
|
||||
|
||||
The appropriate paths are configured in `package.json` and `dist/index.js` accordingly. Please report if any issues are found.
|
||||
|
||||
## Deploying the Example Playground
|
||||
|
||||
The Playground is just a simple [Parcel](https://parceljs.org) app, you can deploy it anywhere you would normally deploy that. Here are some guidelines for **manually** deploying with the Netlify CLI (`npm i -g netlify-cli`):
|
||||
|
||||
```bash
|
||||
cd example # if not already in the example folder
|
||||
npm run build # builds to dist
|
||||
netlify deploy # deploy the dist folder
|
||||
```
|
||||
|
||||
Alternatively, if you already have a git repo connected, you can set up continuous deployment with Netlify:
|
||||
|
||||
```bash
|
||||
netlify init
|
||||
# build command: yarn build && cd example && yarn && yarn build
|
||||
# directory to deploy: example/dist
|
||||
# pick yes for netlify.toml
|
||||
```
|
||||
|
||||
## Named Exports
|
||||
|
||||
Per Palmer Group guidelines, [always use named exports.](https://github.com/palmerhq/typescript#exports) Code split inside your React app instead of your React library.
|
||||
|
||||
## Including Styles
|
||||
|
||||
There are many ways to ship styles, including with CSS-in-JS. TSDX has no opinion on this, configure how you like.
|
||||
|
||||
For vanilla CSS, you can include it at the root directory and add it to the `files` section in your `package.json`, so that it can be imported separately by your users and run through their bundler's loader.
|
||||
|
||||
## Publishing to NPM
|
||||
|
||||
We recommend using [np](https://github.com/sindresorhus/np).
|
||||
|
||||
## Usage with Lerna
|
||||
|
||||
When creating a new package with TSDX within a project set up with Lerna, you might encounter a `Cannot resolve dependency` error when trying to run the `example` project. To fix that you will need to make changes to the `package.json` file _inside the `example` directory_.
|
||||
|
||||
The problem is that due to the nature of how dependencies are installed in Lerna projects, the aliases in the example project's `package.json` might not point to the right place, as those dependencies might have been installed in the root of your Lerna project.
|
||||
|
||||
Change the `alias` to point to where those packages are actually installed. This depends on the directory structure of your Lerna project, so the actual path might be different from the diff below.
|
||||
|
||||
```diff
|
||||
"alias": {
|
||||
- "react": "../node_modules/react",
|
||||
- "react-dom": "../node_modules/react-dom"
|
||||
+ "react": "../../../node_modules/react",
|
||||
+ "react-dom": "../../../node_modules/react-dom"
|
||||
},
|
||||
```
|
||||
|
||||
An alternative to fixing this problem would be to remove aliases altogether and define the dependencies referenced as aliases as dev dependencies instead. [However, that might cause other problems.](https://github.com/palmerhq/tsdx/issues/64)
|
||||
1. The App will send a kind 4 encrypted message with metadata to the Wallet with a Sign Event Request
|
||||
2. The Wallet will show a popup to the user to sign the event
|
||||
3. The Wallet will send a kind 4 encrypted message to ACK the Sign Event Request with the signed event or reject it
|
||||
97
example/index.old.tsx
Normal file
97
example/index.old.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'react-app-polyfill/ie11';
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Session } from '../src/index';
|
||||
import { generatePrivateKey, getPublicKey, nip04, relayInit } from 'nostr-tools'
|
||||
|
||||
const App = () => {
|
||||
/* useEffect(() => {
|
||||
(async () => {
|
||||
})();
|
||||
}, []); */
|
||||
|
||||
const [walletKey, setWalletKey] = React.useState<{ pk: string; sk: string }>();
|
||||
const [sessionHolder, setSession] = React.useState<Session>();
|
||||
const [request, setRequest] = React.useState<any>();
|
||||
|
||||
|
||||
const newWallet = () => {
|
||||
//this is the wallet public key
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
setWalletKey({ pk, sk });
|
||||
}
|
||||
|
||||
const newSession = async () => {
|
||||
|
||||
const session = new Session({
|
||||
name: 'Auth',
|
||||
description: 'lorem ipsum dolor sit amet',
|
||||
url: 'https://vulpem.com',
|
||||
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
|
||||
});
|
||||
|
||||
await session.pair(walletKey?.pk);
|
||||
setSession(session);
|
||||
};
|
||||
|
||||
const listen = async () => {
|
||||
if (!sessionHolder) return;
|
||||
|
||||
// let's query for an event to this wallet pub key
|
||||
const relay = relayInit('wss://nostr.vulpem.com');
|
||||
await relay.connect()
|
||||
|
||||
relay.on('connect', () => {
|
||||
console.log(`wallet: connected to ${relay.url}`)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
console.log(`wallet: failed to connect to ${relay.url}`)
|
||||
})
|
||||
|
||||
let sub = relay.sub([{ kinds: [4] }])
|
||||
// on the receiver side
|
||||
sub.on('event', async (event) => {
|
||||
if (!walletKey) return;
|
||||
|
||||
const mention = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
||||
if (mention !== walletKey.pk) return;
|
||||
|
||||
const plaintext = await nip04.decrypt(walletKey?.sk, sessionHolder.pubKey, event.content);
|
||||
console.log('wallet', event.id, event.pubkey, JSON.parse(plaintext));
|
||||
setRequest(JSON.parse(plaintext));
|
||||
})
|
||||
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>💸 Wallet</h1>
|
||||
{walletKey && <p> 🔎 Wallet Pub: {walletKey?.pk} </p>}
|
||||
<button onClick={newWallet}>Create Wallet</button>
|
||||
<button disabled={!sessionHolder} onClick={listen}>Listen</button>
|
||||
{request && <div>
|
||||
<h1>📨 Incoming Request</h1>
|
||||
<img height={80} src={request?.icons[0]} />
|
||||
<p>
|
||||
Name <b>{request?.name}</b>
|
||||
</p>
|
||||
<p>
|
||||
Description <b>{request?.description}</b>
|
||||
</p>
|
||||
<p>
|
||||
URL: <b>{request?.url}</b>
|
||||
</p>
|
||||
</div>}
|
||||
<hr />
|
||||
<h1> App </h1>
|
||||
<button disabled={!walletKey} onClick={newSession}>New Session</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
@@ -1,97 +1,56 @@
|
||||
import 'react-app-polyfill/ie11';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Session } from '../src/index';
|
||||
import { generatePrivateKey, getPublicKey, nip04, relayInit } from 'nostr-tools'
|
||||
import { Connect, ConnectMessageType, GetPublicKeyResponse, Session } from '../src';
|
||||
import { prepareResponse } from '../src/event';
|
||||
|
||||
|
||||
|
||||
const App = () => {
|
||||
/* useEffect(() => {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
/* const webSK = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
|
||||
const webPK = getPublicKey(webSK);
|
||||
console.log('webPk', webPK);
|
||||
|
||||
const sessionWeb = new Session({
|
||||
target: webPK,
|
||||
relay: 'wss://nostr.vulpem.com',
|
||||
metadata: {
|
||||
name: 'My Website',
|
||||
description: 'lorem ipsum dolor sit amet',
|
||||
url: 'https://vulpem.com',
|
||||
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
|
||||
}
|
||||
});
|
||||
sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => {
|
||||
console.log('paired event', msg);
|
||||
});
|
||||
await sessionWeb.listen(webSK);
|
||||
|
||||
// mobile app (this can be a child key)
|
||||
const sessionMobile = Session.fromConnectURI(sessionWeb.connectURI);// 'nostr://connect?target=...&metadata=...'
|
||||
const mobileSK = "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c";
|
||||
const mobilePK = getPublicKey(mobileSK);
|
||||
console.log('mobilePK', mobilePK);
|
||||
|
||||
// we define the behavior of the mobile app for each requests
|
||||
await sessionMobile.listen(mobileSK);
|
||||
await sessionMobile.pair(mobileSK);
|
||||
|
||||
*/
|
||||
|
||||
})();
|
||||
}, []); */
|
||||
}, []);
|
||||
|
||||
const [walletKey, setWalletKey] = React.useState<{ pk: string; sk: string }>();
|
||||
const [sessionHolder, setSession] = React.useState<Session>();
|
||||
const [request, setRequest] = React.useState<any>();
|
||||
|
||||
|
||||
const newWallet = () => {
|
||||
//this is the wallet public key
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
setWalletKey({ pk, sk });
|
||||
}
|
||||
|
||||
const newSession = async () => {
|
||||
|
||||
const session = new Session({
|
||||
name: 'Auth',
|
||||
description: 'lorem ipsum dolor sit amet',
|
||||
url: 'https://vulpem.com',
|
||||
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
|
||||
});
|
||||
|
||||
await session.startPairing(walletKey?.pk);
|
||||
setSession(session);
|
||||
};
|
||||
|
||||
const listen = async () => {
|
||||
if (!sessionHolder) return;
|
||||
|
||||
// let's query for an event to this wallet pub key
|
||||
const relay = relayInit('wss://nostr.vulpem.com');
|
||||
await relay.connect()
|
||||
|
||||
relay.on('connect', () => {
|
||||
console.log(`wallet: connected to ${relay.url}`)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
console.log(`wallet: failed to connect to ${relay.url}`)
|
||||
})
|
||||
|
||||
let sub = relay.sub([{ kinds: [4] }])
|
||||
// on the receiver side
|
||||
sub.on('event', async (event) => {
|
||||
if (!walletKey) return;
|
||||
|
||||
const mention = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
|
||||
if (mention !== walletKey.pk) return;
|
||||
|
||||
const plaintext = await nip04.decrypt(walletKey?.sk, sessionHolder.pubKey, event.content);
|
||||
console.log('wallet', event.id, event.pubkey, JSON.parse(plaintext));
|
||||
setRequest(JSON.parse(plaintext));
|
||||
})
|
||||
|
||||
sub.on('eose', () => {
|
||||
sub.unsub()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>💸 Wallet</h1>
|
||||
{walletKey && <p> 🔎 Wallet Pub: {walletKey?.pk} </p>}
|
||||
<button onClick={newWallet}>Create Wallet</button>
|
||||
<button disabled={!sessionHolder} onClick={listen}>Listen</button>
|
||||
{request && <div>
|
||||
<h1>📨 Incoming Request</h1>
|
||||
<img height={80} src={request?.icons[0]} />
|
||||
<p>
|
||||
Name <b>{request?.name}</b>
|
||||
</p>
|
||||
<p>
|
||||
Description <b>{request?.description}</b>
|
||||
</p>
|
||||
<p>
|
||||
URL: <b>{request?.url}</b>
|
||||
</p>
|
||||
</div>}
|
||||
<hr />
|
||||
<h1> App </h1>
|
||||
<button disabled={!walletKey} onClick={newSession}>New Session</button>
|
||||
<h1>Check you console</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
)
|
||||
}
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
|
||||
3
jest.config.js
Normal file
3
jest.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||
};
|
||||
@@ -48,6 +48,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@size-limit/preset-small-lib": "^8.1.0",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"husky": "^8.0.2",
|
||||
|
||||
148
src/connect.ts
Normal file
148
src/connect.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Event, nip04, relayInit } from "nostr-tools";
|
||||
import { prepareRequest, prepareResponse } from "./event";
|
||||
import { Session, SessionStatus } from "./session";
|
||||
|
||||
export interface ConnectMessage {
|
||||
type: ConnectMessageType,
|
||||
value?: any
|
||||
requestID?: string,
|
||||
}
|
||||
|
||||
export enum ConnectMessageType {
|
||||
PAIRED = 'paired',
|
||||
UNPAIRED = 'unpaired',
|
||||
GET_PUBLIC_KEY_REQUEST = 'getPublicKeyRequest',
|
||||
GET_PUBLIC_KEY_RESPONSE = 'getPublicKeyResponse',
|
||||
}
|
||||
|
||||
export interface PairingACK extends ConnectMessage {
|
||||
type: ConnectMessageType.PAIRED,
|
||||
value: {
|
||||
pubkey: string,
|
||||
}
|
||||
}
|
||||
|
||||
export interface PairingNACK extends ConnectMessage {
|
||||
type: ConnectMessageType.UNPAIRED
|
||||
}
|
||||
|
||||
export interface GetPublicKeyRequest extends ConnectMessage {
|
||||
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST
|
||||
}
|
||||
|
||||
export interface GetPublicKeyResponse extends ConnectMessage {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
export class Connect {
|
||||
session: Session;
|
||||
private targetPrivateKey: string;
|
||||
|
||||
constructor({
|
||||
session,
|
||||
targetPrivateKey,
|
||||
}: {
|
||||
session: Session;
|
||||
targetPrivateKey: string;
|
||||
}) {
|
||||
this.session = session;
|
||||
this.targetPrivateKey = targetPrivateKey;
|
||||
}
|
||||
|
||||
async sendMessage(message: ConnectMessage): Promise<ConnectMessage> {
|
||||
if (this.session.status !== SessionStatus.PAIRED) throw new Error('Session is not paired');
|
||||
if (!this.session.target) throw new Error('Target is required');
|
||||
if (!this.session.remote) throw new Error('Remote is required');
|
||||
|
||||
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(this.targetPrivateKey, event.pubkey, event.content);
|
||||
console.log('plaintext', plaintext);
|
||||
console.log('requestID', requestID);
|
||||
const payload = JSON.parse(plaintext);
|
||||
if (!payload) return;
|
||||
if (!Object.keys(payload).includes('requestID') || !Object.keys(payload).includes('message')) 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', () => {
|
||||
sub.unsub();
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getPublicKey(): Promise<string> {
|
||||
const response: ConnectMessage = await this.sendMessage({
|
||||
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST
|
||||
});
|
||||
if (response.type !== ConnectMessageType.GET_PUBLIC_KEY_RESPONSE) throw new Error('Invalid response type');
|
||||
return response.value.pubkey;
|
||||
}
|
||||
|
||||
async signEvent(_event: Event): Promise<Event> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async getRelays(): Promise<{ [url: string]: { read: boolean, write: boolean } }> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
nip04 = {
|
||||
encrypt: async (_pubkey: string, _plaintext: string): Promise<string> => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
decrypt: async (_pubkey: string, _ciphertext: string): Promise<string> => {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
async request(_opts: { method: string, params: any }): Promise<any> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
}
|
||||
43
src/event.ts
Normal file
43
src/event.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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 };
|
||||
}
|
||||
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './session';
|
||||
export * from './connect';
|
||||
103
src/index.tsx
103
src/index.tsx
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
generatePrivateKey,
|
||||
getPublicKey,
|
||||
validateEvent,
|
||||
verifySignature,
|
||||
signEvent,
|
||||
getEventHash,
|
||||
Event,
|
||||
relayInit,
|
||||
nip04
|
||||
} from 'nostr-tools'
|
||||
|
||||
const KEY_MANAGER = "40852bb98e00d3e242d6a9717ede49574168ecef83841ce88f56699cc83f3085";
|
||||
|
||||
export class Session {
|
||||
request: {
|
||||
name: string;
|
||||
description: any;
|
||||
url: any;
|
||||
icons: any;
|
||||
};
|
||||
pubKey: string;
|
||||
private secretKey: string;
|
||||
|
||||
|
||||
constructor({
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
icons,
|
||||
}: {
|
||||
name: string,
|
||||
description?: string
|
||||
url: string,
|
||||
icons: string[],
|
||||
}) {
|
||||
// Generate an ephemeral identity for this session in the app
|
||||
let sk = generatePrivateKey()
|
||||
let pk = getPublicKey(sk)
|
||||
this.secretKey = sk;
|
||||
this.pubKey = pk;
|
||||
|
||||
this.request = {
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
icons,
|
||||
};
|
||||
console.log(`Session with pubKey: ${this.pubKey}`)
|
||||
}
|
||||
|
||||
async startPairing(walletPubKey: string = KEY_MANAGER) {
|
||||
const payload = JSON.stringify(this.request);
|
||||
const cipherText = await nip04.encrypt(this.secretKey, walletPubKey, payload);
|
||||
let event: Event = {
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: this.pubKey,
|
||||
tags: [['p', walletPubKey]],
|
||||
content: cipherText,
|
||||
}
|
||||
await this.sendEvent(event)
|
||||
}
|
||||
|
||||
async sendEvent(event: Event) {
|
||||
const id = getEventHash(event)
|
||||
const sig = signEvent(event, this.secretKey)
|
||||
|
||||
const signedEvent = { ...event, id, sig };
|
||||
let ok = validateEvent(signedEvent)
|
||||
let veryOk = verifySignature(signedEvent)
|
||||
console.log(ok, veryOk)
|
||||
if (!ok || !veryOk) {
|
||||
throw new Error('Event is not valid')
|
||||
}
|
||||
|
||||
const relay = relayInit('wss://nostr.vulpem.com')
|
||||
|
||||
await relay.connect();
|
||||
relay.on('connect', () => {
|
||||
console.log(`connected to ${relay.url}`)
|
||||
})
|
||||
relay.on('error', () => {
|
||||
throw new Error(`failed to connect to ${relay.url}`);
|
||||
})
|
||||
|
||||
|
||||
let pub = relay.publish(signedEvent);
|
||||
console.log(signedEvent);
|
||||
|
||||
pub.on('ok', () => {
|
||||
console.log(`${relay.url} has accepted our event`)
|
||||
})
|
||||
pub.on('seen', () => {
|
||||
console.log(`we saw the event on ${relay.url}`)
|
||||
})
|
||||
pub.on('failed', (reason: any) => {
|
||||
console.error(reason);
|
||||
console.log(`failed to publish to ${relay.url}: ${reason}`)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
314
src/request.js
Normal file
314
src/request.js
Normal file
@@ -0,0 +1,314 @@
|
||||
"use strict";
|
||||
var __assign = (this && this.__assign) || function () {
|
||||
__assign = Object.assign || function(t) {
|
||||
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
||||
s = arguments[i];
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
||||
t[p] = s[p];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
return __assign.apply(this, arguments);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
||||
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
exports.__esModule = true;
|
||||
exports.prepareEvent = exports.prepareResponse = exports.prepareRequest = exports.randomID = exports.now = exports.NostrRPCServer = exports.NostrRPC = void 0;
|
||||
var nostr_tools_1 = require("nostr-tools");
|
||||
var NostrRPC = /** @class */ (function () {
|
||||
function NostrRPC(opts) {
|
||||
this.relay = (0, nostr_tools_1.relayInit)(opts.relay || "wss://nostr.vulpem.com");
|
||||
this.target = opts.target;
|
||||
this.self = {
|
||||
pubkey: (0, nostr_tools_1.getPublicKey)(opts.secretKey),
|
||||
secret: opts.secretKey
|
||||
};
|
||||
}
|
||||
NostrRPC.prototype.call = function (_a) {
|
||||
var _b = _a.id, id = _b === void 0 ? randomID() : _b, method = _a.method, _c = _a.params, params = _c === void 0 ? [] : _c;
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var body, event;
|
||||
var _this = this;
|
||||
return __generator(this, function (_d) {
|
||||
switch (_d.label) {
|
||||
case 0:
|
||||
// connect to relay
|
||||
return [4 /*yield*/, this.relay.connect()];
|
||||
case 1:
|
||||
// connect to relay
|
||||
_d.sent();
|
||||
this.relay.on('error', function () { throw new Error("failed to connect to ".concat(_this.relay.url)); });
|
||||
body = prepareRequest(id, method, params);
|
||||
return [4 /*yield*/, prepareEvent(this.self.secret, this.target, body)];
|
||||
case 2:
|
||||
event = _d.sent();
|
||||
// send request via relay
|
||||
return [4 /*yield*/, new Promise(function (resolve, reject) {
|
||||
var pub = _this.relay.publish(event);
|
||||
pub.on('failed', reject);
|
||||
pub.on('seen', resolve);
|
||||
})];
|
||||
case 3:
|
||||
// send request via relay
|
||||
_d.sent();
|
||||
console.log("sent request to nostr id: ".concat(event.id), { id: id, method: method, params: params });
|
||||
return [2 /*return*/, new Promise(function (resolve, reject) {
|
||||
// waiting for response from remote
|
||||
// TODO: reject after a timeout
|
||||
var sub = _this.relay.sub([{
|
||||
kinds: [4],
|
||||
authors: [_this.target],
|
||||
"#p": [_this.self.pubkey]
|
||||
}]);
|
||||
sub.on('event', function (event) { return __awaiter(_this, void 0, void 0, function () {
|
||||
var plaintext, payload, ignore_1;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
_a.trys.push([0, 2, , 3]);
|
||||
return [4 /*yield*/, nostr_tools_1.nip04.decrypt(this.self.secret, event.pubkey, event.content)];
|
||||
case 1:
|
||||
plaintext = _a.sent();
|
||||
payload = JSON.parse(plaintext);
|
||||
return [3 /*break*/, 3];
|
||||
case 2:
|
||||
ignore_1 = _a.sent();
|
||||
return [2 /*return*/];
|
||||
case 3:
|
||||
// ignore all the events that are not NostrRPCResponse events
|
||||
if (!plaintext)
|
||||
return [2 /*return*/];
|
||||
if (!payload)
|
||||
return [2 /*return*/];
|
||||
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('error') || !Object.keys(payload).includes('result'))
|
||||
return [2 /*return*/];
|
||||
// ignore all the events that are not for this request
|
||||
if (payload.id !== id)
|
||||
return [2 /*return*/];
|
||||
// if the response is an error, reject the promise
|
||||
if (payload.error) {
|
||||
reject(payload.error);
|
||||
}
|
||||
// if the response is a result, resolve the promise
|
||||
if (payload.result) {
|
||||
resolve(payload.result);
|
||||
}
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
sub.on('eose', function () {
|
||||
sub.unsub();
|
||||
});
|
||||
})];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
return NostrRPC;
|
||||
}());
|
||||
exports.NostrRPC = NostrRPC;
|
||||
var NostrRPCServer = /** @class */ (function () {
|
||||
function NostrRPCServer(opts) {
|
||||
this.relay = (0, nostr_tools_1.relayInit)((opts === null || opts === void 0 ? void 0 : opts.relay) || "wss://nostr.vulpem.com");
|
||||
this.self = {
|
||||
pubkey: (0, nostr_tools_1.getPublicKey)(opts.secretKey),
|
||||
secret: opts.secretKey
|
||||
};
|
||||
}
|
||||
NostrRPCServer.prototype.listen = function () {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var sub;
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, this.relay.connect()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, new Promise(function (resolve, reject) {
|
||||
_this.relay.on('connect', resolve);
|
||||
_this.relay.on('error', reject);
|
||||
})];
|
||||
case 2:
|
||||
_a.sent();
|
||||
sub = this.relay.sub([{
|
||||
kinds: [4],
|
||||
"#p": [this.self.pubkey],
|
||||
since: now()
|
||||
}]);
|
||||
sub.on('event', function (event) { return __awaiter(_this, void 0, void 0, function () {
|
||||
var plaintext, payload, ignore_2, response, body, responseEvent;
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
_a.trys.push([0, 2, , 3]);
|
||||
return [4 /*yield*/, nostr_tools_1.nip04.decrypt(this.self.secret, event.pubkey, event.content)];
|
||||
case 1:
|
||||
plaintext = _a.sent();
|
||||
payload = JSON.parse(plaintext);
|
||||
return [3 /*break*/, 3];
|
||||
case 2:
|
||||
ignore_2 = _a.sent();
|
||||
return [2 /*return*/];
|
||||
case 3:
|
||||
// ignore all the events that are not NostrRPCRequest events
|
||||
if (!plaintext)
|
||||
return [2 /*return*/];
|
||||
if (!payload)
|
||||
return [2 /*return*/];
|
||||
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('method') || !Object.keys(payload).includes('params'))
|
||||
return [2 /*return*/];
|
||||
return [4 /*yield*/, this.handleRequest(payload)];
|
||||
case 4:
|
||||
response = _a.sent();
|
||||
body = prepareResponse(response.id, response.result, response.error);
|
||||
return [4 /*yield*/, prepareEvent(this.self.secret, event.pubkey, body)];
|
||||
case 5:
|
||||
responseEvent = _a.sent();
|
||||
console.log('response to be sent', responseEvent);
|
||||
// send response via relay
|
||||
return [4 /*yield*/, new Promise(function (resolve, reject) {
|
||||
var pub = _this.relay.publish(responseEvent);
|
||||
pub.on('failed', reject);
|
||||
pub.on('seen', function () { return resolve(); });
|
||||
})];
|
||||
case 6:
|
||||
// send response via relay
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
sub.on('eose', function () {
|
||||
sub.unsub();
|
||||
});
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
NostrRPCServer.prototype.handleRequest = function (request) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var id, method, params, result, error, e_1;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
id = request.id, method = request.method, params = request.params;
|
||||
result = null;
|
||||
error = null;
|
||||
_a.label = 1;
|
||||
case 1:
|
||||
_a.trys.push([1, 3, , 4]);
|
||||
return [4 /*yield*/, this[method].apply(this, params)];
|
||||
case 2:
|
||||
result = _a.sent();
|
||||
return [3 /*break*/, 4];
|
||||
case 3:
|
||||
e_1 = _a.sent();
|
||||
if (e_1 instanceof Error) {
|
||||
error = e_1.message;
|
||||
}
|
||||
else {
|
||||
error = 'unknown error';
|
||||
}
|
||||
return [3 /*break*/, 4];
|
||||
case 4: return [2 /*return*/, {
|
||||
id: id,
|
||||
result: result,
|
||||
error: error
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
return NostrRPCServer;
|
||||
}());
|
||||
exports.NostrRPCServer = NostrRPCServer;
|
||||
function now() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
exports.now = now;
|
||||
function randomID() {
|
||||
return Math.random().toString().slice(2);
|
||||
}
|
||||
exports.randomID = randomID;
|
||||
function prepareRequest(id, method, params) {
|
||||
return JSON.stringify({
|
||||
id: id,
|
||||
method: method,
|
||||
params: params
|
||||
});
|
||||
}
|
||||
exports.prepareRequest = prepareRequest;
|
||||
function prepareResponse(id, result, error) {
|
||||
return JSON.stringify({
|
||||
id: id,
|
||||
result: result,
|
||||
error: error
|
||||
});
|
||||
}
|
||||
exports.prepareResponse = prepareResponse;
|
||||
function prepareEvent(secretKey, pubkey, content) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var cipherText, event, id, sig, signedEvent, ok, veryOk;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, nostr_tools_1.nip04.encrypt(secretKey, pubkey, content)];
|
||||
case 1:
|
||||
cipherText = _a.sent();
|
||||
event = {
|
||||
kind: 4,
|
||||
created_at: now(),
|
||||
pubkey: (0, nostr_tools_1.getPublicKey)(secretKey),
|
||||
tags: [['p', pubkey]],
|
||||
content: cipherText
|
||||
};
|
||||
id = (0, nostr_tools_1.getEventHash)(event);
|
||||
sig = (0, nostr_tools_1.signEvent)(event, secretKey);
|
||||
signedEvent = __assign(__assign({}, event), { id: id, sig: sig });
|
||||
ok = (0, nostr_tools_1.validateEvent)(signedEvent);
|
||||
veryOk = (0, nostr_tools_1.verifySignature)(signedEvent);
|
||||
if (!ok || !veryOk) {
|
||||
throw new Error('Event is not valid');
|
||||
}
|
||||
return [2 /*return*/, signedEvent];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.prepareEvent = prepareEvent;
|
||||
228
src/request.ts
Normal file
228
src/request.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { relayInit, Relay, getEventHash, signEvent, validateEvent, verifySignature, getPublicKey, nip04, Event, Sub } from "nostr-tools";
|
||||
|
||||
|
||||
export interface NostrRPCRequest {
|
||||
id: string;
|
||||
method: string;
|
||||
params: any[];
|
||||
}
|
||||
export interface NostrRPCResponse {
|
||||
id: string;
|
||||
result: any;
|
||||
error: any;
|
||||
}
|
||||
|
||||
|
||||
export class NostrRPC {
|
||||
relay: Relay;
|
||||
self: { pubkey: string, secret: string };
|
||||
target: string;
|
||||
|
||||
constructor(opts: { relay?: string, target: string, secretKey: string }) {
|
||||
this.relay = relayInit(opts.relay || "wss://nostr.vulpem.com");
|
||||
this.target = opts.target;
|
||||
this.self = {
|
||||
pubkey: getPublicKey(opts.secretKey),
|
||||
secret: opts.secretKey,
|
||||
};
|
||||
}
|
||||
async call({
|
||||
id = randomID(),
|
||||
method,
|
||||
params = [],
|
||||
} : {
|
||||
id?: string,
|
||||
method: string,
|
||||
params?: any[],
|
||||
}): Promise<any> {
|
||||
|
||||
// connect to relay
|
||||
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, this.target, body);
|
||||
|
||||
// send request via relay
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pub = this.relay.publish(event);
|
||||
pub.on('failed', reject);
|
||||
pub.on('seen', resolve);
|
||||
});
|
||||
|
||||
console.log(`sent request to nostr id: ${event.id}`, { id, method, params })
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
|
||||
// waiting for response from remote
|
||||
// TODO: reject after a timeout
|
||||
let sub = this.relay.sub([{
|
||||
kinds: [4],
|
||||
authors: [this.target],
|
||||
"#p": [this.self.pubkey],
|
||||
}]);
|
||||
|
||||
sub.on('event', async (event: Event) => {
|
||||
let plaintext;
|
||||
let payload;
|
||||
try {
|
||||
plaintext = await nip04.decrypt(this.self.secret, event.pubkey, event.content);
|
||||
payload = JSON.parse(plaintext);
|
||||
} catch(ignore) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore all the events that are not NostrRPCResponse events
|
||||
if (!plaintext) return;
|
||||
if (!payload) return;
|
||||
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('error') || !Object.keys(payload).includes('result')) return;
|
||||
|
||||
console.log(`received response from nostr id: ${event.id}`, payload)
|
||||
|
||||
// ignore all the events that are not for this request
|
||||
if (payload.id !== id) return;
|
||||
|
||||
// if the response is an error, reject the promise
|
||||
if (payload.error) {
|
||||
reject(payload.error);
|
||||
}
|
||||
|
||||
// if the response is a result, resolve the promise
|
||||
if (payload.result) {
|
||||
resolve(payload.result);
|
||||
}
|
||||
});
|
||||
|
||||
sub.on('eose', () => {
|
||||
sub.unsub();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NostrRPCServer {
|
||||
relay: Relay;
|
||||
self: { pubkey: string, secret: string };
|
||||
[key: string]: any; // TODO: remove this [key: string]
|
||||
|
||||
constructor(opts: { relay?: string, secretKey: string }) {
|
||||
this.relay = relayInit(opts?.relay || "wss://nostr.vulpem.com");
|
||||
this.self = {
|
||||
pubkey: getPublicKey(opts.secretKey),
|
||||
secret: opts.secretKey,
|
||||
};
|
||||
}
|
||||
async listen(): Promise<Sub> {
|
||||
await this.relay.connect();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.relay.on('connect', resolve);
|
||||
this.relay.on('error', reject);
|
||||
});
|
||||
|
||||
let sub = this.relay.sub([{
|
||||
kinds: [4],
|
||||
"#p": [this.self.pubkey],
|
||||
since: now(),
|
||||
}]);
|
||||
|
||||
sub.on('event', async (event: Event) => {
|
||||
let plaintext;
|
||||
let payload;
|
||||
try {
|
||||
plaintext = await nip04.decrypt(this.self.secret, event.pubkey, event.content);
|
||||
payload = JSON.parse(plaintext);
|
||||
} catch(ignore) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore all the events that are not NostrRPCRequest events
|
||||
if (!plaintext) return;
|
||||
if (!payload) return;
|
||||
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('method') || !Object.keys(payload).includes('params')) return;
|
||||
|
||||
// handle request
|
||||
const response = await this.handleRequest(payload);
|
||||
const body = prepareResponse(response.id, response.result, response.error);
|
||||
const responseEvent = await prepareEvent(this.self.secret, event.pubkey, body);
|
||||
|
||||
console.log('response to be sent', responseEvent)
|
||||
// send response via relay
|
||||
const pub = this.relay.publish(responseEvent);
|
||||
pub.on('failed', console.error);
|
||||
});
|
||||
|
||||
sub.on('eose', () => {
|
||||
sub.unsub();
|
||||
});
|
||||
|
||||
return sub;
|
||||
}
|
||||
async handleRequest(request: NostrRPCRequest): Promise<NostrRPCResponse> {
|
||||
const { id, method, params } = request;
|
||||
let result = null;
|
||||
let error = null;
|
||||
try {
|
||||
result = await this[method](...params);
|
||||
} catch(e: any) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'unknown error'
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
result,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function now(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
export function randomID(): string {
|
||||
return Math.random().toString().slice(2);
|
||||
}
|
||||
export function prepareRequest(id: string, method: string, params: any[]): string {
|
||||
return JSON.stringify({
|
||||
id,
|
||||
method: method,
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
export function prepareResponse(id: string, result: any, error: any): string {
|
||||
return JSON.stringify({
|
||||
id: id,
|
||||
result: result,
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
export async function prepareEvent(secretKey: string, pubkey: string, content: string): Promise<Event> {
|
||||
const cipherText = await nip04.encrypt(
|
||||
secretKey,
|
||||
pubkey,
|
||||
content,
|
||||
);
|
||||
|
||||
const event: Event = {
|
||||
kind: 4,
|
||||
created_at: now(),
|
||||
pubkey: getPublicKey(secretKey),
|
||||
tags: [['p', pubkey]],
|
||||
content: cipherText,
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
208
src/session.ts
Normal file
208
src/session.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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,11 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Thing } from '../src';
|
||||
|
||||
describe('it', () => {
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<Thing />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
||||
});
|
||||
116
test/connect.test.ts
Normal file
116
test/connect.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getPublicKey } from "nostr-tools";
|
||||
import { Connect, ConnectMessageType, GetPublicKeyResponse, Session } from "../src/index";
|
||||
|
||||
jest.setTimeout(5000);
|
||||
|
||||
describe('Nostr Connect', () => {
|
||||
it('connect', async () => {
|
||||
let resolvePaired: (arg0: boolean) => void;
|
||||
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({
|
||||
target: webPK,
|
||||
relay: 'wss://nostr.vulpem.com',
|
||||
metadata: {
|
||||
name: 'My Website',
|
||||
description: 'lorem ipsum dolor sit amet',
|
||||
url: 'https://vulpem.com',
|
||||
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
|
||||
}
|
||||
});
|
||||
sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => {
|
||||
expect(msg).toBeDefined();
|
||||
resolvePaired(true);
|
||||
});
|
||||
await sessionWeb.listen(webSK);
|
||||
|
||||
// mobile app (this can be a child key)
|
||||
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
|
||||
sessionMobile.on(ConnectMessageType.GET_PUBLIC_KEY_REQUEST, async () => {
|
||||
const message: GetPublicKeyResponse = {
|
||||
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE,
|
||||
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
|
||||
// The WebApp fetch the public key sending request via session
|
||||
const connect = new Connect({
|
||||
session: sessionWeb,
|
||||
targetPrivateKey: webSK,
|
||||
});
|
||||
const response = await connect.sendMessage({
|
||||
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST,
|
||||
});
|
||||
expect(response).toBeDefined();
|
||||
|
||||
return expect(
|
||||
Promise.all([
|
||||
new Promise(resolve => {
|
||||
resolvePaired = resolve
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
resolveGetPublicKey = resolve
|
||||
})
|
||||
])
|
||||
).resolves.toEqual([true, true])
|
||||
|
||||
/*
|
||||
expect(handler).toBeCalledTimes(1);
|
||||
expect(handler).toBeCalledWith({
|
||||
type: ConnectMessageType.PAIRED,
|
||||
value: {
|
||||
pubkey: mobilePK,
|
||||
}
|
||||
});
|
||||
|
||||
const pubkey = await connect.getPublicKey();
|
||||
expect(pubkey).toBe(mobilePK);
|
||||
const signedEvt = await connect.signEvent({});
|
||||
const relays = await connect.getRelays();
|
||||
|
||||
const plainText = "hello 🌍";
|
||||
const cipherText = await connect.nip04.encrypt(childPK, plainText);
|
||||
const plainText2 = await connect.nip04.decrypt(childPK, cipherText);
|
||||
expect(plainText === plainText2).toBeTruthy();
|
||||
|
||||
await connect.request({
|
||||
method: 'signSchnorr',
|
||||
params: [
|
||||
'0x000000',
|
||||
'0x000000'
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
sessionWeb.on(ConnectMessageType.UNPAIRED, () => {
|
||||
console.log('unpaired');
|
||||
});
|
||||
|
||||
*/
|
||||
});
|
||||
});
|
||||
67
test/request.test.ts
Normal file
67
test/request.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { nip04, Event } from 'nostr-tools';
|
||||
import { NostrRPC, NostrRPCServer, prepareEvent, prepareResponse } from '../src/request';
|
||||
|
||||
class Example extends NostrRPCServer {
|
||||
async ping(): Promise<string> {
|
||||
return 'pong';
|
||||
}
|
||||
}
|
||||
|
||||
jest.setTimeout(10000);
|
||||
|
||||
describe('Nostr RPC', () => {
|
||||
it('starts a server', async () => {
|
||||
const server = new Example({
|
||||
secretKey: "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c",
|
||||
});
|
||||
const sub = await server.listen();
|
||||
sub.on('event', async (event: Event) => {
|
||||
let plaintext;
|
||||
let payload;
|
||||
try {
|
||||
plaintext = await nip04.decrypt(server.self.secret, event.pubkey, event.content);
|
||||
payload = JSON.parse(plaintext);
|
||||
} catch(ignore) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore all the events that are not NostrRPCRequest events
|
||||
if (!plaintext) return;
|
||||
if (!payload) return;
|
||||
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('method') || !Object.keys(payload).includes('params')) return;
|
||||
|
||||
// handle request
|
||||
const response = {
|
||||
id: payload.id,
|
||||
result: "pong",
|
||||
error: null,
|
||||
}
|
||||
const body = prepareResponse(response.id, response.result, response.error);
|
||||
const responseEvent = await prepareEvent(server.self.secret, event.pubkey, body);
|
||||
|
||||
console.log('response to be sent', responseEvent)
|
||||
// send response via relay
|
||||
const pub = server.relay.publish(responseEvent);
|
||||
pub.on('failed', console.error);
|
||||
});
|
||||
|
||||
sub.on('eose', () => {
|
||||
sub.unsub();
|
||||
});
|
||||
|
||||
const client = new NostrRPC({
|
||||
secretKey: "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3",
|
||||
target: server.self.pubkey,
|
||||
});
|
||||
console.log(`from: ` + client.self.pubkey, `to: ` + server.self.pubkey);
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
const result = await client.call({ method: 'ping' });
|
||||
console.log(result);
|
||||
})
|
||||
})
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
18
test/server.js
Normal file
18
test/server.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const {NostrRPCServer} = require('../src/request.js');
|
||||
|
||||
class Example extends NostrRPCServer {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
}
|
||||
async ping() {
|
||||
return 'pong';
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = new Example({
|
||||
secretKey: "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c",
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Server listening on port 3000');
|
||||
}
|
||||
10
test/setupTests.ts
Normal file
10
test/setupTests.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
//setupTests.tsx
|
||||
import crypto from "crypto";
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
(global as any).TextDecoder = TextDecoder;
|
||||
|
||||
Object.defineProperty(global.self, 'crypto', {
|
||||
value: crypto.webcrypto
|
||||
});
|
||||
@@ -1424,7 +1424,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@types/node@*":
|
||||
"@types/node@*", "@types/node@^18.11.18":
|
||||
version "18.11.18"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
|
||||
integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
|
||||
|
||||
Reference in New Issue
Block a user