| author | |
| committer | |
| log | 602148f8fe4b5fe369c253022af6bedc27cd553a |
| tree | ae108437a8dc2b3753562ea4ccd7d1f24d93a0f2 |
| parent | 92be60e7c1bae7bdf74482116e09be4cefed1e56 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 100 insertions(+), 85 deletions(-)
apps/desktop/src/ipc/methods/cloudAuth.test.ts+34-42| ... | ... | @@ -1,29 +1,35 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 5 | 3 | import { afterEach } from "vite-plus/test"; |
| 6 | 4 | |
| 7 | 5 | import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts"; |
| 8 | 6 | |
| 9 | 7 | const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY; |
| 8 | const originalFetch = globalThis.fetch; | |
| 10 | 9 | |
| 11 | 10 | const clerkPublishableKey = (hostname: string): string => |
| 12 | 11 | `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`; |
| 13 | 12 | |
| 14 | function makeHttpClientLayer( | |
| 15 | handler: ( | |
| 16 | request: HttpClientRequest.HttpClientRequest, | |
| 17 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>, | |
| 18 | ) { | |
| 19 | return Layer.succeed( | |
| 20 | HttpClient.HttpClient, | |
| 21 | HttpClient.make((request) => handler(request)), | |
| 22 | ); | |
| 23 | } | |
| 13 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; | |
| 14 | ||
| 15 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { | |
| 16 | const calls: Array<FetchCall> = []; | |
| 17 | let responseIndex = 0; | |
| 18 | const fetchFn = ((input, init) => { | |
| 19 | calls.push([input, init ?? {}]); | |
| 20 | const response = responses[responseIndex++]; | |
| 21 | if (!response) { | |
| 22 | return Promise.reject(new Error("Unexpected fetch call")); | |
| 23 | } | |
| 24 | return Promise.resolve(response); | |
| 25 | }) satisfies typeof fetch; | |
| 26 | ||
| 27 | return { fetchFn, calls }; | |
| 28 | }; | |
| 24 | 29 | |
| 25 | 30 | describe("Desktop cloud auth IPC", () => { |
| 26 | 31 | afterEach(() => { |
| 32 | globalThis.fetch = originalFetch; | |
| 27 | 33 | if (originalClerkPublishableKey === undefined) { |
| 28 | 34 | delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY; |
| 29 | 35 | } else { |
| ... | ... | @@ -33,16 +39,8 @@ describe("Desktop cloud auth IPC", () => { |
| 33 | 39 | |
| 34 | 40 | it.effect("preserves Clerk's URL-encoded OAuth form content type", () => { |
| 35 | 41 | const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback"; |
| 36 | let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; | |
| 37 | const layer = makeHttpClientLayer((request) => | |
| 38 | Effect.sync(() => { | |
| 39 | forwardedRequest = request; | |
| 40 | return HttpClientResponse.fromWeb( | |
| 41 | request, | |
| 42 | Response.json({ response: { object: "sign_in_attempt" } }), | |
| 43 | ); | |
| 44 | }), | |
| 45 | ); | |
| 42 | const fetch = recordedFetch(Response.json({ response: { object: "sign_in_attempt" } })); | |
| 43 | globalThis.fetch = fetch.fetchFn; | |
| 46 | 44 | |
| 47 | 45 | return Effect.gen(function* () { |
| 48 | 46 | yield* fetchCloudAuth.handler({ |
| ... | ... | @@ -55,32 +53,25 @@ describe("Desktop cloud auth IPC", () => { |
| 55 | 53 | body, |
| 56 | 54 | }); |
| 57 | 55 | |
| 58 | assert(forwardedRequest !== null); | |
| 56 | const forwardedRequest = fetch.calls[0]; | |
| 57 | assert(forwardedRequest !== undefined); | |
| 58 | const [url, init] = forwardedRequest; | |
| 59 | assert.equal(String(url), "https://example.clerk.accounts.dev/v1/client/sign_ins"); | |
| 60 | assert.equal(init.method, "POST"); | |
| 59 | 61 | assert.equal( |
| 60 | forwardedRequest.headers["content-type"], | |
| 62 | new Headers(init.headers).get("content-type"), | |
| 61 | 63 | "application/x-www-form-urlencoded;charset=UTF-8", |
| 62 | 64 | ); |
| 63 | assert.equal(forwardedRequest.body._tag, "Uint8Array"); | |
| 64 | if (forwardedRequest.body._tag === "Uint8Array") { | |
| 65 | assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body); | |
| 66 | } | |
| 67 | }).pipe(Effect.provide(layer)); | |
| 65 | assert.equal(new TextDecoder().decode(init.body as Uint8Array), body); | |
| 66 | }); | |
| 68 | 67 | }); |
| 69 | 68 | |
| 70 | 69 | it.effect( |
| 71 | 70 | "allows the custom Clerk Frontend API host encoded by the configured publishable key", |
| 72 | 71 | () => { |
| 73 | 72 | process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); |
| 74 | let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; | |
| 75 | const layer = makeHttpClientLayer((request) => | |
| 76 | Effect.sync(() => { | |
| 77 | forwardedRequest = request; | |
| 78 | return HttpClientResponse.fromWeb( | |
| 79 | request, | |
| 80 | Response.json({ response: { object: "client" } }), | |
| 81 | ); | |
| 82 | }), | |
| 83 | ); | |
| 73 | const fetch = recordedFetch(Response.json({ response: { object: "client" } })); | |
| 74 | globalThis.fetch = fetch.fetchFn; | |
| 84 | 75 | |
| 85 | 76 | return Effect.gen(function* () { |
| 86 | 77 | yield* fetchCloudAuth.handler({ |
| ... | ... | @@ -89,9 +80,10 @@ describe("Desktop cloud auth IPC", () => { |
| 89 | 80 | headers: {}, |
| 90 | 81 | }); |
| 91 | 82 | |
| 92 | assert(forwardedRequest !== null); | |
| 93 | assert.equal(forwardedRequest.url.toString(), "https://clerk.t3.codes/v1/client"); | |
| 94 | }).pipe(Effect.provide(layer)); | |
| 83 | const forwardedRequest = fetch.calls[0]; | |
| 84 | assert(forwardedRequest !== undefined); | |
| 85 | assert.equal(String(forwardedRequest[0]), "https://clerk.t3.codes/v1/client"); | |
| 86 | }); | |
| 95 | 87 | }, |
| 96 | 88 | ); |
| 97 | 89 |
apps/desktop/src/ipc/methods/cloudAuth.ts+66-43| ... | ... | @@ -8,9 +8,11 @@ import { |
| 8 | 8 | } from "@t3tools/shared/relayAuth"; |
| 9 | 9 | import * as Data from "effect/Data"; |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | import { identity } from "effect/Function"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 11 | 13 | import * as Option from "effect/Option"; |
| 12 | 14 | import * as Schema from "effect/Schema"; |
| 13 | import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 15 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 14 | 16 | |
| 15 | 17 | import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; |
| 16 | 18 | import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; |
| ... | ... | @@ -52,6 +54,68 @@ export function validateClerkFrontendApiUrl(rawUrl: string): URL { |
| 52 | 54 | return url; |
| 53 | 55 | } |
| 54 | 56 | |
| 57 | function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInputSchema.Type) { | |
| 58 | return Effect.gen(function* () { | |
| 59 | const method = (input.method ?? "GET") as "GET" | "POST"; | |
| 60 | const headers = new Headers(input.headers); | |
| 61 | const response = yield* HttpClientRequest.make(method)(url).pipe( | |
| 62 | HttpClientRequest.setHeaders(headers), | |
| 63 | input.body === undefined | |
| 64 | ? identity | |
| 65 | : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), | |
| 66 | HttpClient.execute, | |
| 67 | Effect.mapError( | |
| 68 | (cause) => | |
| 69 | new DesktopCloudAuthFetchError({ | |
| 70 | reason: "Desktop cloud auth fetch failed to execute.", | |
| 71 | cause, | |
| 72 | }), | |
| 73 | ), | |
| 74 | ); | |
| 75 | ||
| 76 | const body = yield* response.text.pipe( | |
| 77 | Effect.mapError( | |
| 78 | (cause) => | |
| 79 | new DesktopCloudAuthFetchError({ | |
| 80 | reason: "Desktop cloud auth fetch response could not be read.", | |
| 81 | cause, | |
| 82 | }), | |
| 83 | ), | |
| 84 | ); | |
| 85 | ||
| 86 | return { | |
| 87 | ok: response.status >= 200 && response.status < 300, | |
| 88 | status: response.status, | |
| 89 | statusText: "", | |
| 90 | headers: response.headers, | |
| 91 | body, | |
| 92 | }; | |
| 93 | }); | |
| 94 | } | |
| 95 | ||
| 96 | const electronNetFetchLayer = Layer.unwrap( | |
| 97 | Effect.gen(function* () { | |
| 98 | const electronFetch = yield* Effect.promise(async () => { | |
| 99 | const electron = (await import("electron")) as { | |
| 100 | readonly net?: { readonly fetch?: typeof globalThis.fetch }; | |
| 101 | }; | |
| 102 | return typeof electron.net?.fetch === "function" | |
| 103 | ? electron.net.fetch.bind(electron.net) | |
| 104 | : null; | |
| 105 | }).pipe(Effect.catchCause(() => Effect.succeed(null))); | |
| 106 | ||
| 107 | if (!electronFetch) { | |
| 108 | yield* Effect.logWarning( | |
| 109 | "electron.net.fetch is not available, falling back to global fetch. This may cause unexpected errors.", | |
| 110 | ); | |
| 111 | } | |
| 112 | ||
| 113 | return FetchHttpClient.layer.pipe( | |
| 114 | Layer.provide(Layer.succeed(FetchHttpClient.Fetch, electronFetch ?? globalThis.fetch)), | |
| 115 | ); | |
| 116 | }), | |
| 117 | ); | |
| 118 | ||
| 55 | 119 | export const createCloudAuthRequest = makeIpcMethod({ |
| 56 | 120 | channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL, |
| 57 | 121 | payload: Schema.Void, |
| ... | ... | @@ -108,47 +172,6 @@ export const fetchCloudAuth = makeIpcMethod({ |
| 108 | 172 | }), |
| 109 | 173 | }); |
| 110 | 174 | |
| 111 | const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")( | |
| 112 | url, | |
| 113 | { | |
| 114 | headers: input.headers, | |
| 115 | }, | |
| 116 | ); | |
| 117 | const request = | |
| 118 | input.body === undefined | |
| 119 | ? requestWithoutBody | |
| 120 | : HttpClientRequest.bodyText( | |
| 121 | requestWithoutBody, | |
| 122 | input.body, | |
| 123 | Option.getOrUndefined(Headers.get(requestWithoutBody.headers, "content-type")), | |
| 124 | ); | |
| 125 | ||
| 126 | const response = yield* HttpClient.execute(request).pipe( | |
| 127 | Effect.mapError( | |
| 128 | (cause) => | |
| 129 | new DesktopCloudAuthFetchError({ | |
| 130 | reason: "Desktop cloud auth fetch failed.", | |
| 131 | cause, | |
| 132 | }), | |
| 133 | ), | |
| 134 | ); | |
| 135 | ||
| 136 | const body = yield* response.text.pipe( | |
| 137 | Effect.mapError( | |
| 138 | (cause) => | |
| 139 | new DesktopCloudAuthFetchError({ | |
| 140 | reason: "Desktop cloud auth fetch response could not be read.", | |
| 141 | cause, | |
| 142 | }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | ||
| 146 | return { | |
| 147 | ok: response.status >= 200 && response.status < 300, | |
| 148 | status: response.status, | |
| 149 | statusText: "", | |
| 150 | headers: response.headers, | |
| 151 | body, | |
| 152 | }; | |
| 175 | return yield* executeCloudAuthFetch(url, input).pipe(Effect.provide(electronNetFetchLayer)); | |
| 153 | 176 | }), |
| 154 | 177 | }); |