kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 440 lines 13 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 bearer, 14 deviceAuthorization, 15 emailOTP, 16 genericOAuth, 17 lastLoginMethod, 18 magicLink, 19 openAPI, 20 organization, 21} from "better-auth/plugins"; 22import { config } from "dotenv-mono"; 23import { eq } from "drizzle-orm"; 24import db, { schema } from "./database"; 25import { publishEvent } from "./events"; 26import { checkRegistrationAllowed } from "./utils/check-registration-allowed"; 27import { generateDemoName } from "./utils/generate-demo-name"; 28import { getGithubSsoOAuthCredentials } from "./utils/github-sso-env"; 29 30config(); 31 32const githubSso = getGithubSsoOAuthCredentials(); 33 34const isRegistrationDisabled = process.env.DISABLE_REGISTRATION === "true"; 35const isPasswordRegistrationDisabled = 36 process.env.DISABLE_PASSWORD_REGISTRATION === "true"; 37 38const apiUrl = process.env.KANEO_API_URL || "http://localhost:1337"; 39const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 40const isHttps = apiUrl.startsWith("https://"); 41const isCrossSubdomain = (() => { 42 try { 43 const apiHost = new URL(apiUrl).hostname; 44 const clientHost = new URL(clientUrl).hostname; 45 return ( 46 apiHost !== clientHost && 47 apiHost !== "localhost" && 48 clientHost !== "localhost" 49 ); 50 } catch { 51 return false; 52 } 53})(); 54 55const trustedOrigins = [clientUrl]; 56try { 57 const apiOrigin = new URL(apiUrl); 58 const apiOriginString = `${apiOrigin.protocol}//${apiOrigin.host}`; 59 if (!trustedOrigins.includes(apiOriginString)) { 60 trustedOrigins.push(apiOriginString); 61 } 62} catch {} 63 64const baseURLWithoutPath = (() => { 65 try { 66 const url = new URL(apiUrl); 67 return `${url.protocol}//${url.host}`; 68 } catch { 69 return apiUrl.split("/").slice(0, 3).join("/"); // Get protocol://host 70 } 71})(); 72 73if (process.env.AUTH_SECRET && process.env.AUTH_SECRET.length < 32) { 74 console.error( 75 "AUTH_SECRET is less than 32 characters, please generate a new one.", 76 ); 77 process.exit(1); 78} 79 80async function getUserLocale(email: string) { 81 const [user] = await db 82 .select({ locale: schema.userTable.locale }) 83 .from(schema.userTable) 84 .where(eq(schema.userTable.email, email)) 85 .limit(1); 86 87 return user?.locale ?? null; 88} 89 90function getLocaleKey(locale?: string | null) { 91 return locale?.toLowerCase().startsWith("de") ? "de" : "en"; 92} 93 94function getAuthEmailCopy(locale?: string | null) { 95 return getLocaleKey(locale) === "de" 96 ? { 97 magicLinkSubject: "Anmeldelink fuer Kaneo", 98 otpSubject: "Bestaetigungscode fuer Kaneo", 99 } 100 : { 101 magicLinkSubject: "Login for Kaneo", 102 otpSubject: "Authentication code for Kaneo", 103 }; 104} 105 106function getDeviceAuthClientIds(): Set<string> { 107 const raw = process.env.DEVICE_AUTH_CLIENT_IDS?.trim(); 108 if (raw) { 109 return new Set( 110 raw 111 .split(",") 112 .map((s) => s.trim()) 113 .filter(Boolean), 114 ); 115 } 116 return new Set(["kaneo-cli", "kaneo-mcp"]); 117} 118 119function getDeviceAuthVerificationUri(): string { 120 const base = clientUrl.replace(/\/$/, ""); 121 return `${base}/device`; 122} 123 124function getInvitationEmailSubject( 125 locale: string | null, 126 inviterName: string, 127 workspaceName: string, 128) { 129 return getLocaleKey(locale) === "de" 130 ? `${inviterName} hat dich eingeladen, ${workspaceName} auf Kaneo beizutreten` 131 : `${inviterName} invited you to join ${workspaceName} on Kaneo`; 132} 133 134export const auth = betterAuth({ 135 baseURL: baseURLWithoutPath, 136 trustedOrigins, 137 secret: process.env.AUTH_SECRET || "", 138 basePath: "/api/auth", 139 database: drizzleAdapter(db, { 140 provider: "pg", 141 schema: { 142 ...schema, 143 user: schema.userTable, 144 account: schema.accountTable, 145 session: schema.sessionTable, 146 verification: schema.verificationTable, 147 workspace: schema.workspaceTable, 148 workspace_member: schema.workspaceUserTable, 149 invitation: schema.invitationTable, 150 team: schema.teamTable, 151 teamMember: schema.teamMemberTable, 152 apikey: schema.apikeyTable, 153 deviceCode: schema.deviceCodeTable, 154 }, 155 }), 156 user: { 157 additionalFields: { 158 locale: { 159 type: "string", 160 input: true, 161 required: false, 162 }, 163 }, 164 }, 165 emailAndPassword: { 166 enabled: true, 167 autoSignIn: true, 168 password: { 169 hash: async (password) => { 170 return await bcrypt.hash(password, 10); 171 }, 172 verify: async ({ hash, password }) => { 173 return await bcrypt.compare(password, hash); 174 }, 175 }, 176 }, 177 socialProviders: { 178 github: { 179 clientId: githubSso.clientId, 180 clientSecret: githubSso.clientSecret, 181 scope: ["user:email"], 182 }, 183 google: { 184 clientId: process.env.GOOGLE_CLIENT_ID || "", 185 clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", 186 }, 187 discord: { 188 clientId: process.env.DISCORD_CLIENT_ID || "", 189 clientSecret: process.env.DISCORD_CLIENT_SECRET || "", 190 }, 191 }, 192 plugins: [ 193 ...(process.env.DISABLE_GUEST_ACCESS !== "true" 194 ? [ 195 anonymous({ 196 generateName: async () => generateDemoName(), 197 emailDomainName: "kaneo.app", 198 }), 199 ] 200 : []), 201 lastLoginMethod(), 202 magicLink({ 203 sendMagicLink: async ({ email, url }) => { 204 try { 205 const locale = await getUserLocale(email); 206 const copy = getAuthEmailCopy(locale); 207 await sendMagicLinkEmail(email, copy.magicLinkSubject, { 208 magicLink: url, 209 locale, 210 }); 211 } catch (error) { 212 console.error(error); 213 } 214 }, 215 }), 216 emailOTP({ 217 async sendVerificationOTP({ email, otp, type }) { 218 if (type === "sign-in") { 219 const locale = await getUserLocale(email); 220 const copy = getAuthEmailCopy(locale); 221 await sendOtpEmail(email, copy.otpSubject, { 222 otp, 223 locale, 224 }); 225 } 226 }, 227 }), 228 organization({ 229 // creatorRole: "admin", // maybe will want this "The role of the user who creates the organization." 230 // invitationLimit and other fields like this may be beneficial as well 231 teams: { 232 enabled: true, 233 maximumTeams: 10, 234 allowRemovingAllTeams: false, 235 }, 236 schema: { 237 organization: { 238 modelName: "workspace", 239 additionalFields: { 240 // in metadata 241 description: { 242 type: "string", 243 input: true, 244 required: false, 245 }, 246 }, 247 }, 248 member: { 249 modelName: "workspace_member", 250 fields: { 251 organizationId: "workspaceId", 252 createdAt: "joinedAt", 253 }, 254 }, 255 invitation: { 256 modelName: "invitation", 257 fields: { 258 organizationId: "workspaceId", 259 }, 260 }, 261 team: { 262 modelName: "team", 263 fields: { 264 organizationId: "workspaceId", 265 }, 266 }, 267 }, 268 allowUserToCreateOrganization: true, 269 organizationHooks: { 270 afterCreateOrganization: async ({ organization, user }) => { 271 publishEvent("workspace.created", { 272 workspaceId: organization.id, 273 workspaceName: organization.name, 274 ownerEmail: user.name, 275 ownerId: user.id, 276 }); 277 }, 278 }, 279 async sendInvitationEmail(data) { 280 const inviteLink = `${process.env.KANEO_CLIENT_URL}/invitation/accept/${data.id}`; 281 const locale = await getUserLocale(data.email); 282 283 const result = await sendWorkspaceInvitationEmail( 284 data.email, 285 getInvitationEmailSubject( 286 locale, 287 data.inviter.user.name, 288 data.organization.name, 289 ), 290 { 291 inviterEmail: data.inviter.user.email, 292 inviterName: data.inviter.user.name, 293 locale, 294 workspaceName: data.organization.name, 295 invitationLink: inviteLink, 296 to: data.email, 297 }, 298 ); 299 300 if ( 301 result?.success === false && 302 result.reason === "SMTP_NOT_CONFIGURED" 303 ) { 304 console.warn( 305 "Invitation created but email not sent due to SMTP not being configured", 306 ); 307 return; 308 } 309 }, 310 }), 311 genericOAuth({ 312 config: [ 313 { 314 providerId: "custom", 315 clientId: process.env.CUSTOM_OAUTH_CLIENT_ID || "", 316 clientSecret: process.env.CUSTOM_OAUTH_CLIENT_SECRET, 317 authorizationUrl: process.env.CUSTOM_OAUTH_AUTHORIZATION_URL || "", 318 tokenUrl: process.env.CUSTOM_OAUTH_TOKEN_URL || "", 319 userInfoUrl: process.env.CUSTOM_OAUTH_USER_INFO_URL || "", 320 scopes: process.env.CUSTOM_OAUTH_SCOPES?.split(",") 321 .map((s) => s.trim()) 322 .filter(Boolean) || ["profile", "email"], 323 responseType: process.env.CUSTOM_OAUTH_RESPONSE_TYPE || "code", 324 discoveryUrl: process.env.CUSTOM_OAUTH_DISCOVERY_URL || "", 325 pkce: process.env.CUSTOM_AUTH_PKCE !== "false", 326 }, 327 ], 328 }), 329 bearer(), 330 apiKey({ 331 enableSessionForAPIKeys: true, 332 apiKeyHeaders: "x-api-key", 333 rateLimit: { 334 enabled: true, 335 maxRequests: 100, 336 timeWindow: 60 * 1000, 337 }, 338 }), 339 deviceAuthorization({ 340 verificationUri: getDeviceAuthVerificationUri(), 341 validateClient: async (clientId) => 342 getDeviceAuthClientIds().has(clientId), 343 }), 344 openAPI(), 345 ], 346 session: { 347 cookieCache: { 348 enabled: true, 349 maxAge: 5 * 60, 350 }, 351 }, 352 databaseHooks: { 353 user: { 354 create: { 355 before: async (user) => { 356 const result = await checkRegistrationAllowed(user.email); 357 if (!result.allowed) { 358 throw new APIError("FORBIDDEN", { 359 message: result.reason, 360 }); 361 } 362 }, 363 }, 364 }, 365 }, 366 hooks: { 367 before: createAuthMiddleware(async (ctx) => { 368 const isSignUpPath = 369 ctx.path === "/sign-up/email" || 370 ctx.path.startsWith("/callback/") || 371 ctx.path.startsWith("/sign-in/social"); 372 373 if (!isSignUpPath) { 374 return; 375 } 376 377 if (ctx.path === "/sign-up/email") { 378 if (isPasswordRegistrationDisabled) { 379 throw new APIError("FORBIDDEN", { 380 message: 381 "Password registration is currently disabled. Please use a configured social or OIDC sign-in method.", 382 }); 383 } 384 } 385 386 if (!isRegistrationDisabled) { 387 return; 388 } 389 390 const email = 391 ctx.body?.email || 392 ctx.query?.email || 393 ctx.headers?.get("x-invitation-email"); 394 const invitationId = 395 ctx.body?.invitationId || 396 ctx.query?.invitationId || 397 ctx.headers?.get("x-invitation-id"); 398 399 if (ctx.path === "/sign-up/email") { 400 const result = await checkRegistrationAllowed(email, invitationId); 401 if (!result.allowed) { 402 throw new APIError("FORBIDDEN", { 403 message: result.reason, 404 }); 405 } 406 } 407 }), 408 after: createAuthMiddleware(async (ctx) => { 409 if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) { 410 const newSession = ctx.context.newSession; 411 if (newSession) { 412 const workspaceMember = await db 413 .select({ workspaceId: schema.workspaceUserTable.workspaceId }) 414 .from(schema.workspaceUserTable) 415 .where(eq(schema.workspaceUserTable.userId, newSession.user.id)) 416 .limit(1); 417 418 const activeWorkspaceId = workspaceMember[0]?.workspaceId || null; 419 420 if (activeWorkspaceId) { 421 await db 422 .update(schema.sessionTable) 423 .set({ activeOrganizationId: activeWorkspaceId }) 424 .where(eq(schema.sessionTable.id, newSession.session.id)); 425 } 426 } 427 } 428 }), 429 }, 430 advanced: { 431 defaultCookieAttributes: { 432 // For cross-subdomain auth with HTTPS, use sameSite: "none" with secure: true 433 // For same-domain or HTTP deployments, use sameSite: "lax" with secure: false 434 sameSite: isCrossSubdomain && isHttps ? "none" : "lax", 435 secure: isCrossSubdomain && isHttps, // must be true when sameSite is "none" 436 partitioned: isCrossSubdomain && isHttps, 437 domain: process.env.COOKIE_DOMAIN || undefined, // Optional: e.g., ".andrej.com" for explicit cross-subdomain cookies 438 }, 439 }, 440});