@m5kdev/backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
@@ -0,0 +1,288 @@
1
+ import * as client from "openid-client";
2
+ import { logger as rootLogger } from "#utils/logger";
3
+ import type { ConnectProvider } from "./connect.types";
4
+
5
+ export interface OAuthState {
6
+ state: string;
7
+ codeVerifier: string;
8
+ codeChallenge: string;
9
+ sessionId: string;
10
+ provider: string;
11
+ }
12
+
13
+ // In-memory store for OAuth state (keyed by sessionId + provider)
14
+ // In production, consider using Redis with TTL
15
+ const oauthStateStore = new Map<string, OAuthState>();
16
+
17
+ const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
18
+
19
+ function getStateKey(sessionId: string, provider: string): string {
20
+ return `${sessionId}:${provider}`;
21
+ }
22
+
23
+ export async function generateOAuthState(sessionId: string, provider: string): Promise<OAuthState> {
24
+ const state = client.randomState();
25
+ const codeVerifier = client.randomPKCECodeVerifier();
26
+ const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
27
+
28
+ const oauthState: OAuthState = {
29
+ state,
30
+ codeVerifier,
31
+ codeChallenge,
32
+ sessionId,
33
+ provider,
34
+ };
35
+
36
+ const key = getStateKey(sessionId, provider);
37
+ oauthStateStore.set(key, oauthState);
38
+
39
+ // Clean up after TTL
40
+ setTimeout(() => {
41
+ oauthStateStore.delete(key);
42
+ }, STATE_TTL_MS);
43
+
44
+ return oauthState;
45
+ }
46
+
47
+ export function getOAuthState(
48
+ sessionId: string,
49
+ provider: string,
50
+ state: string
51
+ ): OAuthState | null {
52
+ const key = getStateKey(sessionId, provider);
53
+ const stored = oauthStateStore.get(key);
54
+
55
+ if (!stored || stored.state !== state) {
56
+ return null;
57
+ }
58
+
59
+ // Clean up after use
60
+ oauthStateStore.delete(key);
61
+ return stored;
62
+ }
63
+
64
+ export async function createConfiguration(
65
+ provider: ConnectProvider
66
+ ): Promise<client.Configuration> {
67
+ // LinkedIn uses client_secret_post (form-encoded body parameters)
68
+ // The library's ClientSecretPost handles this correctly
69
+ const clientAuth = provider.clientSecret
70
+ ? client.ClientSecretPost(provider.clientSecret)
71
+ : client.None();
72
+
73
+ if (provider.issuerConfig) {
74
+ // Use manual issuer config (e.g., LinkedIn) - create Configuration directly
75
+ // LinkedIn doesn't support OpenID Connect discovery
76
+ const serverMetadata: client.ServerMetadata = {
77
+ issuer: provider.issuerConfig.issuer,
78
+ authorization_endpoint: provider.issuerConfig.authorization_endpoint,
79
+ token_endpoint: provider.issuerConfig.token_endpoint,
80
+ ...(provider.issuerConfig.userinfo_endpoint && {
81
+ userinfo_endpoint: provider.issuerConfig.userinfo_endpoint,
82
+ }),
83
+ // LinkedIn JWKS URI for ID token signature verification
84
+ ...(provider.id === "linkedin" && {
85
+ jwks_uri: "https://www.linkedin.com/oauth/openid/jwks",
86
+ }),
87
+ };
88
+
89
+ const clientMetadata: Partial<client.ClientMetadata> = {
90
+ client_id: provider.clientId,
91
+ ...(provider.clientSecret && { client_secret: provider.clientSecret }),
92
+ redirect_uris: [provider.redirectUri],
93
+ };
94
+
95
+ return new client.Configuration(serverMetadata, provider.clientId, clientMetadata, clientAuth);
96
+ }
97
+
98
+ // Auto-discovery from well-known endpoint
99
+ if (!provider.issuerUrl) {
100
+ throw new Error("Provider must have either issuerConfig or issuerUrl");
101
+ }
102
+
103
+ const serverUrl = new URL(provider.issuerUrl);
104
+ return await client.discovery(serverUrl, provider.clientId, undefined, clientAuth);
105
+ }
106
+
107
+ export async function buildAuthorizationUrl(
108
+ provider: ConnectProvider,
109
+ state: OAuthState
110
+ ): Promise<string> {
111
+ const config = await createConfiguration(provider);
112
+
113
+ const parameters: Record<string, string> = {
114
+ scope: provider.scopes.join(" "),
115
+ state: state.state,
116
+ redirect_uri: provider.redirectUri,
117
+ };
118
+
119
+ // Add PKCE parameters only if provider supports it
120
+ if (provider.supportsPKCE !== false) {
121
+ parameters.code_challenge = state.codeChallenge;
122
+ parameters.code_challenge_method = "S256";
123
+ }
124
+
125
+ const url = client.buildAuthorizationUrl(config, parameters);
126
+ return url.toString();
127
+ }
128
+
129
+ export async function exchangeCodeForTokens(
130
+ provider: ConnectProvider,
131
+ code: string,
132
+ codeVerifier: string,
133
+ redirectUri: string,
134
+ state: string
135
+ ): Promise<{
136
+ accessToken: string;
137
+ refreshToken?: string;
138
+ tokenType?: string;
139
+ expiresAt?: Date;
140
+ scope?: string;
141
+ }> {
142
+ const logger = rootLogger.child({ layer: "exchangeCodeForTokens" });
143
+
144
+ try {
145
+ // LinkedIn-specific workaround: Manual token exchange to bypass ID token validation
146
+ // LinkedIn's OpenID Connect ID token has non-standard claim format
147
+ if (provider.id === "linkedin" && provider.issuerConfig) {
148
+ const tokenEndpoint = provider.issuerConfig.token_endpoint;
149
+ const body = new URLSearchParams({
150
+ grant_type: "authorization_code",
151
+ code,
152
+ redirect_uri: provider.redirectUri,
153
+ client_id: provider.clientId,
154
+ client_secret: provider.clientSecret,
155
+ });
156
+
157
+ const response = await fetch(tokenEndpoint, {
158
+ method: "POST",
159
+ headers: {
160
+ "Content-Type": "application/x-www-form-urlencoded",
161
+ },
162
+ body: body.toString(),
163
+ });
164
+
165
+ if (!response.ok) {
166
+ const errorData = await response.json().catch(() => ({}));
167
+ throw new Error(
168
+ `LinkedIn token exchange failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`
169
+ );
170
+ }
171
+
172
+ const tokenData = (await response.json()) as {
173
+ access_token: string;
174
+ refresh_token?: string;
175
+ token_type?: string;
176
+ expires_in?: number;
177
+ scope?: string;
178
+ };
179
+
180
+ return {
181
+ accessToken: tokenData.access_token,
182
+ refreshToken: tokenData.refresh_token,
183
+ tokenType: tokenData.token_type || "bearer",
184
+ expiresAt: tokenData.expires_in
185
+ ? new Date(Date.now() + tokenData.expires_in * 1000)
186
+ : undefined,
187
+ scope: tokenData.scope,
188
+ };
189
+ }
190
+
191
+ // Standard flow for other providers
192
+ const config = await createConfiguration(provider);
193
+ const currentUrl = new URL(redirectUri);
194
+ currentUrl.searchParams.set("code", code);
195
+ currentUrl.searchParams.set("state", state);
196
+
197
+ const checks: {
198
+ pkceCodeVerifier?: string;
199
+ expectedState: string;
200
+ } = {
201
+ expectedState: state,
202
+ };
203
+
204
+ // Only include PKCE verifier if provider supports it
205
+ if (provider.supportsPKCE !== false) {
206
+ checks.pkceCodeVerifier = codeVerifier;
207
+ }
208
+
209
+ const tokenSet = await client.authorizationCodeGrant(config, currentUrl, checks);
210
+
211
+ return {
212
+ accessToken: tokenSet.access_token,
213
+ refreshToken: tokenSet.refresh_token,
214
+ tokenType: tokenSet.token_type,
215
+ expiresAt: tokenSet.expires_in
216
+ ? new Date(Date.now() + tokenSet.expires_in * 1000)
217
+ : undefined,
218
+ scope: tokenSet.scope,
219
+ };
220
+ } catch (error: unknown) {
221
+ // Enhanced error logging for OAuth issues
222
+ logger.error("Token exchange error", { error, provider: provider.id });
223
+
224
+ if (error instanceof Error) {
225
+ const errorMessage = error.message || "Unknown error";
226
+
227
+ // Check if this is an ID token validation error for LinkedIn
228
+ // LinkedIn's ID token may have non-standard claim format, but we can still use the access token
229
+ if (
230
+ provider.id === "linkedin" &&
231
+ errorMessage.includes("JWT claim") &&
232
+ (error as { code?: string }).code === "OAUTH_JWT_CLAIM_COMPARISON_FAILED"
233
+ ) {
234
+ // Try to extract access token from the error response if available
235
+ // This is a workaround for LinkedIn's ID token validation issues
236
+ logger.warn(
237
+ "LinkedIn ID token validation failed, but token exchange may have succeeded. Check if access token is available.",
238
+ { error: errorMessage }
239
+ );
240
+ // Re-throw for now - we need the access token to continue
241
+ // In a production scenario, you might want to manually parse the token response
242
+ throw new Error(
243
+ `LinkedIn ID token validation failed: ${errorMessage}. This is a known issue with LinkedIn's OpenID Connect implementation.`
244
+ );
245
+ }
246
+
247
+ // ResponseBodyError from oauth4webapi has a 'cause' property with the error details
248
+ const responseBodyError = error as { cause?: Record<string, unknown> };
249
+ const errorDetails = responseBodyError.cause;
250
+
251
+ // Extract error and error_description from LinkedIn's response
252
+ const linkedInError = errorDetails?.error as string | undefined;
253
+ const linkedInErrorDescription = errorDetails?.error_description as string | undefined;
254
+
255
+ const fullErrorMessage = linkedInError
256
+ ? `LinkedIn OAuth error: ${linkedInError}${linkedInErrorDescription ? ` - ${linkedInErrorDescription}` : ""}`
257
+ : `Token exchange failed: ${errorMessage}${
258
+ errorDetails ? ` - Details: ${JSON.stringify(errorDetails)}` : ""
259
+ }`;
260
+
261
+ throw new Error(fullErrorMessage);
262
+ }
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ export async function refreshAccessToken(
268
+ provider: ConnectProvider,
269
+ refreshToken: string
270
+ ): Promise<{
271
+ accessToken: string;
272
+ refreshToken?: string;
273
+ tokenType?: string;
274
+ expiresAt?: Date;
275
+ scope?: string;
276
+ }> {
277
+ const config = await createConfiguration(provider);
278
+
279
+ const tokenSet = await client.refreshTokenGrant(config, refreshToken);
280
+
281
+ return {
282
+ accessToken: tokenSet.access_token,
283
+ refreshToken: tokenSet.refresh_token || refreshToken,
284
+ tokenType: tokenSet.token_type,
285
+ expiresAt: tokenSet.expires_in ? new Date(Date.now() + tokenSet.expires_in * 1000) : undefined,
286
+ scope: tokenSet.scope,
287
+ };
288
+ }
@@ -0,0 +1,65 @@
1
+ import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
2
+ import { and, eq, inArray, isNull } from "drizzle-orm";
3
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
4
+ import { ok } from "neverthrow";
5
+ import { BaseTableRepository } from "#modules/base/base.repository";
6
+ import * as connect from "#modules/connect/connect.db";
7
+ import type { ConnectListInputSchema } from "#modules/connect/connect.dto";
8
+
9
+ const schema = { ...connect };
10
+ type Schema = typeof schema;
11
+ type Orm = LibSQLDatabase<Schema>;
12
+
13
+ export type ConnectRow = InferSelectModel<Schema["connect"]>;
14
+ export type ConnectInsert = InferInsertModel<Schema["connect"]>;
15
+
16
+ export class ConnectRepository extends BaseTableRepository<Orm, Schema, Record<string, never>, Schema["connect"]> {
17
+ async list(data: ConnectListInputSchema & { userId: string }, tx?: Orm) {
18
+ return this.throwableAsync(async () => {
19
+ const db = tx ?? this.orm;
20
+ const { ConditionBuilder } = this.helpers;
21
+ const conditions = new ConditionBuilder();
22
+ if (data.providers) conditions.push(inArray(this.schema.connect.provider, data.providers));
23
+ if (data.inactive) conditions.push(isNull(this.schema.connect.revokedAt));
24
+ conditions.push(eq(this.schema.connect.userId, data.userId));
25
+ const rows = await db.select().from(this.schema.connect).where(conditions.join());
26
+ return ok(rows);
27
+ });
28
+ }
29
+
30
+ async upsert(data: ConnectInsert, tx?: Orm) {
31
+ return this.throwableAsync(async () => {
32
+ const db = tx ?? this.orm;
33
+ const [existing] = await db
34
+ .select()
35
+ .from(this.schema.connect)
36
+ .where(
37
+ and(
38
+ eq(this.schema.connect.userId, data.userId),
39
+ eq(this.schema.connect.provider, data.provider),
40
+ eq(this.schema.connect.providerAccountId, data.providerAccountId)
41
+ )
42
+ )
43
+ .limit(1);
44
+
45
+ if (existing) {
46
+ // Update existing
47
+ const [updated] = await db
48
+ .update(this.schema.connect)
49
+ .set({
50
+ ...data,
51
+ updatedAt: new Date(),
52
+ lastRefreshedAt: new Date(),
53
+ })
54
+ .where(eq(this.schema.connect.id, existing.id))
55
+ .returning();
56
+ return ok(updated);
57
+ }
58
+
59
+ // Create new
60
+ const [created] = await db.insert(this.schema.connect).values(data).returning();
61
+ if (!created) return this.error("UNPROCESSABLE_CONTENT");
62
+ return ok(created);
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,76 @@
1
+ import { Router } from "express";
2
+ import type { AuthMiddleware, AuthRequest } from "#modules/auth/auth.middleware";
3
+ import type { ConnectService } from "./connect.service";
4
+
5
+ export function createConnectRouter(
6
+ authMiddleware: AuthMiddleware,
7
+ connectService: ConnectService
8
+ ): Router {
9
+ const connectRouter = Router();
10
+
11
+ connectRouter.get("/:provider/start", authMiddleware, async (req: AuthRequest, res) => {
12
+ const user = req.user;
13
+ const session = req.session;
14
+ if (!user || !session) {
15
+ return res.status(401).json({ message: "Unauthorized" });
16
+ }
17
+ const { provider } = req.params;
18
+ const { redirect } = req.query;
19
+
20
+ const result = await connectService.startAuth(user, session.id, provider);
21
+
22
+ if (result.isErr()) {
23
+ const errorUrl = redirect
24
+ ? `${redirect}?error=${encodeURIComponent(result.error.message)}`
25
+ : `${process.env.VITE_APP_URL || process.env.VITE_SERVER_URL || "/"}?connect_error=${encodeURIComponent(result.error.message)}`;
26
+ return res.redirect(errorUrl);
27
+ }
28
+
29
+ return res.redirect(result.value.url);
30
+ });
31
+
32
+ connectRouter.get("/:provider/callback", authMiddleware, async (req: AuthRequest, res) => {
33
+ const user = req.user;
34
+ const session = req.session;
35
+ if (!user || !session) {
36
+ return res.status(401).json({ message: "Unauthorized" });
37
+ }
38
+ const { provider } = req.params;
39
+ const { code, state, error, error_description } = req.query;
40
+ const { redirect } = req.query;
41
+
42
+ const successUrl =
43
+ redirect && typeof redirect === "string"
44
+ ? redirect
45
+ : `${process.env.VITE_APP_URL || process.env.VITE_SERVER_URL || "/"}?connect_success=true`;
46
+
47
+ // Handle OAuth errors from provider
48
+ if (error) {
49
+ const errorMessage = error_description || error || "OAuth error";
50
+ const errorUrl = `${successUrl}&error=${encodeURIComponent(String(errorMessage))}`;
51
+ return res.redirect(errorUrl);
52
+ }
53
+
54
+ if (!code || !state) {
55
+ const errorUrl = `${successUrl}&error=${encodeURIComponent("Missing code or state")}`;
56
+ return res.redirect(errorUrl);
57
+ }
58
+
59
+ const result = await connectService.handleCallback(
60
+ user,
61
+ session.id,
62
+ provider,
63
+ String(code),
64
+ String(state)
65
+ );
66
+
67
+ if (result.isErr()) {
68
+ const errorUrl = `${successUrl}&error=${encodeURIComponent(result.error.message)}`;
69
+ return res.redirect(errorUrl);
70
+ }
71
+
72
+ return res.redirect(`${successUrl}&provider=${provider}`);
73
+ });
74
+
75
+ return connectRouter;
76
+ }
@@ -0,0 +1,171 @@
1
+ import { err, ok } from "neverthrow";
2
+ import type { User } from "#modules/auth/auth.lib";
3
+ import type { ServerResultAsync } from "#modules/base/base.dto";
4
+ import { BaseService } from "#modules/base/base.service";
5
+ import type {
6
+ ConnectDeleteInputSchema,
7
+ ConnectListInputSchema,
8
+ } from "#modules/connect/connect.dto";
9
+ import {
10
+ buildAuthorizationUrl,
11
+ exchangeCodeForTokens,
12
+ generateOAuthState,
13
+ getOAuthState,
14
+ refreshAccessToken,
15
+ } from "./connect.oauth";
16
+ import type { ConnectRepository } from "./connect.repository";
17
+ import type { ConnectProvider } from "./connect.types";
18
+
19
+ export class ConnectService extends BaseService<{ connect: ConnectRepository }, never> {
20
+ private providers = new Map<string, ConnectProvider>();
21
+
22
+ constructor(repositories: { connect: ConnectRepository }, providers: ConnectProvider[]) {
23
+ super(repositories);
24
+ this.providers = new Map(providers.map((provider) => [provider.id, provider]));
25
+ }
26
+
27
+ getProvider(id: string): ConnectProvider | null {
28
+ return this.providers.get(id) || null;
29
+ }
30
+
31
+ async startAuth(
32
+ _user: User,
33
+ sessionId: string,
34
+ providerId: string
35
+ ): ServerResultAsync<{ url: string }> {
36
+ return this.throwableAsync(async () => {
37
+ const provider = this.getProvider(providerId);
38
+ if (!provider) {
39
+ return this.error("BAD_REQUEST", `Unknown provider: ${providerId}`);
40
+ }
41
+
42
+ const state = await generateOAuthState(sessionId, providerId);
43
+ const url = await buildAuthorizationUrl(provider, state);
44
+
45
+ return ok({ url });
46
+ });
47
+ }
48
+
49
+ async handleCallback(
50
+ user: User,
51
+ sessionId: string,
52
+ providerId: string,
53
+ code: string,
54
+ state: string
55
+ ) {
56
+ return this.throwableAsync(async () => {
57
+ const provider = this.getProvider(providerId);
58
+ if (!provider) {
59
+ return this.error("BAD_REQUEST", `Unknown provider: ${providerId}`);
60
+ }
61
+
62
+ const oauthState = getOAuthState(sessionId, providerId, state);
63
+ if (!oauthState) {
64
+ return this.error("BAD_REQUEST", "Invalid or expired state");
65
+ }
66
+
67
+ // Exchange code for tokens
68
+ // Note: redirectUri must match exactly what was used in authorization request
69
+ const tokens = await exchangeCodeForTokens(
70
+ provider,
71
+ code,
72
+ oauthState.codeVerifier,
73
+ provider.redirectUri,
74
+ state
75
+ );
76
+
77
+ // Fetch profile
78
+ const profile = await provider.mapProfile(tokens.accessToken);
79
+
80
+ // Upsert connection
81
+ const connection = await this.repository.connect.upsert({
82
+ userId: user.id,
83
+ provider: providerId,
84
+ accountType: profile.accountType,
85
+ providerAccountId: profile.providerAccountId,
86
+ handle: profile.handle,
87
+ displayName: profile.displayName,
88
+ avatarUrl: profile.avatarUrl,
89
+ accessToken: tokens.accessToken,
90
+ refreshToken: tokens.refreshToken,
91
+ tokenType: tokens.tokenType || "bearer",
92
+ scope: tokens.scope,
93
+ expiresAt: tokens.expiresAt,
94
+ parentId: profile.parentId,
95
+ metadataJson: profile.metadata ? JSON.stringify(profile.metadata) : null,
96
+ });
97
+
98
+ if (connection.isErr()) {
99
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to save connection", {
100
+ cause: connection.error,
101
+ });
102
+ }
103
+
104
+ return ok(connection.value);
105
+ });
106
+ }
107
+
108
+ async refreshToken(connectionId: string) {
109
+ return this.throwableAsync(async () => {
110
+ const connection = await this.repository.connect.findById(connectionId);
111
+ if (connection.isErr() || !connection.value) {
112
+ return this.error("NOT_FOUND", "Connection not found");
113
+ }
114
+
115
+ const conn = connection.value;
116
+ if (!conn.refreshToken) {
117
+ return this.error("BAD_REQUEST", "No refresh token available");
118
+ }
119
+
120
+ const provider = this.getProvider(conn.provider);
121
+ if (!provider) {
122
+ return this.error("BAD_REQUEST", `Unknown provider: ${conn.provider}`);
123
+ }
124
+
125
+ const tokens = await refreshAccessToken(provider, conn.refreshToken);
126
+
127
+ const updateData: {
128
+ id: string;
129
+ accessToken: string;
130
+ refreshToken?: string | null;
131
+ tokenType?: string | null;
132
+ scope?: string | null;
133
+ expiresAt?: Date | null;
134
+ lastRefreshedAt: Date;
135
+ } = {
136
+ id: conn.id,
137
+ accessToken: tokens.accessToken,
138
+ refreshToken: tokens.refreshToken || null,
139
+ tokenType: tokens.tokenType || conn.tokenType || null,
140
+ scope: tokens.scope || conn.scope || null,
141
+ expiresAt: tokens.expiresAt || null,
142
+ lastRefreshedAt: new Date(),
143
+ };
144
+
145
+ const updated = await this.repository.connect.update(updateData);
146
+
147
+ if (updated.isErr()) {
148
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to update tokens", {
149
+ cause: updated.error,
150
+ });
151
+ }
152
+
153
+ return ok(updated.value);
154
+ });
155
+ }
156
+
157
+ async list(input: ConnectListInputSchema, { user }: { user: User }) {
158
+ return this.repository.connect.list({ userId: user.id, ...input });
159
+ }
160
+
161
+ async delete({ id }: ConnectDeleteInputSchema, { user }: { user: User }) {
162
+ const connection = await this.repository.connect.findById(id);
163
+ if (connection.isOk()) {
164
+ if (connection.value?.userId !== user.id) {
165
+ return this.error("FORBIDDEN", "Not your connection");
166
+ }
167
+ return this.repository.connect.deleteById(id);
168
+ }
169
+ return err(connection.error);
170
+ }
171
+ }
@@ -0,0 +1,26 @@
1
+ import { handleTRPCResult, procedure, router } from "#trpc";
2
+ import {
3
+ connectDeleteInputSchema,
4
+ connectDeleteOutputSchema,
5
+ connectListInputSchema,
6
+ connectListOutputSchema,
7
+ } from "./connect.dto";
8
+ import type { ConnectService } from "./connect.service";
9
+
10
+ export function createConnectTRPC(connectService: ConnectService) {
11
+ return router({
12
+ list: procedure
13
+ .input(connectListInputSchema)
14
+ .output(connectListOutputSchema)
15
+ .query(async ({ ctx, input }) => {
16
+ return handleTRPCResult(await connectService.list(input, ctx));
17
+ }),
18
+
19
+ delete: procedure
20
+ .input(connectDeleteInputSchema)
21
+ .output(connectDeleteOutputSchema)
22
+ .mutation(async ({ ctx, input }) => {
23
+ return handleTRPCResult(await connectService.delete(input, ctx));
24
+ }),
25
+ });
26
+ }
@@ -0,0 +1,27 @@
1
+ export interface ConnectProvider {
2
+ id: string;
3
+ clientId: string;
4
+ clientSecret: string;
5
+ redirectUri: string;
6
+ scopes: string[];
7
+ issuerConfig?: {
8
+ issuer: string;
9
+ authorization_endpoint: string;
10
+ token_endpoint: string;
11
+ userinfo_endpoint?: string;
12
+ };
13
+ issuerUrl?: string; // For auto-discovery
14
+ supportsPKCE?: boolean; // Whether provider supports PKCE (default: true)
15
+ mapProfile: (accessToken: string) => Promise<ConnectProfile>;
16
+ }
17
+
18
+ export interface ConnectProfile {
19
+ providerAccountId: string;
20
+ displayName?: string;
21
+ avatarUrl?: string;
22
+ handle?: string;
23
+ accountType: "user" | "page" | "org" | "channel";
24
+ parentId?: string;
25
+ metadata?: Record<string, unknown>;
26
+ }
27
+
@@ -0,0 +1,15 @@
1
+ import { integer, real, sqliteTable as table, text } from "drizzle-orm/sqlite-core";
2
+ import { v4 as uuidv4 } from "uuid";
3
+
4
+ export const cryptoPayments = table("crypto_payments", {
5
+ id: text("id").primaryKey().$default(uuidv4),
6
+ createdAt: integer("created_at", { mode: "timestamp" })
7
+ .notNull()
8
+ .$default(() => new Date()),
9
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
10
+ address: text("address").notNull(),
11
+ referenceId: text("reference_id").notNull(),
12
+ status: text("status").notNull().default("pending"),
13
+ derivationIndex: integer("derivation_index").notNull(),
14
+ amountExpected: real("amount_expected").notNull(),
15
+ });
@@ -0,0 +1,13 @@
1
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
2
+ import { BaseTableRepository } from "#modules/base/base.repository";
3
+ import * as crypto from "#modules/crypto/crypto.db";
4
+
5
+ const schema = { ...crypto };
6
+ type Schema = typeof schema;
7
+
8
+ export class CryptoRepository extends BaseTableRepository<
9
+ LibSQLDatabase<Schema>,
10
+ Schema,
11
+ Record<string, never>,
12
+ Schema["cryptoPayments"]
13
+ > {}