kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { dirname } from "node:path";
2import { fileURLToPath, pathToFileURL } from "node:url";
3import { serve } from "@hono/node-server";
4import type { Session, User } from "better-auth/types";
5import { eq } from "drizzle-orm";
6import { migrate } from "drizzle-orm/node-postgres/migrator";
7import { Hono } from "hono";
8import { cors } from "hono/cors";
9import { HTTPException } from "hono/http-exception";
10import {
11 describeRoute,
12 openAPIRouteHandler,
13 resolver,
14 validator,
15} from "hono-openapi";
16import * as v from "valibot";
17import activity from "./activity";
18import { auth } from "./auth";
19import column from "./column";
20import comment from "./comment";
21import config from "./config";
22import db, { schema } from "./database";
23import discordIntegration from "./discord-integration";
24import externalLink from "./external-link";
25import genericWebhookIntegration from "./generic-webhook-integration";
26import giteaIntegration, { handleGiteaWebhookRoute } from "./gitea-integration";
27import githubIntegration, {
28 handleGithubWebhookRoute,
29} from "./github-integration";
30import invitation from "./invitation";
31import label from "./label";
32import mcpRoutes, { mcpWellKnownRoutes } from "./mcp";
33import { migrateColumns } from "./migrations/column-migration";
34import notification from "./notification";
35import notificationPreferences from "./notification-preferences";
36import { initializePlugins } from "./plugins";
37import { migrateGitHubIntegration } from "./plugins/github/migration";
38import project from "./project";
39import { getPublicProject } from "./project/controllers/get-public-project";
40import { initializeScheduler, shutdownScheduler } from "./scheduler";
41import search from "./search";
42import slackIntegration from "./slack-integration";
43import { getPrivateObject } from "./storage/s3";
44import task from "./task";
45import taskRelation from "./task-relation";
46import telegramIntegration from "./telegram-integration";
47import timeEntry from "./time-entry";
48import {
49 authenticateApiRequest,
50 resolveAssetBearerOrCookie,
51} from "./utils/authenticate-api-request";
52import { getInvitationDetails } from "./utils/check-registration-allowed";
53import { migrateApiKeyReferenceId } from "./utils/migrate-apikey-reference-id";
54import { migrateNotificationPreferencesSchema } from "./utils/migrate-notification-preferences-schema";
55import { migrateSessionColumn } from "./utils/migrate-session-column";
56import { migrateWorkspaceUserEmail } from "./utils/migrate-workspace-user-email";
57import {
58 dedupeOperationIds,
59 ensureOperationSummaries,
60 markOptionalSchemaFieldsNullable,
61 mergeOpenApiSpecs,
62 normalizeApiServerUrl,
63 normalizeEmptyAndEnumSchemas,
64 normalizeEmptyRequiredArrays,
65 normalizeNullableSchemasForOpenApi30,
66 normalizeOrganizationAuthOperations,
67} from "./utils/openapi-spec";
68import { validateWorkspaceAccess } from "./utils/validate-workspace-access";
69import workflowRule from "./workflow-rule";
70import workspace from "./workspace";
71
72type ApiKey = {
73 id: string;
74 userId: string;
75 enabled: boolean;
76};
77
78type AppVariables = {
79 Variables: {
80 user: User | null;
81 session: Session | null;
82 userId: string;
83 apiKey?: ApiKey;
84 };
85};
86
87type ApiVariables = {
88 Variables: {
89 user: User | null;
90 session: Session | null;
91 userId: string;
92 userEmail: string;
93 apiKey?: ApiKey;
94 };
95};
96
97function buildContentDisposition(filename: string) {
98 const normalized = filename
99 .normalize("NFC")
100 .replace(/[\r\n"]/g, "")
101 .trim();
102 const safeFilename = normalized || "file";
103 const asciiFallback =
104 safeFilename
105 .normalize("NFKD")
106 .replace(/[\u0300-\u036f]/g, "")
107 .replace(/[\\/]/g, "-")
108 .replace(/[^\x20-\u7E]+/g, "_")
109 .replace(/\s+/g, " ")
110 .trim() || "file";
111 const encodedFilename = encodeURIComponent(safeFilename).replace(
112 /['()*]/g,
113 (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
114 );
115
116 return `inline; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
117}
118
119export function createApp() {
120 const app = new Hono<AppVariables>();
121 const corsOrigins = process.env.CORS_ORIGINS
122 ? process.env.CORS_ORIGINS.split(",").map((origin) => origin.trim())
123 : undefined;
124
125 app.use(
126 "*",
127 cors({
128 credentials: true,
129 origin: (origin) => {
130 if (!corsOrigins) {
131 return origin || "*";
132 }
133
134 if (!origin) {
135 return null;
136 }
137
138 return corsOrigins.includes(origin) ? origin : null;
139 },
140 }),
141 );
142
143 const api = new Hono<ApiVariables>();
144
145 api.get("/health", (c) => {
146 return c.json({ status: "ok" });
147 });
148
149 const publicProjectApi = api.get("/public-project/:id", async (c) => {
150 const { id } = c.req.param();
151 const project = await getPublicProject(id);
152
153 return c.json(project);
154 });
155
156 api.post("/github-integration/webhook", handleGithubWebhookRoute);
157
158 api.post(
159 "/gitea-integration/webhook/:integrationId",
160 handleGiteaWebhookRoute,
161 );
162
163 const invitationPublicApi = api.get("/invitation/public/:id", async (c) => {
164 const { id } = c.req.param();
165 const result = await getInvitationDetails(id);
166 return c.json(result);
167 });
168
169 api.get(
170 "/auth/get-session",
171 describeRoute({
172 operationId: "getSession",
173 tags: ["Authentication"],
174 description: "Get the current authenticated session",
175 security: [],
176 responses: {
177 200: {
178 description: "Current session details or null when unauthenticated",
179 content: {
180 "application/json": { schema: resolver(v.any()) },
181 },
182 },
183 },
184 }),
185 async (c) => {
186 const session = await auth.api.getSession({ headers: c.req.raw.headers });
187 return c.json(session ?? null);
188 },
189 );
190
191 api.get(
192 "/asset/:id",
193 describeRoute({
194 operationId: "getAsset",
195 tags: ["Assets"],
196 description: "Download an uploaded asset by ID",
197 security: [],
198 responses: {
199 200: {
200 description: "The requested asset binary stream",
201 content: {
202 "*/*": { schema: resolver(v.any()) },
203 },
204 },
205 },
206 }),
207 validator("param", v.object({ id: v.string() })),
208 async (c) => {
209 const { id } = c.req.param();
210 const [asset] = await db
211 .select({
212 id: schema.assetTable.id,
213 objectKey: schema.assetTable.objectKey,
214 mimeType: schema.assetTable.mimeType,
215 filename: schema.assetTable.filename,
216 workspaceId: schema.assetTable.workspaceId,
217 isPublic: schema.projectTable.isPublic,
218 })
219 .from(schema.assetTable)
220 .innerJoin(
221 schema.projectTable,
222 eq(schema.assetTable.projectId, schema.projectTable.id),
223 )
224 .where(eq(schema.assetTable.id, id))
225 .limit(1);
226
227 if (!asset) {
228 throw new HTTPException(404, { message: "Asset not found" });
229 }
230
231 const { userId, apiKeyId } = await resolveAssetBearerOrCookie(c);
232
233 if (userId) {
234 await validateWorkspaceAccess(userId, asset.workspaceId, apiKeyId);
235 } else if (!asset.isPublic) {
236 throw new HTTPException(401, { message: "Unauthorized" });
237 }
238
239 try {
240 const object = await getPrivateObject(asset.objectKey);
241
242 return new Response(object.body as BodyInit, {
243 headers: {
244 "Cache-Control": asset.isPublic
245 ? "public, max-age=300"
246 : "private, max-age=120",
247 "Content-Disposition": buildContentDisposition(asset.filename),
248 "Content-Length": object.contentLength?.toString() || "",
249 "Content-Type": object.contentType || asset.mimeType,
250 ETag: object.etag || "",
251 "Last-Modified": object.lastModified?.toUTCString() || "",
252 },
253 });
254 } catch (error) {
255 console.error("Failed to stream asset:", error);
256 throw new HTTPException(404, { message: "Asset object not found" });
257 }
258 },
259 );
260
261 const configApi = api.route("/config", config);
262
263 const honoOpenApiHandler = openAPIRouteHandler(api, {
264 documentation: {
265 openapi: "3.0.3",
266 info: {
267 title: "Kaneo API",
268 version: "1.0.0",
269 description:
270 "Kaneo Project Management API - Manage projects, tasks, labels, and more",
271 },
272 servers: [
273 {
274 url: normalizeApiServerUrl(
275 process.env.KANEO_API_URL || "https://cloud.kaneo.app",
276 ),
277 description: "Kaneo API Server",
278 },
279 ],
280 components: {
281 securitySchemes: {
282 bearerAuth: {
283 type: "http",
284 scheme: "bearer",
285 description: "API key or session token (Bearer)",
286 },
287 },
288 },
289 security: [{ bearerAuth: [] }],
290 },
291 });
292
293 api.get("/openapi", async (c) => {
294 const maybeResponse = await honoOpenApiHandler(c, async () => {});
295 const honoSpecResponse = maybeResponse ?? c.res;
296 const honoSpec = (await honoSpecResponse.json()) as Record<string, unknown>;
297
298 let authSpec: Record<string, unknown> = {};
299 try {
300 authSpec = (await auth.api.generateOpenAPISchema()) as Record<
301 string,
302 unknown
303 >;
304 } catch (error) {
305 console.error("Failed to generate Better Auth OpenAPI schema:", error);
306 }
307
308 const normalizedAuthSpec = normalizeOrganizationAuthOperations(authSpec);
309 return c.json(
310 ensureOperationSummaries(
311 dedupeOperationIds(
312 markOptionalSchemaFieldsNullable(
313 normalizeNullableSchemasForOpenApi30(
314 normalizeEmptyAndEnumSchemas(
315 normalizeEmptyRequiredArrays(
316 mergeOpenApiSpecs(honoSpec, normalizedAuthSpec),
317 ),
318 ),
319 ),
320 ),
321 ),
322 ),
323 );
324 });
325
326 // Better Auth serves GET /auth/device as JSON. Browsers that open the API URL
327 // directly expect a page — redirect full document navigations to the web app.
328 const authDeviceQuerySchema = v.object({
329 user_code: v.optional(v.string()),
330 ui: v.optional(v.picklist(["1"])),
331 });
332
333 api.get(
334 "/auth/device",
335 describeRoute({
336 operationId: "getDeviceAuthorizationPage",
337 tags: ["Authentication"],
338 description:
339 "Redirect browser-based device authorization requests to the web UI",
340 security: [],
341 parameters: [
342 {
343 name: "user_code",
344 in: "query",
345 required: false,
346 schema: {
347 type: "string",
348 },
349 description: "The device authorization user code.",
350 },
351 {
352 name: "ui",
353 in: "query",
354 required: false,
355 schema: {
356 type: "string",
357 enum: ["1"],
358 },
359 description: "Force a redirect to the web UI.",
360 },
361 ],
362 responses: {
363 302: {
364 description: "Redirects the browser to the web app device screen",
365 },
366 200: {
367 description: "Device authorization payload from Better Auth",
368 content: {
369 "application/json": { schema: resolver(v.any()) },
370 },
371 },
372 },
373 }),
374 validator("query", authDeviceQuerySchema),
375 async (c) => {
376 const { user_code: userCode, ui } = c.req.valid("query");
377 const secFetchDest = c.req.header("Sec-Fetch-Dest");
378 const forceUiRedirect = ui === "1";
379 // Top-level browser tab / address bar (not `fetch()` / XHR from the SPA).
380 // Optional `ui=1` forces redirect when Sec-Fetch-* headers are missing (e.g. some clients).
381 if (forceUiRedirect || secFetchDest === "document") {
382 const clientUrl = (
383 process.env.KANEO_CLIENT_URL || "http://localhost:5173"
384 ).replace(/\/$/, "");
385 const deviceUrl = new URL(`${clientUrl}/device`);
386 if (userCode) {
387 deviceUrl.searchParams.set("user_code", userCode);
388 }
389 return c.redirect(deviceUrl.toString(), 302);
390 }
391 return auth.handler(c.req.raw);
392 },
393 );
394
395 api.on(["POST", "GET", "PUT", "DELETE"], "/auth/*", async (c) => {
396 const authHeader = c.req.header("Authorization");
397 const apiKeyHeader = c.req.header("x-api-key");
398 const bearerMatch = authHeader?.match(/^Bearer\s+(\S+)$/i);
399
400 if (bearerMatch && !apiKeyHeader) {
401 const session = await auth.api.getSession({
402 headers: c.req.raw.headers,
403 });
404
405 // Preserve Better Auth bearer session tokens on auth routes.
406 if (session?.session && session.user) {
407 return auth.handler(c.req.raw);
408 }
409
410 const headers = new Headers(c.req.raw.headers);
411
412 // Better Auth API key plugin validates from x-api-key by default.
413 headers.set("x-api-key", bearerMatch[1]);
414
415 return auth.handler(
416 new Request(c.req.raw, {
417 headers,
418 }),
419 );
420 }
421
422 return auth.handler(c.req.raw);
423 });
424
425 api.route("/", mcpRoutes);
426
427 api.use("*", async (c, next) => {
428 const path = c.req.path;
429 if (path.startsWith("/api/mcp") || path.startsWith("/api/.well-known/")) {
430 return next();
431 }
432 try {
433 await authenticateApiRequest(c);
434 } catch (error) {
435 if (error instanceof HTTPException) {
436 throw error;
437 }
438 console.error("API authentication failed:", error);
439 throw new HTTPException(500, { message: "Internal Server Error" });
440 }
441 return next();
442 });
443
444 const projectApi = api.route("/project", project);
445 const taskApi = api.route("/task", task);
446 const columnApi = api.route("/column", column);
447 const activityApi = api.route("/activity", activity);
448 const commentApi = api.route("/comment", comment);
449 const timeEntryApi = api.route("/time-entry", timeEntry);
450 const labelApi = api.route("/label", label);
451 const notificationApi = api.route("/notification", notification);
452 const notificationPreferencesApi = api.route(
453 "/notification-preferences",
454 notificationPreferences,
455 );
456 const searchApi = api.route("/search", search);
457 const githubIntegrationApi = api.route(
458 "/github-integration",
459 githubIntegration,
460 );
461 const giteaIntegrationApi = api.route("/gitea-integration", giteaIntegration);
462 const genericWebhookIntegrationApi = api.route(
463 "/generic-webhook-integration",
464 genericWebhookIntegration,
465 );
466 const discordIntegrationApi = api.route(
467 "/discord-integration",
468 discordIntegration,
469 );
470 const slackIntegrationApi = api.route("/slack-integration", slackIntegration);
471 const telegramIntegrationApi = api.route(
472 "/telegram-integration",
473 telegramIntegration,
474 );
475 const taskRelationApi = api.route("/task-relation", taskRelation);
476 const externalLinkApi = api.route("/external-link", externalLink);
477 const workflowRuleApi = api.route("/workflow-rule", workflowRule);
478 const invitationApi = api.route("/invitation", invitation);
479 const workspaceApi = api.route("/workspace", workspace);
480
481 app.route(
482 "/",
483 mcpWellKnownRoutes(
484 (process.env.KANEO_API_URL || "http://localhost:1337").replace(
485 /\/api\/?$/,
486 "",
487 ),
488 ),
489 );
490
491 app.route("/api", api);
492
493 return {
494 app,
495 api,
496 activityApi,
497 columnApi,
498 commentApi,
499 configApi,
500 discordIntegrationApi,
501 externalLinkApi,
502 genericWebhookIntegrationApi,
503 githubIntegrationApi,
504 giteaIntegrationApi,
505 invitationApi,
506 invitationPublicApi,
507 labelApi,
508 notificationApi,
509 notificationPreferencesApi,
510 projectApi,
511 publicProjectApi,
512 searchApi,
513 slackIntegrationApi,
514 taskApi,
515 taskRelationApi,
516 telegramIntegrationApi,
517 timeEntryApi,
518 workflowRuleApi,
519 workspaceApi,
520 };
521}
522
523export async function runStartupTasks() {
524 const currentDir = dirname(fileURLToPath(import.meta.url));
525
526 await migrateWorkspaceUserEmail();
527 await migrateSessionColumn();
528 await migrateApiKeyReferenceId();
529
530 console.log("🔄 Migrating database...");
531 await migrate(db, {
532 migrationsFolder: `${currentDir}/../drizzle`,
533 });
534 console.log("✅ Database migrated successfully!");
535
536 await migrateNotificationPreferencesSchema();
537 await migrateGitHubIntegration();
538 await migrateColumns();
539
540 initializePlugins();
541 initializeScheduler();
542}
543
544export async function startServer(port = 1337) {
545 try {
546 await runStartupTasks();
547 } catch (error) {
548 console.error("❌ Database migration failed!", error);
549 process.exit(1);
550 }
551
552 process.on("SIGTERM", () => {
553 shutdownScheduler();
554 });
555
556 serve(
557 {
558 fetch: app.fetch,
559 port,
560 },
561 () => {
562 console.log(
563 `⚡ API is running at ${process.env.KANEO_API_URL || "http://localhost:1337"}`,
564 );
565 },
566 );
567}
568
569const createdApp = createApp();
570const {
571 app,
572 activityApi,
573 columnApi,
574 commentApi,
575 configApi,
576 discordIntegrationApi,
577 externalLinkApi,
578 genericWebhookIntegrationApi,
579 githubIntegrationApi,
580 giteaIntegrationApi,
581 invitationApi,
582 invitationPublicApi,
583 labelApi,
584 notificationApi,
585 notificationPreferencesApi,
586 projectApi,
587 publicProjectApi,
588 searchApi,
589 slackIntegrationApi,
590 taskApi,
591 taskRelationApi,
592 telegramIntegrationApi,
593 timeEntryApi,
594 workflowRuleApi,
595 workspaceApi,
596} = createdApp;
597
598const isMainModule =
599 Boolean(process.argv[1]) &&
600 import.meta.url === pathToFileURL(process.argv[1]).href;
601
602if (isMainModule) {
603 void startServer();
604}
605
606export type AppType =
607 | typeof configApi
608 | typeof projectApi
609 | typeof taskApi
610 | typeof columnApi
611 | typeof activityApi
612 | typeof commentApi
613 | typeof timeEntryApi
614 | typeof labelApi
615 | typeof notificationApi
616 | typeof notificationPreferencesApi
617 | typeof searchApi
618 | typeof githubIntegrationApi
619 | typeof giteaIntegrationApi
620 | typeof genericWebhookIntegrationApi
621 | typeof discordIntegrationApi
622 | typeof slackIntegrationApi
623 | typeof telegramIntegrationApi
624 | typeof taskRelationApi
625 | typeof externalLinkApi
626 | typeof workflowRuleApi
627 | typeof invitationApi
628 | typeof workspaceApi
629 | typeof publicProjectApi
630 | typeof invitationPublicApi;
631
632export default app;