kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 340 lines 10 kB view raw
1import { apiKey } from "@better-auth/api-key"; 2import { 3 sendMagicLinkEmail, 4 sendOtpEmail, 5 sendWorkspaceInvitationEmail, 6} from "@kaneo/email"; 7import bcrypt from "bcrypt"; 8import { betterAuth } from "better-auth"; 9import { drizzleAdapter } from "better-auth/adapters/drizzle"; 10import { APIError, createAuthMiddleware } from "better-auth/api"; 11import { 12 anonymous, 13 emailOTP, 14 genericOAuth, 15 lastLoginMethod, 16 magicLink, 17 openAPI, 18 organization, 19} from "better-auth/plugins"; 20import { config } from "dotenv-mono"; 21import { eq } from "drizzle-orm"; 22import db, { schema } from "./database"; 23import { publishEvent } from "./events"; 24import { checkRegistrationAllowed } from "./utils/check-registration-allowed"; 25import { generateDemoName } from "./utils/generate-demo-name"; 26 27config(); 28 29const apiUrl = process.env.KANEO_API_URL || "http://localhost:1337"; 30const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 31const isHttps = apiUrl.startsWith("https://"); 32const isCrossSubdomain = (() => { 33 try { 34 const apiHost = new URL(apiUrl).hostname; 35 const clientHost = new URL(clientUrl).hostname; 36 return ( 37 apiHost !== clientHost && 38 apiHost !== "localhost" && 39 clientHost !== "localhost" 40 ); 41 } catch { 42 return false; 43 } 44})(); 45 46const trustedOrigins = [clientUrl]; 47try { 48 const apiOrigin = new URL(apiUrl); 49 const apiOriginString = `${apiOrigin.protocol}//${apiOrigin.host}`; 50 if (!trustedOrigins.includes(apiOriginString)) { 51 trustedOrigins.push(apiOriginString); 52 } 53} catch {} 54 55const baseURLWithoutPath = (() => { 56 try { 57 const url = new URL(apiUrl); 58 return `${url.protocol}//${url.host}`; 59 } catch { 60 return apiUrl.split("/").slice(0, 3).join("/"); // Get protocol://host 61 } 62})(); 63 64if (process.env.AUTH_SECRET && process.env.AUTH_SECRET.length < 32) { 65 console.error( 66 "AUTH_SECRET is less than 32 characters, please generate a new one.", 67 ); 68 process.exit(1); 69} 70 71export const auth = betterAuth({ 72 baseURL: baseURLWithoutPath, 73 trustedOrigins, 74 secret: process.env.AUTH_SECRET || "", 75 basePath: "/api/auth", 76 database: drizzleAdapter(db, { 77 provider: "pg", 78 schema: { 79 ...schema, 80 user: schema.userTable, 81 account: schema.accountTable, 82 session: schema.sessionTable, 83 verification: schema.verificationTable, 84 workspace: schema.workspaceTable, 85 workspace_member: schema.workspaceUserTable, 86 invitation: schema.invitationTable, 87 team: schema.teamTable, 88 teamMember: schema.teamMemberTable, 89 apikey: schema.apikeyTable, 90 }, 91 }), 92 emailAndPassword: { 93 enabled: true, 94 autoSignIn: true, 95 password: { 96 hash: async (password) => { 97 return await bcrypt.hash(password, 10); 98 }, 99 verify: async ({ hash, password }) => { 100 return await bcrypt.compare(password, hash); 101 }, 102 }, 103 }, 104 socialProviders: { 105 github: { 106 clientId: process.env.GITHUB_CLIENT_ID || "", 107 clientSecret: process.env.GITHUB_CLIENT_SECRET || "", 108 scope: ["user:email"], 109 }, 110 google: { 111 clientId: process.env.GOOGLE_CLIENT_ID || "", 112 clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", 113 }, 114 discord: { 115 clientId: process.env.DISCORD_CLIENT_ID || "", 116 clientSecret: process.env.DISCORD_CLIENT_SECRET || "", 117 }, 118 }, 119 plugins: [ 120 ...(process.env.DISABLE_GUEST_ACCESS !== "true" 121 ? [ 122 anonymous({ 123 generateName: async () => generateDemoName(), 124 emailDomainName: "kaneo.app", 125 }), 126 ] 127 : []), 128 lastLoginMethod(), 129 magicLink({ 130 sendMagicLink: async ({ email, url }) => { 131 try { 132 await sendMagicLinkEmail(email, "Login for Kaneo", { 133 magicLink: url, 134 }); 135 } catch (error) { 136 console.error(error); 137 } 138 }, 139 }), 140 emailOTP({ 141 async sendVerificationOTP({ email, otp, type }) { 142 if (type === "sign-in") { 143 await sendOtpEmail(email, "Authentication code for Kaneo", { otp }); 144 } 145 }, 146 }), 147 organization({ 148 // creatorRole: "admin", // maybe will want this "The role of the user who creates the organization." 149 // invitationLimit and other fields like this may be beneficial as well 150 teams: { 151 enabled: true, 152 maximumTeams: 10, 153 allowRemovingAllTeams: false, 154 }, 155 schema: { 156 organization: { 157 modelName: "workspace", 158 additionalFields: { 159 // in metadata 160 description: { 161 type: "string", 162 input: true, 163 required: false, 164 }, 165 }, 166 }, 167 member: { 168 modelName: "workspace_member", 169 fields: { 170 organizationId: "workspaceId", 171 createdAt: "joinedAt", 172 }, 173 }, 174 invitation: { 175 modelName: "invitation", 176 fields: { 177 organizationId: "workspaceId", 178 }, 179 }, 180 team: { 181 modelName: "team", 182 fields: { 183 organizationId: "workspaceId", 184 }, 185 }, 186 }, 187 allowUserToCreateOrganization: true, 188 organizationHooks: { 189 afterCreateOrganization: async ({ organization, user }) => { 190 publishEvent("workspace.created", { 191 workspaceId: organization.id, 192 workspaceName: organization.name, 193 ownerEmail: user.name, 194 ownerId: user.id, 195 }); 196 }, 197 }, 198 async sendInvitationEmail(data) { 199 const inviteLink = `${process.env.KANEO_CLIENT_URL}/invitation/accept/${data.id}`; 200 201 const result = await sendWorkspaceInvitationEmail( 202 data.email, 203 `${data.inviter.user.name} invited you to join ${data.organization.name} on Kaneo`, 204 { 205 inviterEmail: data.inviter.user.email, 206 inviterName: data.inviter.user.name, 207 workspaceName: data.organization.name, 208 invitationLink: inviteLink, 209 to: data.email, 210 }, 211 ); 212 213 if ( 214 result?.success === false && 215 result.reason === "SMTP_NOT_CONFIGURED" 216 ) { 217 console.warn( 218 "Invitation created but email not sent due to SMTP not being configured", 219 ); 220 return; 221 } 222 }, 223 }), 224 genericOAuth({ 225 config: [ 226 { 227 providerId: "custom", 228 clientId: process.env.CUSTOM_OAUTH_CLIENT_ID || "", 229 clientSecret: process.env.CUSTOM_OAUTH_CLIENT_SECRET, 230 authorizationUrl: process.env.CUSTOM_OAUTH_AUTHORIZATION_URL || "", 231 tokenUrl: process.env.CUSTOM_OAUTH_TOKEN_URL || "", 232 userInfoUrl: process.env.CUSTOM_OAUTH_USER_INFO_URL || "", 233 scopes: process.env.CUSTOM_OAUTH_SCOPES?.split(",") 234 .map((s) => s.trim()) 235 .filter(Boolean) || ["profile", "email"], 236 responseType: process.env.CUSTOM_OAUTH_RESPONSE_TYPE || "code", 237 discoveryUrl: process.env.CUSTOM_OAUTH_DISCOVERY_URL || "", 238 pkce: process.env.CUSTOM_AUTH_PKCE !== "false", 239 }, 240 ], 241 }), 242 apiKey({ 243 enableSessionForAPIKeys: true, 244 apiKeyHeaders: "x-api-key", 245 rateLimit: { 246 enabled: true, 247 maxRequests: 100, 248 timeWindow: 60 * 1000, 249 }, 250 }), 251 openAPI(), 252 ], 253 session: { 254 cookieCache: { 255 enabled: true, 256 maxAge: 5 * 60, 257 }, 258 }, 259 databaseHooks: { 260 user: { 261 create: { 262 before: async (user) => { 263 const result = await checkRegistrationAllowed(user.email); 264 if (!result.allowed) { 265 throw new APIError("FORBIDDEN", { 266 message: result.reason, 267 }); 268 } 269 }, 270 }, 271 }, 272 }, 273 hooks: { 274 before: createAuthMiddleware(async (ctx) => { 275 const isSignUpPath = 276 ctx.path === "/sign-up/email" || 277 ctx.path.startsWith("/callback/") || 278 ctx.path.startsWith("/sign-in/social"); 279 280 if (!isSignUpPath) { 281 return; 282 } 283 284 const isRegistrationDisabled = 285 process.env.DISABLE_REGISTRATION === "true"; 286 if (!isRegistrationDisabled) { 287 return; 288 } 289 290 const email = 291 ctx.body?.email || 292 ctx.query?.email || 293 ctx.headers?.get("x-invitation-email"); 294 const invitationId = 295 ctx.body?.invitationId || 296 ctx.query?.invitationId || 297 ctx.headers?.get("x-invitation-id"); 298 299 if (ctx.path === "/sign-up/email") { 300 const result = await checkRegistrationAllowed(email, invitationId); 301 if (!result.allowed) { 302 throw new APIError("FORBIDDEN", { 303 message: result.reason, 304 }); 305 } 306 } 307 }), 308 after: createAuthMiddleware(async (ctx) => { 309 if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) { 310 const newSession = ctx.context.newSession; 311 if (newSession) { 312 const workspaceMember = await db 313 .select({ workspaceId: schema.workspaceUserTable.workspaceId }) 314 .from(schema.workspaceUserTable) 315 .where(eq(schema.workspaceUserTable.userId, newSession.user.id)) 316 .limit(1); 317 318 const activeWorkspaceId = workspaceMember[0]?.workspaceId || null; 319 320 if (activeWorkspaceId) { 321 await db 322 .update(schema.sessionTable) 323 .set({ activeOrganizationId: activeWorkspaceId }) 324 .where(eq(schema.sessionTable.id, newSession.session.id)); 325 } 326 } 327 } 328 }), 329 }, 330 advanced: { 331 defaultCookieAttributes: { 332 // For cross-subdomain auth with HTTPS, use sameSite: "none" with secure: true 333 // For same-domain or HTTP deployments, use sameSite: "lax" with secure: false 334 sameSite: isCrossSubdomain && isHttps ? "none" : "lax", 335 secure: isCrossSubdomain && isHttps, // must be true when sameSite is "none" 336 partitioned: isCrossSubdomain && isHttps, 337 domain: process.env.COOKIE_DOMAIN || undefined, // Optional: e.g., ".andrej.com" for explicit cross-subdomain cookies 338 }, 339 }, 340});