kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
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});