Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: signer ready #23

Merged
merged 22 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/signer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {type MockInstance} from 'vitest';
import * as signerHandlers from './handlers/signer.handlers';
import {Signer, type SignerParameters} from './signer';
import {ICRC29_STATUS} from './types/icrc';
import {JSON_RPC_VERSION_2} from './types/rpc';

describe('Signer', () => {
const mockParameters: SignerParameters = {};
Expand Down Expand Up @@ -61,4 +64,160 @@ describe('Signer', () => {
expect(onMessageListenerSpy).not.toHaveBeenCalled();
});
});

describe('origin and postMessage', () => {
const testId = 'test-123';

let originalOpener: typeof window.opener;

let notifyReadySpy: MockInstance;
let signer: Signer;

let postMessageMock: MockInstance;

beforeEach(() => {
signer = Signer.init(mockParameters);
notifyReadySpy = vi.spyOn(signerHandlers, 'notifyReady');
postMessageMock = vi.fn();
vi.stubGlobal('opener', {postMessage: postMessageMock});
});

afterEach(() => {
signer.disconnect();

window.opener = originalOpener;

vi.clearAllMocks();
vi.restoreAllMocks();
});

it('should use the origin and respond with a post message', () => {
const testOrigin = 'https://hello.com';

const messageEvent = new MessageEvent('message', {
data: {
id: testId,
jsonrpc: JSON_RPC_VERSION_2,
method: ICRC29_STATUS
},
origin: testOrigin
});

window.dispatchEvent(messageEvent);

expect(notifyReadySpy).toHaveBeenCalledWith({
id: testId,
origin: testOrigin
});
});

it('should notify an error if a message from different origin is dispatched', () => {
const testOrigin = 'https://hello.com';
const differentOrigin = 'https://test.com';

const msg = {
data: {
id: testId,
jsonrpc: JSON_RPC_VERSION_2,
method: ICRC29_STATUS
},
origin: testOrigin
};

const messageEvent = new MessageEvent('message', msg);
window.dispatchEvent(messageEvent);

const messageEventDiff = new MessageEvent('message', {...msg, origin: differentOrigin});
window.dispatchEvent(messageEventDiff);

expect(postMessageMock).toHaveBeenCalledWith(
{
jsonrpc: JSON_RPC_VERSION_2,
id: testId,
error: {
code: 500,
message: "The relying party's origin is not allowed to interact with the signer."
}
},
differentOrigin
);
});

it('should reset #walletOrigin to null after disconnect', () => {
const testOrigin = 'https://hello.com';
const differentOrigin = 'https://world.com';

const msg = {
data: {
id: testId,
jsonrpc: JSON_RPC_VERSION_2,
method: ICRC29_STATUS
},
origin: testOrigin
};

const messageEvent = new MessageEvent('message', msg);
window.dispatchEvent(messageEvent);

const messageEventDiff = new MessageEvent('message', {...msg, origin: differentOrigin});

signer.disconnect();

expect(() => {
window.dispatchEvent(messageEventDiff);
}).not.toThrow();
});
});

describe('READY postMessage', () => {
const testId = 'test-123';

let originalOpener: typeof window.opener;

let signer: Signer;

let postMessageMock: MockInstance;

beforeEach(() => {
signer = Signer.init(mockParameters);

postMessageMock = vi.fn();

vi.stubGlobal('opener', {postMessage: postMessageMock});
});

afterEach(() => {
signer.disconnect();

window.opener = originalOpener;

vi.clearAllMocks();
vi.restoreAllMocks();
});

it('should notify READY', () => {
const testOrigin = 'https://hello.com';

const msg = {
data: {
id: testId,
jsonrpc: JSON_RPC_VERSION_2,
method: ICRC29_STATUS
},
origin: testOrigin
};

const messageEvent = new MessageEvent('message', msg);
window.dispatchEvent(messageEvent);

expect(postMessageMock).toHaveBeenCalledWith(
{
jsonrpc: JSON_RPC_VERSION_2,
id: testId,
result: 'ready'
},
testOrigin
);
});
});
});
75 changes: 63 additions & 12 deletions src/signer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type {
IcrcWalletPermissionsRequestType,
IcrcWalletRequestPermissionsRequestType,
IcrcWalletSupportedStandardsRequestType
import {nonNullish} from '@dfinity/utils';
import {SignerErrorCode} from './constants/signer.constants';
import {notifyError, notifyReady} from './handlers/signer.handlers';
import {
IcrcWalletStatusRequest,
type IcrcWalletPermissionsRequestType,
type IcrcWalletRequestPermissionsRequestType,
type IcrcWalletSupportedStandardsRequestType
} from './types/icrc-requests';
import {RpcRequest} from './types/rpc';

/**
* The parameters to initialize a signer.
Expand All @@ -11,16 +16,16 @@ import type {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SignerParameters {}

type SignerMessageEvent = MessageEvent<
Partial<
| IcrcWalletRequestPermissionsRequestType
| IcrcWalletPermissionsRequestType
| IcrcWalletSupportedStandardsRequestType
>
type SignerMessageEventData = Partial<
| IcrcWalletRequestPermissionsRequestType
| IcrcWalletPermissionsRequestType
| IcrcWalletSupportedStandardsRequestType
>;

type SignerMessageEvent = MessageEvent<SignerMessageEventData>;

export class Signer {
readonly #walletOrigin: string | undefined;
#walletOrigin: string | undefined | null;

private constructor(_parameters: SignerParameters) {
window.addEventListener('message', this.onMessageListener);
Expand All @@ -44,11 +49,57 @@ export class Signer {
*/
disconnect = (): void => {
window.removeEventListener('message', this.onMessageListener);
this.#walletOrigin = null;
};

private readonly onMessageListener = (message: SignerMessageEvent): void => {
void this.onMessage(message);
};

private readonly onMessage = async (_message: SignerMessageEvent): Promise<void> => {};
private readonly onMessage = async ({
data: msgData,
origin
}: SignerMessageEvent): Promise<void> => {
// TODO: ignore messages that are not Rpc Requests.
// TODO: assert messages to notify error if methods are not supported.

this.assertAndSetOrigin({msgData, origin});

const {success: isStatusRequest, data} = IcrcWalletStatusRequest.safeParse(msgData);

if (isStatusRequest) {
const {id} = data;
notifyReady({id, origin});
}
};

private assertAndSetOrigin({
msgData,
origin
}: {
origin: string;
msgData: SignerMessageEventData;
}): void {
if (nonNullish(this.#walletOrigin) && this.#walletOrigin !== origin) {
const {data} = RpcRequest.safeParse(msgData);

notifyError({
id: data?.id ?? null,
origin,
error: {
code: SignerErrorCode.ORIGIN_ERROR,
message: `The relying party's origin is not allowed to interact with the signer.`
}
});

return;
}

// We do not reassign the origin with the same value if it is already set. It is not a significant performance win.
if (nonNullish(this.#walletOrigin)) {
return;
}

this.#walletOrigin = origin;
}
}