diff --git a/src/index.ts b/src/index.ts index d7a013c..a806ac4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ import { randomBytes } from "crypto"; import DB from "./lib/db.js"; import { CanvasClient } from "./lib/canvas.js"; import { - handleMcpRequest, - getProtectedResourceMetadata, + handleMcpRequest, + getProtectedResourceMetadata, } from "./lib/mcp-transport.js"; import Mailer from "./lib/email.js"; @@ -12,216 +12,229 @@ import indexPage from "./public/index.html"; import dashboardPage from "./public/dashboard.html"; // Configuration -const PORT = parseInt(process.env.PORT || "3000"); +const PORT = parseInt(process.env.PORT || "3000", 10); const HOST = process.env.HOST || "localhost"; const BASE_URL = process.env.BASE_URL || `http://${HOST}:${PORT}`; // Generate session cookie function generateSessionId(): string { - return randomBytes(32).toString("base64url"); + return randomBytes(32).toString("base64url"); } // Get session from cookie function getSession(req: Request) { - const cookie = req.headers.get("cookie"); - if (!cookie) return null; + const cookie = req.headers.get("cookie"); + if (!cookie) return null; - const sessionCookie = cookie - .split(";") - .find((c) => c.trim().startsWith("session=")); - if (!sessionCookie) return null; + const sessionCookie = cookie + .split(";") + .find((c) => c.trim().startsWith("session=")); + if (!sessionCookie) return null; - const sessionId = sessionCookie.split("=")[1]; - return DB.getSession(sessionId); + const sessionId = sessionCookie.split("=")[1]; + return DB.getSession(sessionId); } // Routes const routes = { - // Web pages - "/": indexPage, - "/dashboard": dashboardPage, - - // Favicon - "/favicon.ico": { - GET() { - const file = Bun.file("src/public/favicon.ico"); - return new Response(file, { - headers: { "Content-Type": "image/x-icon" }, - }); - }, - }, - - // Health check endpoint - "/health": { - GET(req: Request) { - const url = new URL(req.url); - const detailed = url.searchParams.get("detailed") === "true"; - - if (detailed) { - return Response.json({ - status: "healthy", - timestamp: new Date().toISOString(), - version: "1.0.0", - uptime: process.uptime(), - cache: { - apiKeys: DB.getApiKeyCacheStats(), - canvas: CanvasClient.getCacheStats(), - }, - }); - } - - return Response.json({ status: "healthy" }); - }, - }, - - // MCP Protocol endpoint (Streamable HTTP) - "/mcp": { - async POST(req: Request) { - // Extract Bearer token from Authorization header - const authHeader = req.headers.get("Authorization"); - const token = authHeader?.startsWith("Bearer ") - ? authHeader.slice(7) - : undefined; - - return handleMcpRequest(req, token); - }, - }, - - // Protected Resource Metadata (OAuth discovery) - "/.well-known/oauth-protected-resource": { - GET() { - return Response.json(getProtectedResourceMetadata(BASE_URL)); - }, - }, - - // Protected Resource Metadata with MCP path - "/.well-known/oauth-protected-resource/mcp": { - GET() { - return Response.json(getProtectedResourceMetadata(BASE_URL)); - }, - }, - - // Authorization Server Metadata (at root for discovery) - "/.well-known/oauth-authorization-server/auth": { - GET() { - return Response.json({ - issuer: `${BASE_URL}/auth`, - authorization_endpoint: `${BASE_URL}/auth/authorize`, - token_endpoint: `${BASE_URL}/auth/token`, - code_challenge_methods_supported: ["S256"], - grant_types_supported: ["authorization_code", "refresh_token"], - response_types_supported: ["code"], - scopes_supported: [ - "canvas:read", - "canvas:courses:read", - "canvas:assignments:read", - "canvas:grades:read", - "canvas:announcements:read", - ], - token_endpoint_auth_methods_supported: ["none"], - client_id_metadata_document_supported: true, - }); - }, - }, - - // OpenID Connect Discovery (some clients look for this) - "/.well-known/openid-configuration/auth": { - GET() { - return Response.json({ - issuer: `${BASE_URL}/auth`, - authorization_endpoint: `${BASE_URL}/auth/authorize`, - token_endpoint: `${BASE_URL}/auth/token`, - code_challenge_methods_supported: ["S256"], - grant_types_supported: ["authorization_code", "refresh_token"], - response_types_supported: ["code"], - scopes_supported: [ - "canvas:read", - "canvas:courses:read", - "canvas:assignments:read", - "canvas:grades:read", - "canvas:announcements:read", - ], - token_endpoint_auth_methods_supported: ["none"], - }); - }, - }, - - "/auth/.well-known/openid-configuration": { - GET() { - return Response.json({ - issuer: `${BASE_URL}/auth`, - authorization_endpoint: `${BASE_URL}/auth/authorize`, - token_endpoint: `${BASE_URL}/auth/token`, - code_challenge_methods_supported: ["S256"], - grant_types_supported: ["authorization_code", "refresh_token"], - response_types_supported: ["code"], - scopes_supported: [ - "canvas:read", - "canvas:courses:read", - "canvas:assignments:read", - "canvas:grades:read", - "canvas:announcements:read", - ], - token_endpoint_auth_methods_supported: ["none"], - }); - }, - }, - - // Dynamic client registration (return 501 Not Implemented for now) - "/register": { - POST() { - return Response.json( - { error: "dynamic_registration_not_supported", error_description: "Use Client ID Metadata Documents instead" }, - { status: 501 } - ); - }, - }, - - // OAuth authorization endpoint - "/auth/authorize": { - async GET(req: Request) { - const url = new URL(req.url); - const client_id = url.searchParams.get("client_id"); - const redirect_uri = url.searchParams.get("redirect_uri"); - const code_challenge = url.searchParams.get("code_challenge"); - const code_challenge_method = url.searchParams.get("code_challenge_method"); - const resource = url.searchParams.get("resource"); - const scope = url.searchParams.get("scope") || "canvas:read"; - const state = url.searchParams.get("state") || ""; - const response_type = url.searchParams.get("response_type"); - - // Validate required parameters - if (!client_id || !redirect_uri || !code_challenge || !response_type) { - return new Response("Missing required OAuth parameters", { status: 400 }); - } - - if (response_type !== "code") { - return new Response("Only authorization_code flow is supported", { status: 400 }); - } - - if (code_challenge_method !== "S256") { - return new Response("Only S256 PKCE method is supported", { status: 400 }); - } - - // Check if user is logged in - const session = getSession(req); - if (!session?.user_id) { - // Redirect to login, preserving OAuth params - return new Response(null, { - status: 302, - headers: { - Location: `/?oauth_redirect=${encodeURIComponent(req.url)}`, - }, - }); - } - - // Check if user has Canvas connected - const user = DB.raw - .query("SELECT * FROM users WHERE id = ?") - .get(session.user_id) as any; - - if (!user || !user.canvas_domain) { - return new Response(` + // Web pages + "/": indexPage, + "/dashboard": dashboardPage, + + // Favicon + "/favicon.ico": { + GET() { + const file = Bun.file("src/public/favicon.ico"); + return new Response(file, { + headers: { "Content-Type": "image/x-icon" }, + }); + }, + }, + "/og.png": Bun.file("src/public/og.png"), + + // Health check endpoint + "/health": { + GET(req: Request) { + const url = new URL(req.url); + const detailed = url.searchParams.get("detailed") === "true"; + + if (detailed) { + return Response.json({ + status: "healthy", + timestamp: new Date().toISOString(), + version: "1.0.0", + uptime: process.uptime(), + cache: { + apiKeys: DB.getApiKeyCacheStats(), + canvas: CanvasClient.getCacheStats(), + }, + }); + } + + return Response.json({ status: "healthy" }); + }, + }, + + // MCP Protocol endpoint (Streamable HTTP) + "/mcp": { + async POST(req: Request) { + // Extract Bearer token from Authorization header + const authHeader = req.headers.get("Authorization"); + const token = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : undefined; + + return handleMcpRequest(req, token); + }, + }, + + // Protected Resource Metadata (OAuth discovery) + "/.well-known/oauth-protected-resource": { + GET() { + return Response.json(getProtectedResourceMetadata(BASE_URL)); + }, + }, + + // Protected Resource Metadata with MCP path + "/.well-known/oauth-protected-resource/mcp": { + GET() { + return Response.json(getProtectedResourceMetadata(BASE_URL)); + }, + }, + + // Authorization Server Metadata (at root for discovery) + "/.well-known/oauth-authorization-server/auth": { + GET() { + return Response.json({ + issuer: `${BASE_URL}/auth`, + authorization_endpoint: `${BASE_URL}/auth/authorize`, + token_endpoint: `${BASE_URL}/auth/token`, + code_challenge_methods_supported: ["S256"], + grant_types_supported: ["authorization_code", "refresh_token"], + response_types_supported: ["code"], + scopes_supported: [ + "canvas:read", + "canvas:courses:read", + "canvas:assignments:read", + "canvas:grades:read", + "canvas:announcements:read", + ], + token_endpoint_auth_methods_supported: ["none"], + client_id_metadata_document_supported: true, + }); + }, + }, + + // OpenID Connect Discovery (some clients look for this) + "/.well-known/openid-configuration/auth": { + GET() { + return Response.json({ + issuer: `${BASE_URL}/auth`, + authorization_endpoint: `${BASE_URL}/auth/authorize`, + token_endpoint: `${BASE_URL}/auth/token`, + code_challenge_methods_supported: ["S256"], + grant_types_supported: ["authorization_code", "refresh_token"], + response_types_supported: ["code"], + scopes_supported: [ + "canvas:read", + "canvas:courses:read", + "canvas:assignments:read", + "canvas:grades:read", + "canvas:announcements:read", + ], + token_endpoint_auth_methods_supported: ["none"], + }); + }, + }, + + "/auth/.well-known/openid-configuration": { + GET() { + return Response.json({ + issuer: `${BASE_URL}/auth`, + authorization_endpoint: `${BASE_URL}/auth/authorize`, + token_endpoint: `${BASE_URL}/auth/token`, + code_challenge_methods_supported: ["S256"], + grant_types_supported: ["authorization_code", "refresh_token"], + response_types_supported: ["code"], + scopes_supported: [ + "canvas:read", + "canvas:courses:read", + "canvas:assignments:read", + "canvas:grades:read", + "canvas:announcements:read", + ], + token_endpoint_auth_methods_supported: ["none"], + }); + }, + }, + + // Dynamic client registration (return 501 Not Implemented for now) + "/register": { + POST() { + return Response.json( + { + error: "dynamic_registration_not_supported", + error_description: "Use Client ID Metadata Documents instead", + }, + { status: 501 }, + ); + }, + }, + + // OAuth authorization endpoint + "/auth/authorize": { + async GET(req: Request) { + const url = new URL(req.url); + const client_id = url.searchParams.get("client_id"); + const redirect_uri = url.searchParams.get("redirect_uri"); + const code_challenge = url.searchParams.get("code_challenge"); + const code_challenge_method = url.searchParams.get( + "code_challenge_method", + ); + const resource = url.searchParams.get("resource"); + const scope = url.searchParams.get("scope") || "canvas:read"; + const state = url.searchParams.get("state") || ""; + const response_type = url.searchParams.get("response_type"); + + // Validate required parameters + if (!client_id || !redirect_uri || !code_challenge || !response_type) { + return new Response("Missing required OAuth parameters", { + status: 400, + }); + } + + if (response_type !== "code") { + return new Response("Only authorization_code flow is supported", { + status: 400, + }); + } + + if (code_challenge_method !== "S256") { + return new Response("Only S256 PKCE method is supported", { + status: 400, + }); + } + + // Check if user is logged in + const session = getSession(req); + if (!session?.user_id) { + // Redirect to login, preserving OAuth params + return new Response(null, { + status: 302, + headers: { + Location: `/?oauth_redirect=${encodeURIComponent(req.url)}`, + }, + }); + } + + // Check if user has Canvas connected + const user = DB.raw + .query("SELECT * FROM users WHERE id = ?") + .get(session.user_id) as any; + + if (!user || !user.canvas_domain) { + return new Response( + `
@@ -256,11 +269,13 @@ const routes = { -`, { headers: { "Content-Type": "text/html" }}); - } +