From da625e5b0dab2df86e0bc970854df811a2a1b20d Mon Sep 17 00:00:00 2001 From: horsefacts Date: Tue, 19 Dec 2023 14:29:32 -0500 Subject: [PATCH] feat: app/wallet clients --- apps/relay/src/handlers.ts | 6 +- apps/relay/src/server.ts | 2 +- .../connect/src/actions/authenticate.test.ts | 68 +++++++++++ packages/connect/src/actions/authenticate.ts | 28 +++++ packages/connect/src/actions/connect.test.ts | 46 ++++++++ packages/connect/src/actions/connect.ts | 24 ++++ packages/connect/src/actions/status.test.ts | 36 ++++++ packages/connect/src/actions/status.ts | 25 ++++ .../src/clients/createAppClient.test.ts | 37 ++++++ .../connect/src/clients/createAppClient.ts | 18 +++ .../connect/src/clients/createClient.test.ts | 46 ++++++++ packages/connect/src/clients/createClient.ts | 18 +++ .../src/clients/createWalletClient.test.ts | 36 ++++++ .../connect/src/clients/createWalletClient.ts | 15 +++ packages/connect/src/clients/index.ts | 13 +++ .../src/clients/transports/http.test.ts | 110 ++++++++++++++++++ .../connect/src/clients/transports/http.ts | 54 +++++++++ packages/connect/src/index.ts | 1 + 18 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 packages/connect/src/actions/authenticate.test.ts create mode 100644 packages/connect/src/actions/authenticate.ts create mode 100644 packages/connect/src/actions/connect.test.ts create mode 100644 packages/connect/src/actions/connect.ts create mode 100644 packages/connect/src/actions/status.test.ts create mode 100644 packages/connect/src/actions/status.ts create mode 100644 packages/connect/src/clients/createAppClient.test.ts create mode 100644 packages/connect/src/clients/createAppClient.ts create mode 100644 packages/connect/src/clients/createClient.test.ts create mode 100644 packages/connect/src/clients/createClient.ts create mode 100644 packages/connect/src/clients/createWalletClient.test.ts create mode 100644 packages/connect/src/clients/createWalletClient.ts create mode 100644 packages/connect/src/clients/index.ts create mode 100644 packages/connect/src/clients/transports/http.test.ts create mode 100644 packages/connect/src/clients/transports/http.ts diff --git a/apps/relay/src/handlers.ts b/apps/relay/src/handlers.ts index d6cde3f..a4b6dd7 100644 --- a/apps/relay/src/handlers.ts +++ b/apps/relay/src/handlers.ts @@ -75,12 +75,12 @@ export async function authenticate(request: FastifyRequest<{ Body: AuthenticateR signature, }); if (update.isOk()) { - reply.send(); + reply.send(update.value); } else { reply.code(500).send({ error: update.error.message }); } } else { - if (channel.error.errCode === "not_found") reply.code(401).send(); + if (channel.error.errCode === "not_found") reply.code(401).send({ error: "Unauthorized " }); reply.code(500).send({ error: channel.error.message }); } } @@ -97,7 +97,7 @@ export async function status(request: FastifyRequest, reply: FastifyReply) { } reply.send(res); } else { - if (channel.error.errCode === "not_found") reply.code(401).send(); + if (channel.error.errCode === "not_found") reply.code(401).send({ error: "Unauthorized" }); reply.code(500).send({ error: channel.error.message }); } } diff --git a/apps/relay/src/server.ts b/apps/relay/src/server.ts index 30b5829..9645593 100644 --- a/apps/relay/src/server.ts +++ b/apps/relay/src/server.ts @@ -63,7 +63,7 @@ export class RelayServer { if (channelToken) { request.channelToken = channelToken; } else { - reply.code(401).send(); + reply.code(401).send({ error: "Unauthorized " }); return; } }); diff --git a/packages/connect/src/actions/authenticate.test.ts b/packages/connect/src/actions/authenticate.test.ts new file mode 100644 index 0000000..7169e96 --- /dev/null +++ b/packages/connect/src/actions/authenticate.test.ts @@ -0,0 +1,68 @@ +import { createWalletClient } from "../clients/createWalletClient"; +import { jest } from "@jest/globals"; + +describe("authenticate", () => { + const client = createWalletClient({ + relayURI: "https://connect.farcaster.xyz", + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const message = "example.com wants you to sign in with your Ethereum account = [...]"; + const signature = "0xabcd1234"; + const fid = 1; + const username = "alice"; + const bio = "I'm a little teapot who didn't fill out my bio"; + const displayName = "Alice Teapot"; + const pfpUrl = "https://example.com/alice.png"; + + const statusResponseDataStub = { + state: "completed", + nonce: "abcd1234", + connectURI: "farcaster://connect?nonce=abcd1234[...]", + message, + signature, + fid, + username, + bio, + displayName, + pfpUrl, + }; + + test("constructs API request", async () => { + const response = new Response(JSON.stringify(statusResponseDataStub)); + const spy = jest.spyOn(global, "fetch").mockResolvedValue(response); + + const res = await client.authenticate({ + channelToken: "some-channel-token", + message, + signature, + fid, + username, + bio, + displayName, + pfpUrl, + }); + + expect(res.response).toEqual(response); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("https://connect.farcaster.xyz/v1/connect/authenticate", { + method: "POST", + body: JSON.stringify({ + message, + signature, + fid, + username, + bio, + displayName, + pfpUrl, + }), + headers: { + "Content-Type": "application/json", + Authorization: "Bearer some-channel-token", + }, + }); + }); +}); diff --git a/packages/connect/src/actions/authenticate.ts b/packages/connect/src/actions/authenticate.ts new file mode 100644 index 0000000..a6e51a6 --- /dev/null +++ b/packages/connect/src/actions/authenticate.ts @@ -0,0 +1,28 @@ +import { StatusResponse } from "./status"; +import { post, AsyncHttpResponse } from "../clients/transports/http"; +import { Client } from "../clients/createClient"; + +export interface AuthenticateArgs extends AuthenticateRequest { + channelToken: string; +} + +interface AuthenticateRequest { + message: string; + signature: `0x${string}`; + fid: number; + username: string; + bio: string; + displayName: string; + pfpUrl: string; +} + +export type AuthenticateResponse = StatusResponse; + +const path = "connect/authenticate"; + +export const authenticate = async ( + client: Client, + { channelToken, ...request }: AuthenticateArgs, +): AsyncHttpResponse => { + return post(client, path, request, { authToken: channelToken }); +}; diff --git a/packages/connect/src/actions/connect.test.ts b/packages/connect/src/actions/connect.test.ts new file mode 100644 index 0000000..3e73b87 --- /dev/null +++ b/packages/connect/src/actions/connect.test.ts @@ -0,0 +1,46 @@ +import { createAppClient } from "../clients/createAppClient"; +import { jest } from "@jest/globals"; + +describe("connect", () => { + const client = createAppClient({ + relayURI: "https://connect.farcaster.xyz", + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const siweUri = "https://example.com/login"; + const domain = "example.com"; + const nonce = "abcd1234"; + + const connectResponseDataStub = { + channelToken: "some-channel-token", + state: "completed", + }; + + test("constructs API request", async () => { + const response = new Response(JSON.stringify(connectResponseDataStub)); + const spy = jest.spyOn(global, "fetch").mockResolvedValue(response); + + const res = await client.connect({ + siweUri, + domain, + nonce, + }); + + expect(res.response).toEqual(response); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("https://connect.farcaster.xyz/v1/connect", { + method: "POST", + body: JSON.stringify({ + siweUri, + domain, + nonce, + }), + headers: { + "Content-Type": "application/json", + }, + }); + }); +}); diff --git a/packages/connect/src/actions/connect.ts b/packages/connect/src/actions/connect.ts new file mode 100644 index 0000000..e62be9b --- /dev/null +++ b/packages/connect/src/actions/connect.ts @@ -0,0 +1,24 @@ +import { Client } from "../clients/createClient"; +import { AsyncHttpResponse, post } from "../clients/transports/http"; + +export type ConnectArgs = ConnectRequest; + +interface ConnectRequest { + siweUri: string; + domain: string; + nonce?: string; + notBefore?: string; + expirationTime?: string; + requestId?: string; +} + +export interface ConnectResponse { + channelToken: string; + connectURI: string; +} + +const path = "connect"; + +export const connect = async (client: Client, { ...request }: ConnectArgs): AsyncHttpResponse => { + return post(client, path, request); +}; diff --git a/packages/connect/src/actions/status.test.ts b/packages/connect/src/actions/status.test.ts new file mode 100644 index 0000000..45f399b --- /dev/null +++ b/packages/connect/src/actions/status.test.ts @@ -0,0 +1,36 @@ +import { createAppClient } from "../clients/createAppClient"; +import { jest } from "@jest/globals"; + +describe("status", () => { + const client = createAppClient({ + relayURI: "https://connect.farcaster.xyz", + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const statusResponseDataStub = { + state: "pending", + nonce: "abcd1234", + connectURI: "farcaster://connect?nonce=abcd1234[...]", + }; + + test("constructs API request", async () => { + const response = new Response(JSON.stringify(statusResponseDataStub)); + const spy = jest.spyOn(global, "fetch").mockResolvedValue(response); + + const res = await client.status({ + channelToken: "some-channel-token", + }); + + expect(res.response).toEqual(response); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("https://connect.farcaster.xyz/v1/connect/status", { + headers: { + "Content-Type": "application/json", + Authorization: "Bearer some-channel-token", + }, + }); + }); +}); diff --git a/packages/connect/src/actions/status.ts b/packages/connect/src/actions/status.ts new file mode 100644 index 0000000..ec936d1 --- /dev/null +++ b/packages/connect/src/actions/status.ts @@ -0,0 +1,25 @@ +import { Client } from "../clients/createClient"; +import { get, AsyncHttpResponse } from "../clients/transports/http"; + +export interface StatusArgs { + channelToken: string; +} + +export interface StatusResponse { + state: "pending" | "completed"; + nonce: string; + connectURI: string; + message?: string; + signature?: `0x${string}`; + fid?: number; + username?: string; + bio?: string; + displayName?: string; + pfpUrl?: string; +} + +const path = "connect/status"; + +export const status = async (client: Client, { channelToken }: StatusArgs): AsyncHttpResponse => { + return get(client, path, { authToken: channelToken }); +}; diff --git a/packages/connect/src/clients/createAppClient.test.ts b/packages/connect/src/clients/createAppClient.test.ts new file mode 100644 index 0000000..9c41143 --- /dev/null +++ b/packages/connect/src/clients/createAppClient.test.ts @@ -0,0 +1,37 @@ +import { createAppClient, AppClient } from "./createAppClient"; + +describe("createAppClient", () => { + const config = { + relayURI: "https://connect.farcaster.xyz", + }; + + let appClient: AppClient; + + beforeEach(() => { + appClient = createAppClient(config); + }); + + test("adds version to config", () => { + expect(appClient.config).toEqual({ + relayURI: "https://connect.farcaster.xyz", + version: "v1", + }); + }); + + test("overrides version", () => { + appClient = createAppClient({ + ...config, + version: "v2", + }); + + expect(appClient.config).toEqual({ + relayURI: "https://connect.farcaster.xyz", + version: "v2", + }); + }); + + test("includes app actions", () => { + expect(appClient.connect).toBeDefined(); + expect(appClient.status).toBeDefined(); + }); +}); diff --git a/packages/connect/src/clients/createAppClient.ts b/packages/connect/src/clients/createAppClient.ts new file mode 100644 index 0000000..c39855f --- /dev/null +++ b/packages/connect/src/clients/createAppClient.ts @@ -0,0 +1,18 @@ +import { connect, ConnectArgs, ConnectResponse } from "../actions/connect"; +import { status, StatusArgs, StatusResponse } from "../actions/status"; +import { Client, ClientConfig, createClient } from "./createClient"; +import { AsyncHttpResponse } from "./transports/http"; + +export interface AppClient extends Client { + connect: (args: ConnectArgs) => AsyncHttpResponse; + status: (args: StatusArgs) => AsyncHttpResponse; +} + +export const createAppClient = (config: ClientConfig): AppClient => { + const client = createClient(config); + return { + ...client, + connect: (args: ConnectArgs) => connect(client, args), + status: (args: StatusArgs) => status(client, args), + }; +}; diff --git a/packages/connect/src/clients/createClient.test.ts b/packages/connect/src/clients/createClient.test.ts new file mode 100644 index 0000000..e2d04df --- /dev/null +++ b/packages/connect/src/clients/createClient.test.ts @@ -0,0 +1,46 @@ +import { createClient, Client } from "./createClient"; + +describe("createClient", () => { + const config = { + relayURI: "https://connect.farcaster.xyz", + }; + + let client: Client; + + beforeEach(() => { + client = createClient(config); + }); + + test("adds version to config", () => { + expect(client.config).toEqual({ + relayURI: "https://connect.farcaster.xyz", + version: "v1", + }); + }); + + test("overrides version", () => { + client = createClient({ + ...config, + version: "v2", + }); + + expect(client.config).toEqual({ + relayURI: "https://connect.farcaster.xyz", + version: "v2", + }); + }); + + test("includes no actions", () => { + client = createClient({ + ...config, + version: "v2", + }); + + expect(client).toEqual({ + config: { + relayURI: "https://connect.farcaster.xyz", + version: "v2", + }, + }); + }); +}); diff --git a/packages/connect/src/clients/createClient.ts b/packages/connect/src/clients/createClient.ts new file mode 100644 index 0000000..50486af --- /dev/null +++ b/packages/connect/src/clients/createClient.ts @@ -0,0 +1,18 @@ +export interface ClientConfig { + relayURI: string; + version?: string; +} + +export interface Client { + config: ClientConfig; +} + +const configDefaults = { + version: "v1", +}; + +export const createClient = (config: ClientConfig) => { + return { + config: { ...configDefaults, ...config }, + }; +}; diff --git a/packages/connect/src/clients/createWalletClient.test.ts b/packages/connect/src/clients/createWalletClient.test.ts new file mode 100644 index 0000000..9bc0dcb --- /dev/null +++ b/packages/connect/src/clients/createWalletClient.test.ts @@ -0,0 +1,36 @@ +import { createWalletClient, WalletClient } from "./createWalletClient"; + +describe("createWalletClient", () => { + const config = { + relayURI: "https://connect.farcaster.xyz", + }; + + let walletClient: WalletClient; + + beforeEach(() => { + walletClient = createWalletClient(config); + }); + + test("adds version to config", () => { + expect(walletClient.config).toEqual({ + relayURI: "https://connect.farcaster.xyz", + version: "v1", + }); + }); + + test("overrides version", () => { + walletClient = createWalletClient({ + ...config, + version: "v2", + }); + + expect(walletClient.config).toEqual({ + relayURI: "https://connect.farcaster.xyz", + version: "v2", + }); + }); + + test("includes app actions", () => { + expect(walletClient.authenticate).toBeDefined(); + }); +}); diff --git a/packages/connect/src/clients/createWalletClient.ts b/packages/connect/src/clients/createWalletClient.ts new file mode 100644 index 0000000..7c6cb9a --- /dev/null +++ b/packages/connect/src/clients/createWalletClient.ts @@ -0,0 +1,15 @@ +import { authenticate, AuthenticateArgs, AuthenticateResponse } from "../actions/authenticate"; +import { Client, ClientConfig, createClient } from "./createClient"; +import { AsyncHttpResponse } from "./transports/http"; + +export interface WalletClient extends Client { + authenticate: (args: AuthenticateArgs) => AsyncHttpResponse; +} + +export const createWalletClient = (config: ClientConfig): WalletClient => { + const client = createClient(config); + return { + ...client, + authenticate: (args: AuthenticateArgs) => authenticate(client, args), + }; +}; diff --git a/packages/connect/src/clients/index.ts b/packages/connect/src/clients/index.ts new file mode 100644 index 0000000..2494e6e --- /dev/null +++ b/packages/connect/src/clients/index.ts @@ -0,0 +1,13 @@ +export { createAppClient } from "./createAppClient"; +export { createWalletClient } from "./createWalletClient"; + +export type { AppClient } from "./createAppClient"; +export type { WalletClient } from "./createWalletClient"; +export type { ClientConfig } from "./createClient"; +export type { ConnectArgs, ConnectResponse } from "../actions/connect"; +export type { StatusArgs, StatusResponse } from "../actions/status"; +export type { + AuthenticateArgs, + AuthenticateResponse, +} from "../actions/authenticate"; +export type { AsyncHttpResponse, HttpResponse } from "./transports/http"; diff --git a/packages/connect/src/clients/transports/http.test.ts b/packages/connect/src/clients/transports/http.test.ts new file mode 100644 index 0000000..f5b642b --- /dev/null +++ b/packages/connect/src/clients/transports/http.test.ts @@ -0,0 +1,110 @@ +import { createClient } from "../createClient"; +import { get, post } from "./http"; +import { jest } from "@jest/globals"; + +describe("http", () => { + const config = { + relayURI: "https://connect.farcaster.xyz", + }; + + const client = createClient(config); + + const data = { data: "response stub" }; + let response: Response; + + beforeEach(() => { + response = new Response(JSON.stringify(data)); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("get", () => { + test("returns fetch response", async () => { + jest.spyOn(global, "fetch").mockResolvedValue(response); + + const res = await get(client, "path"); + + expect(res.response).toEqual(response); + }); + + test("returns parsed body data", async () => { + jest.spyOn(global, "fetch").mockResolvedValue(response); + + const res = await get(client, "path"); + + expect(res.data).toEqual(data); + }); + + test("constructs fetch request", async () => { + const spy = jest.spyOn(global, "fetch").mockResolvedValue(response); + + await get(client, "path"); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("https://connect.farcaster.xyz/v1/path", { + headers: { "Content-Type": "application/json" }, + }); + }); + + test("adds optional params", async () => { + const spy = jest.spyOn(global, "fetch").mockResolvedValue(response); + + await get(client, "path", { + authToken: "some-auth-token", + headers: { "X-Some-Header": "some-header-value" }, + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("https://connect.farcaster.xyz/v1/path", { + headers: { + "Content-Type": "application/json", + Authorization: "Bearer some-auth-token", + "X-Some-Header": "some-header-value", + }, + }); + }); + }); + + describe("post", () => { + test("returns fetch response", async () => { + jest.spyOn(global, "fetch").mockResolvedValue(response); + + const requestData = { data: "request stub" }; + const res = await post(client, "path", requestData); + + expect(res.response).toEqual(response); + }); + + test("returns parsed body data", async () => { + jest.spyOn(global, "fetch").mockResolvedValue(response); + + const requestData = { data: "request stub" }; + const res = await post(client, "path", requestData); + + expect(res.data).toEqual(data); + }); + + test("constructs fetch request", async () => { + const spy = jest.spyOn(global, "fetch").mockResolvedValue(response); + + const requestData = { data: "request stub" }; + await post(client, "path", requestData, { + authToken: "some-auth-token", + headers: { "X-Some-Header": "some-header-value" }, + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("https://connect.farcaster.xyz/v1/path", { + method: "POST", + body: JSON.stringify(requestData), + headers: { + "Content-Type": "application/json", + Authorization: "Bearer some-auth-token", + "X-Some-Header": "some-header-value", + }, + }); + }); + }); +}); diff --git a/packages/connect/src/clients/transports/http.ts b/packages/connect/src/clients/transports/http.ts new file mode 100644 index 0000000..8e1faf5 --- /dev/null +++ b/packages/connect/src/clients/transports/http.ts @@ -0,0 +1,54 @@ +import { Client } from "../createClient"; + +export interface HttpOpts { + authToken?: string; + headers?: Record; +} + +export interface HttpResponse { + response: Response; + data: ResponseDataType; +} + +export type AsyncHttpResponse = Promise>; + +export const get = async ( + client: Client, + path: string, + opts?: HttpOpts, +): Promise> => { + const response = await fetch(getURI(client, path), { + headers: getHeaders(opts), + }); + const data: ResponseDataType = await response.json(); + return { response, data }; +}; + +export const post = async ( + client: Client, + path: string, + json: BodyType, + opts?: HttpOpts, +): Promise> => { + const response = await fetch(getURI(client, path), { + method: "POST", + body: JSON.stringify(json), + headers: getHeaders(opts), + }); + const data: ResponseDataType = await response.json(); + return { response, data }; +}; + +const getURI = (client: Client, path: string) => { + return `${client.config.relayURI}/${client.config.version}/${path}`; +}; + +const getHeaders = (opts?: HttpOpts) => { + const headers = { + ...opts?.headers, + }; + if (opts?.authToken) { + headers["Authorization"] = `Bearer ${opts.authToken}`; + } + return { ...headers, "Content-Type": "application/json" }; +}; diff --git a/packages/connect/src/index.ts b/packages/connect/src/index.ts index 32703fc..f9e5d5d 100644 --- a/packages/connect/src/index.ts +++ b/packages/connect/src/index.ts @@ -1,2 +1,3 @@ export * from "./errors"; export * from "./messages"; +export * from "./clients";