Skip to content
Source
Sign inSign up

T3ChatAccounts.ts

166 lines5.9 KB

1import { createClerkClient } from "@clerk/backend";
2import { hmac } from "@noble/hashes/hmac";
3import { sha256 } from "@noble/hashes/sha2";
4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as Base64Url from "effect/encoding/Base64Url";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import * as HttpClientResponse from "effect/http/HttpClientResponse";
14import { RelayT3ChatLink } from "@t3tools/contracts/relay";
15​
16import * as RelayConfiguration from "../Config.ts";
17​
18const LINK_TOKEN_TTL_SECONDS = 10 * 60;
19const T3CHAT_REQUEST_TIMEOUT = "5 seconds";
20​
21const decodeLinkResponse = Schema.decodeUnknownEffect(
22 Schema.Struct({ link: Schema.NullOr(RelayT3ChatLink) }),
23);
24​
25export class T3ChatNotConfigured extends Schema.TaggedError<T3ChatNotConfigured>()(
26 "T3ChatNotConfigured",
27 {},
28) {
29 override get message(): string {
30 return "T3 Chat linking is not configured on this relay";
31 }
33​
34export class ClerkUserLookupFailed extends Schema.TaggedError<ClerkUserLookupFailed>()(
35 "ClerkUserLookupFailed",
36 { cause: Schema.Defect() },
37) {
38 override get message(): string {
39 return "Could not read the Clerk user's primary email";
40 }
42​
43export class T3ChatRequestFailed extends Schema.TaggedError<T3ChatRequestFailed>()(
44 "T3ChatRequestFailed",
45 {
46 operation: Schema.Literals(["get-link", "unlink"]),
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 return `T3 Chat '${this.operation}' request failed`;
52 }
54​
55/**
56 * Bridge to T3 Chat. T3 Chat owns the account link; the relay
57 * signs link requests and asks T3 Chat for the linked account's plan.
58 */
59export class T3ChatAccounts extends Context.Service<
60 T3ChatAccounts,
61 {
62 readonly available: boolean;
63 readonly createLinkUrl: (input: {
64 readonly userId: string;
65 }) => Effect.Effect<string, T3ChatNotConfigured | ClerkUserLookupFailed>;
66 readonly getLink: (input: {
67 readonly userId: string;
68 }) => Effect.Effect<RelayT3ChatLink | null, T3ChatNotConfigured | T3ChatRequestFailed>;
69 readonly unlink: (input: {
70 readonly userId: string;
71 }) => Effect.Effect<void, T3ChatNotConfigured | T3ChatRequestFailed>;
72 }
73>()("t3code-relay/t3chat/T3ChatAccounts") {}
74​
75/** Token format shared with T3 Chat: `<base64url JSON payload>.<base64url HMAC-SHA256>`. */
76export const signLinkToken = (
77 secret: string,
78 payload: { readonly sub: string; readonly email: string; readonly exp: number },
79) => {
80 const encoded = Base64Url.encode(JSON.stringify({ v: 1, ...payload }));
81 const signature = hmac(
82 sha256,
83 new TextEncoder().encode(secret),
84 new TextEncoder().encode(encoded),
85 );
86 return `${encoded}.${Base64Url.encode(signature)}`;
87};
88​
89export const make = Effect.gen(function* () {
90 const config = yield* RelayConfiguration.RelayConfiguration;
91 const client = yield* HttpClient.HttpClient;
92 const settings = config.t3Chat;
93​
94 const requireSettings = settings
95 ? Effect.succeed(settings)
96 : Effect.fail(new T3ChatNotConfigured());
97​
98 // The timeout covers the body read so a stalled response still fails fast.
99 const post = Effect.fn("relay.t3chat.post")(function* (
100 operation: T3ChatRequestFailed["operation"],
101 path: string,
102 userId: string,
103 ) {
104 const { baseUrl, linkSecret } = yield* requireSettings;
105 return yield* HttpClientRequest.post(new URL(path, baseUrl).toString()).pipe(
106 HttpClientRequest.bearerToken(Redacted.value(linkSecret)),
107 HttpClientRequest.bodyJsonUnsafe({ clerkUserId: userId }),
108 client.execute,
109 Effect.flatMap(HttpClientResponse.filterStatusOk),
110 Effect.flatMap((response) => response.json),
111 Effect.timeout(T3CHAT_REQUEST_TIMEOUT),
112 Effect.mapError((cause) => new T3ChatRequestFailed({ operation, cause })),
113 );
114 });
115​
116 const lookupClerkPrimaryEmail = Effect.fn("relay.t3chat.lookup_clerk_primary_email")(function* (
117 userId: string,
118 ) {
119 return yield* Effect.tryPromise({
120 try: async () => {
121 const user = await createClerkClient({
122 secretKey: Redacted.value(config.clerkSecretKey),
123 publishableKey: config.clerkPublishableKey,
124 }).users.getUser(userId);
125 const email = user.primaryEmailAddress?.emailAddress;
126 if (!email) throw new Error("Clerk user has no primary email address.");
127 return email;
128 },
129 catch: (cause) => new ClerkUserLookupFailed({ cause }),
130 }).pipe(
131 Effect.timeoutOrElse({
132 duration: T3CHAT_REQUEST_TIMEOUT,
133 orElse: () => Effect.fail(new ClerkUserLookupFailed({ cause: "timed out" })),
134 }),
135 );
136 });
137​
138 return T3ChatAccounts.of({
139 available: settings !== undefined,
140 createLinkUrl: Effect.fn("relay.t3chat.create_link_url")(function* (input) {
141 const { baseUrl, linkSecret } = yield* requireSettings;
142 const email = yield* lookupClerkPrimaryEmail(input.userId);
143 const now = yield* DateTime.now;
144 const token = signLinkToken(Redacted.value(linkSecret), {
145 sub: input.userId,
146 email,
147 exp: Math.floor(now.epochMilliseconds / 1_000) + LINK_TOKEN_TTL_SECONDS,
148 });
149 const url = new URL("/link/t3code", baseUrl);
150 url.searchParams.set("token", token);
151 return url.toString();
152 }),
153 getLink: Effect.fn("relay.t3chat.get_link")(function* (input) {
154 const body = yield* post("get-link", "/api/t3code/link", input.userId);
155 const decoded = yield* decodeLinkResponse(body).pipe(
156 Effect.mapError((cause) => new T3ChatRequestFailed({ operation: "get-link", cause })),
157 );
158 return decoded.link;
159 }),
160 unlink: Effect.fn("relay.t3chat.unlink")(function* (input) {
161 yield* post("unlink", "/api/t3code/unlink", input.userId);
162 }),
163 });
164});
165​
166export const layer = Layer.effect(T3ChatAccounts, make);