mirror of
https://github.com/nostr-connect/connect.git
synced 2025-12-17 05:04:20 +01:00
add NIP-26 delegation (#7)
* add delegate method * enable ci on PR * enable test on ci * lint * lint * off flaky tests
This commit is contained in:
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
@@ -1,5 +1,9 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
|
name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
|
||||||
@@ -7,7 +11,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: ['14.x', '16.x', '18.x']
|
node: ['14.x', '16.x']
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -25,8 +29,5 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
|
||||||
#- name: Test
|
|
||||||
# run: yarn test --ci --coverage --maxWorkers=2
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build
|
run: yarn build
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useStatePersist } from 'use-state-persist';
|
import { useStatePersist } from 'use-state-persist';
|
||||||
|
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
import { broadcastToRelay, Connect, connectToRelay, ConnectURI } from '@nostr-connect/connect';
|
import { broadcastToRelay, Connect, connectToRelay, ConnectURI, TimeRanges } from '../src/index';
|
||||||
|
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { getEventHash, getPublicKey, Event } from 'nostr-tools';
|
import { getEventHash, getPublicKey, Event, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
|
const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
|
||||||
const connectURI = new ConnectURI({
|
const connectURI = new ConnectURI({
|
||||||
target: getPublicKey(secretKey),
|
target: getPublicKey(secretKey),
|
||||||
relay: 'wss://nostr.vulpem.com',
|
relay: 'wss://nostr.vulpem.com',
|
||||||
metadata: {
|
metadata: {
|
||||||
name: 'Example',
|
name: 'Vulpem',
|
||||||
description: '🔉🔉🔉',
|
description: 'Bitcoin company',
|
||||||
url: 'https://example.com',
|
url: 'https://vulpem.com',
|
||||||
icons: ['https://example.com/icon.png'],
|
icons: ['https://vulpem.com/favicon.ico'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ const App = () => {
|
|||||||
target: pubkey,
|
target: pubkey,
|
||||||
});
|
});
|
||||||
const pk = await connect.getPublicKey();
|
const pk = await connect.getPublicKey();
|
||||||
setGetPublicKeyReply(pk);
|
setGetPublicKeyReply(nip19.npubEncode(pk));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
@@ -116,19 +116,13 @@ const App = () => {
|
|||||||
target: pubkey,
|
target: pubkey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sig = await connect.rpc.call({
|
const sig = await connect.delegate(
|
||||||
target: pubkey,
|
getPublicKey(secretKey),
|
||||||
request: {
|
{
|
||||||
method: 'delegate',
|
kind: 1,
|
||||||
params: [
|
until: TimeRanges.ONE_DAY,
|
||||||
getPublicKey(secretKey),
|
|
||||||
{
|
|
||||||
kind: 0,
|
|
||||||
until: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
setDelegateSig(sig);
|
setDelegateSig(sig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -158,6 +152,7 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appEpehemeralPubKey = nip19.npubEncode(getPublicKey(secretKey));
|
||||||
return (
|
return (
|
||||||
<div className='hero is-fullheight has-background-black has-text-white'>
|
<div className='hero is-fullheight has-background-black has-text-white'>
|
||||||
<section className="container">
|
<section className="container">
|
||||||
@@ -165,7 +160,7 @@ const App = () => {
|
|||||||
<h1 className='title has-text-white'>Nostr Connect Playground</h1>
|
<h1 className='title has-text-white'>Nostr Connect Playground</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className='content'>
|
<div className='content'>
|
||||||
<p className='subtitle is-6 has-text-white'><b>Nostr ID</b> {getPublicKey(secretKey)}</p>
|
<p className='subtitle is-6 has-text-white'><b>Nostr ID (ephemeral)</b> {appEpehemeralPubKey}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='content'>
|
<div className='content'>
|
||||||
<p className='subtitle is-6 has-text-white'><b>Status</b> {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}</p>
|
<p className='subtitle is-6 has-text-white'><b>Status</b> {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}</p>
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ export interface Metadata {
|
|||||||
icons?: string[];
|
icons?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TimeRanges {
|
||||||
|
FIVE_MINS = '5mins',
|
||||||
|
ONE_HR = '1hour',
|
||||||
|
ONE_DAY = '1day',
|
||||||
|
ONE_WEEK = '1week',
|
||||||
|
ONE_MONTH = '1month',
|
||||||
|
ONE_YEAR = '1year',
|
||||||
|
}
|
||||||
|
export const TimeRangeToUnix: Record<TimeRanges, number> = {
|
||||||
|
[TimeRanges.FIVE_MINS]: Math.round(Date.now() / 1000) + 60 * 5,
|
||||||
|
[TimeRanges.ONE_HR]: Math.round(Date.now() / 1000) + 60 * 60,
|
||||||
|
[TimeRanges.ONE_DAY]: Math.round(Date.now() / 1000) + 60 * 60 * 24,
|
||||||
|
[TimeRanges.ONE_WEEK]: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 7,
|
||||||
|
[TimeRanges.ONE_MONTH]: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30,
|
||||||
|
[TimeRanges.ONE_YEAR]: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 365,
|
||||||
|
};
|
||||||
|
|
||||||
export class ConnectURI {
|
export class ConnectURI {
|
||||||
target: string;
|
target: string;
|
||||||
metadata: Metadata;
|
metadata: Metadata;
|
||||||
@@ -209,6 +226,54 @@ export class Connect {
|
|||||||
return signature as string;
|
return signature as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async describe(): Promise<string[]> {
|
||||||
|
if (!this.target) throw new Error('Not connected');
|
||||||
|
|
||||||
|
const response = await this.rpc.call({
|
||||||
|
target: this.target,
|
||||||
|
request: {
|
||||||
|
method: 'describe',
|
||||||
|
params: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delegate(
|
||||||
|
delegatee: string = this.rpc.self.pubkey,
|
||||||
|
conditions: {
|
||||||
|
kind?: number;
|
||||||
|
until?: number | TimeRanges;
|
||||||
|
since?: number | TimeRanges;
|
||||||
|
}
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.target) throw new Error('Not connected');
|
||||||
|
|
||||||
|
if (conditions.until && typeof conditions.until !== 'number') {
|
||||||
|
if (!Object.keys(TimeRangeToUnix).includes(conditions.until))
|
||||||
|
throw new Error(
|
||||||
|
'conditions.until must be either a number or a valid TimeRange'
|
||||||
|
);
|
||||||
|
conditions.until = TimeRangeToUnix[conditions.until];
|
||||||
|
}
|
||||||
|
if (conditions.since && typeof conditions.since !== 'number') {
|
||||||
|
if (!Object.keys(TimeRangeToUnix).includes(conditions.since))
|
||||||
|
throw new Error(
|
||||||
|
'conditions.since must be either a number or a valid TimeRange'
|
||||||
|
);
|
||||||
|
conditions.since = TimeRangeToUnix[conditions.since];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = await this.rpc.call({
|
||||||
|
target: this.target,
|
||||||
|
request: {
|
||||||
|
method: 'delegate',
|
||||||
|
params: [delegatee, conditions],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return sig as string;
|
||||||
|
}
|
||||||
|
|
||||||
async getRelays(): Promise<{
|
async getRelays(): Promise<{
|
||||||
[url: string]: { read: boolean; write: boolean };
|
[url: string]: { read: boolean; write: boolean };
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getPublicKey, signEvent, Event } from 'nostr-tools';
|
import { getPublicKey, signEvent, Event, nip26 } from 'nostr-tools';
|
||||||
import { Connect, ConnectURI, NostrSigner } from '../src';
|
import { Connect, ConnectURI, NostrSigner, TimeRanges } from '../src';
|
||||||
import { sleep } from './utils';
|
import { sleep } from './utils';
|
||||||
|
|
||||||
jest.setTimeout(5000);
|
jest.setTimeout(7500);
|
||||||
|
|
||||||
// web app (this is ephemeral and represents the currention session)
|
// web app (this is ephemeral and represents the currention session)
|
||||||
const webSK =
|
const webSK =
|
||||||
@@ -21,27 +21,34 @@ class MobileHandler extends NostrSigner {
|
|||||||
return getPublicKey(this.self.secret);
|
return getPublicKey(this.self.secret);
|
||||||
}
|
}
|
||||||
async sign_event(event: any): Promise<string> {
|
async sign_event(event: any): Promise<string> {
|
||||||
if (!this.event) throw new Error('No origin event');
|
const sigEvt = signEvent(event, this.self.secret);
|
||||||
|
return Promise.resolve(sigEvt);
|
||||||
// emit event to the UI to show a modal
|
}
|
||||||
this.events.emit('sign_event_request', event);
|
async delegate(
|
||||||
|
delegatee: string,
|
||||||
// wait for the user to approve or reject the request
|
conditions: {
|
||||||
return new Promise((resolve, reject) => {
|
kind?: number;
|
||||||
// listen for user acceptance
|
until?: number;
|
||||||
this.events.on('sign_event_approve', () => {
|
since?: number;
|
||||||
resolve(signEvent(event, this.self.secret));
|
}
|
||||||
});
|
): Promise<string> {
|
||||||
|
const delegateParameters: nip26.Parameters = {
|
||||||
// or rejection
|
pubkey: delegatee,
|
||||||
this.events.on('sign_event_reject', () => {
|
kind: conditions.kind,
|
||||||
reject(new Error('User rejected request'));
|
since: conditions.since || Math.round(Date.now() / 1000),
|
||||||
});
|
until:
|
||||||
});
|
conditions.until ||
|
||||||
|
Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */,
|
||||||
|
};
|
||||||
|
const delegation = nip26.createDelegation(
|
||||||
|
this.self.secret,
|
||||||
|
delegateParameters
|
||||||
|
);
|
||||||
|
return Promise.resolve(delegation.sig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Nostr Connect', () => {
|
describe('ConnectURI', () => {
|
||||||
it('roundtrip connectURI', async () => {
|
it('roundtrip connectURI', async () => {
|
||||||
const connectURI = new ConnectURI({
|
const connectURI = new ConnectURI({
|
||||||
target: `b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4`,
|
target: `b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4`,
|
||||||
@@ -70,6 +77,45 @@ describe('Nostr Connect', () => {
|
|||||||
'https://vulpem.com/1000x860-p-500.422be1bc.png'
|
'https://vulpem.com/1000x860-p-500.422be1bc.png'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Connect', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
// start listening for connect messages on the mobile app
|
||||||
|
const remoteHandler = new MobileHandler({
|
||||||
|
secretKey: mobileSK,
|
||||||
|
relay: 'wss://nostr.vulpem.com',
|
||||||
|
});
|
||||||
|
await remoteHandler.listen();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pubkey and delegation', async () => {
|
||||||
|
// start listening for connect messages on the web app
|
||||||
|
const connect = new Connect({
|
||||||
|
secretKey: webSK,
|
||||||
|
target: mobilePK,
|
||||||
|
});
|
||||||
|
await connect.init();
|
||||||
|
|
||||||
|
sleep(1000);
|
||||||
|
|
||||||
|
// send the get_public_key message to the mobile app from the web
|
||||||
|
const pubkey = await connect.getPublicKey();
|
||||||
|
expect(pubkey).toBe(mobilePK);
|
||||||
|
|
||||||
|
// send the delegate message to the mobile app from the web to ask for permission to sign kind 1 notes on behalf of the user for 5 mins
|
||||||
|
const sig = await connect.delegate(webPK, {
|
||||||
|
kind: 1,
|
||||||
|
until: TimeRanges.FIVE_MINS,
|
||||||
|
});
|
||||||
|
expect(sig).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it.skip('connect', async () => {
|
it.skip('connect', async () => {
|
||||||
const testHandler = jest.fn();
|
const testHandler = jest.fn();
|
||||||
|
|
||||||
@@ -95,31 +141,6 @@ describe('Nostr Connect', () => {
|
|||||||
|
|
||||||
expect(testHandler).toBeCalledTimes(1);
|
expect(testHandler).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns pubkey', async () => {
|
|
||||||
// start listening for connect messages on the mobile app
|
|
||||||
const remoteHandler = new MobileHandler({
|
|
||||||
secretKey: mobileSK,
|
|
||||||
relay: 'wss://nostr.vulpem.com',
|
|
||||||
});
|
|
||||||
await remoteHandler.listen();
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
// start listening for connect messages on the web app
|
|
||||||
const connect = new Connect({
|
|
||||||
secretKey: webSK,
|
|
||||||
target: mobilePK,
|
|
||||||
});
|
|
||||||
await connect.init();
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
// send the get_public_key message to the mobile app from the web
|
|
||||||
const pubkey = await connect.getPublicKey();
|
|
||||||
expect(pubkey).toBe(mobilePK);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('returns a signed event', async () => {
|
it.skip('returns a signed event', async () => {
|
||||||
// start listening for connect messages on the mobile app
|
// start listening for connect messages on the mobile app
|
||||||
const remoteHandler = new MobileHandler({
|
const remoteHandler = new MobileHandler({
|
||||||
@@ -164,36 +185,3 @@ describe('Nostr Connect', () => {
|
|||||||
expect(event).toBeDefined();
|
expect(event).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
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, () => {
|
|
||||||
});
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
Reference in New Issue
Block a user