UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

489 lines (483 loc) 17.6 kB
import { ROUTABLE_HTTP_METHODS, httpActionGeneric, httpRouter } from "convex/server"; //#region src/auth/error-response.ts const isApiErrorLike = (error) => !!error && typeof error === "object" && (error.name === "APIError" && "statusCode" in error || typeof error.statusCode === "number"); const toResponseInit = (error) => { const init = { headers: new Headers(error.headers ?? {}), status: typeof error.statusCode === "number" ? error.statusCode : 500 }; if (typeof error.status === "string") init.statusText = error.status; return init; }; const toAuthErrorResponse = (error) => { if (!isApiErrorLike(error)) return null; const init = toResponseInit(error); const { body } = error; if (body === void 0) return new Response(null, init); if (typeof body === "string") { if (init.headers instanceof Headers && !init.headers.has("content-type")) init.headers.set("content-type", "text/plain"); return new Response(body, init); } return Response.json(body, init); }; //#endregion //#region src/auth/middleware.ts /** * Create auth middleware that handles auth routes and OpenID well-known redirect. * * @example * ```ts * import { Hono } from 'hono'; * import { cors } from 'hono/cors'; * import { authMiddleware } from 'kitcn/auth/http'; * import { createHttpRouter } from 'kitcn/server'; * * const app = new Hono(); * app.use('/api/*', cors({ origin: process.env.SITE_URL, credentials: true })); * app.use(authMiddleware(getAuth)); * * export default createHttpRouter(app, httpRouter); * ``` */ function authMiddleware(getAuth, opts = {}) { const basePath = opts.basePath ?? "/api/auth"; return async (c, next) => { if (c.req.path === "/.well-known/openid-configuration") return c.redirect(`${process.env.CONVEX_SITE_URL}${basePath}/convex/.well-known/openid-configuration`); if (c.req.path.startsWith(basePath)) { if (opts.verbose) console.log("request headers", c.req.raw.headers); const auth = getAuth(c.env); let response; try { response = await auth.handler(c.req.raw); } catch (error) { const errorResponse = toAuthErrorResponse(error); if (errorResponse) return errorResponse; throw error; } if (opts.verbose) console.log("response headers", response.headers); return response; } return next(); }; } //#endregion //#region src/internal/upstream/server/cors.ts /** biome-ignore-all lint: vendored upstream helper source */ /** * Vendored from upstream helper repository at commit c5e52c8. * Source path: packages/convex_helpers/server/cors.ts */ /** * This file defines a CorsHttpRouter class that extends Convex's HttpRouter. * It provides CORS (Cross-Origin Resource Sharing) support for HTTP routes. * * The CorsHttpRouter: * 1. Allows specifying allowed origins for CORS. * 2. Overrides the route method to add CORS headers to all non-OPTIONS requests. * 3. Automatically adds an OPTIONS route to handle CORS preflight requests. * 4. Uses the handleCors helper function to apply CORS headers consistently. * * This router simplifies the process of making Convex HTTP endpoints * accessible to web applications hosted on different domains while * maintaining proper CORS configuration. */ const DEFAULT_EXPOSED_HEADERS = ["Content-Range", "Accept-Ranges"]; /** * Factory function to create a router that adds CORS support to routes. * @param allowedOrigins An array of allowed origins for CORS. * @returns A function to use instead of http.route when you want CORS. */ const corsRouter = (http, corsConfig) => { const allowedExactMethodsByPath = /* @__PURE__ */ new Map(); const allowedPrefixMethodsByPath = /* @__PURE__ */ new Map(); return { http, route: (routeSpec) => { const tempRouter = httpRouter(); tempRouter.exactRoutes = http.exactRoutes; tempRouter.prefixRoutes = http.prefixRoutes; const config = { ...corsConfig, ...routeSpec }; const httpCorsHandler = handleCors({ originalHandler: routeSpec.handler, allowedMethods: [routeSpec.method], ...config }); /** * Figure out what kind of route we're adding: exact or prefix and handle * accordingly. */ if ("path" in routeSpec) { let methods = allowedExactMethodsByPath.get(routeSpec.path); if (!methods) { methods = /* @__PURE__ */ new Set(); allowedExactMethodsByPath.set(routeSpec.path, methods); } methods.add(routeSpec.method); tempRouter.route({ path: routeSpec.path, method: routeSpec.method, handler: httpCorsHandler }); handleExactRoute(tempRouter, routeSpec, config, Array.from(methods)); } else { let methods = allowedPrefixMethodsByPath.get(routeSpec.pathPrefix); if (!methods) { methods = /* @__PURE__ */ new Set(); allowedPrefixMethodsByPath.set(routeSpec.pathPrefix, methods); } methods.add(routeSpec.method); tempRouter.route({ pathPrefix: routeSpec.pathPrefix, method: routeSpec.method, handler: httpCorsHandler }); handlePrefixRoute(tempRouter, routeSpec, config, Array.from(methods)); } /** * Copy the routes from the temporary router to the main router. */ http.exactRoutes = new Map(tempRouter.exactRoutes); http.prefixRoutes = new Map(tempRouter.prefixRoutes); } }; }; /** * Handles exact route matching and adds OPTIONS handler. * @param tempRouter Temporary router instance. * @param routeSpec Route specification for exact matching. */ function handleExactRoute(tempRouter, routeSpec, config, allowedMethods) { const currentMethodsForPath = tempRouter.exactRoutes.get(routeSpec.path); /** * Add the OPTIONS handler for the given path */ const optionsHandler = createOptionsHandlerForMethods(allowedMethods, config); currentMethodsForPath?.set("OPTIONS", optionsHandler); tempRouter.exactRoutes.set(routeSpec.path, new Map(currentMethodsForPath)); } /** * Handles prefix route matching and adds OPTIONS handler. * @param tempRouter Temporary router instance. * @param routeSpec Route specification for prefix matching. */ function handlePrefixRoute(tempRouter, routeSpec, config, allowedMethods) { /** * prefixRoutes is structured differently than exactRoutes. It's defined as * a Map<string, Map<string, PublicHttpAction>> where the KEY is the * METHOD and the VALUE is a map of paths and handlers. */ const optionsHandler = createOptionsHandlerForMethods(allowedMethods, config); const optionsPrefixes = tempRouter.prefixRoutes.get("OPTIONS") || /* @__PURE__ */ new Map(); optionsPrefixes.set(routeSpec.pathPrefix, optionsHandler); tempRouter.prefixRoutes.set("OPTIONS", optionsPrefixes); } /** * Creates an OPTIONS handler for the given HTTP methods. * @param methods Array of HTTP methods to be allowed. * @returns A CORS-enabled OPTIONS handler. */ function createOptionsHandlerForMethods(methods, config) { return handleCors({ ...config, allowedMethods: methods }); } /** * handleCors() is a higher-order function that wraps a Convex HTTP action handler to add CORS support. * It allows for customization of allowed HTTP methods and origins for cross-origin requests. * * The function: * 1. Validates and normalizes the allowed HTTP methods. * 2. Generates appropriate CORS headers based on the provided configuration. * 3. Handles preflight OPTIONS requests automatically. * 4. Wraps the original handler to add CORS headers to its response. * * This helper simplifies the process of making Convex HTTP actions accessible * to web applications hosted on different domains. */ const SECONDS_IN_A_DAY = 3600 * 24; /** * Example CORS origins: * - "*" (allow all origins) * - "https://example.com" (allow a specific domain) * - "https://*.example.com" (allow all subdomains of example.com) * - "https://example1.com, https://example2.com" (allow multiple specific domains) * - "null" (allow requests from data URLs or local files) */ const handleCors = ({ originalHandler, allowedMethods = ["OPTIONS"], allowedOrigins = ["*"], allowedHeaders = ["Content-Type"], exposedHeaders = DEFAULT_EXPOSED_HEADERS, allowCredentials = false, browserCacheMaxAge = SECONDS_IN_A_DAY, enforceAllowOrigins = false, debug = false }) => { const filteredMethods = Array.from(new Set(allowedMethods.map((method) => method.toUpperCase()))).filter((method) => ROUTABLE_HTTP_METHODS.includes(method)); if (filteredMethods.length === 0) throw new Error("No valid HTTP methods provided"); /** * Ensure OPTIONS is not duplicated if it was passed in * E.g. if allowedMethods = ["GET", "OPTIONS"] */ const allowMethods = filteredMethods.includes("OPTIONS") ? filteredMethods.join(", ") : [...filteredMethods].join(", "); /** * Build up the set of CORS headers */ const commonHeaders = { Vary: "Origin" }; if (allowCredentials) commonHeaders["Access-Control-Allow-Credentials"] = "true"; if (exposedHeaders.length > 0) commonHeaders["Access-Control-Expose-Headers"] = exposedHeaders.join(", "); async function parseAllowedOrigins(request) { return Array.isArray(allowedOrigins) ? allowedOrigins : await allowedOrigins(request); } async function isAllowedOrigin(request) { const requestOrigin = request.headers.get("origin"); if (!requestOrigin) return false; return (await parseAllowedOrigins(request)).some((allowed) => { if (allowed === "*") return true; if (allowed === requestOrigin) return true; if (allowed.startsWith("*.")) { const wildcardDomain = allowed.slice(1); const rootDomain = allowed.slice(2); try { const url = new URL(requestOrigin); return url.protocol === "https:" && (url.hostname.endsWith(wildcardDomain) || url.hostname === rootDomain); } catch { return false; } } return false; }); } /** * Return our modified HTTP action */ return httpActionGeneric(async (ctx, request) => { if (debug) console.log("CORS request", { path: request.url, origin: request.headers.get("origin"), headers: request.headers, method: request.method, body: request.body }); const requestOrigin = request.headers.get("origin"); const parsedAllowedOrigins = await parseAllowedOrigins(request); if (debug) console.log("allowed origins", parsedAllowedOrigins); let allowOrigins = null; if (parsedAllowedOrigins.includes("*") && requestOrigin && !allowCredentials) allowOrigins = requestOrigin; else if (requestOrigin) { if (await isAllowedOrigin(request)) allowOrigins = requestOrigin; } if (enforceAllowOrigins && !allowOrigins) { console.error(`Request from origin ${requestOrigin} blocked, missing from allowed origins: ${parsedAllowedOrigins.join()}`); return new Response(null, { status: 403 }); } /** * OPTIONS has no handler and just returns headers */ if (request.method === "OPTIONS") { const responseHeaders = new Headers({ ...commonHeaders, ...allowOrigins ? { "Access-Control-Allow-Origin": allowOrigins } : {}, "Access-Control-Allow-Methods": allowMethods, "Access-Control-Allow-Headers": allowedHeaders.join(", "), "Access-Control-Max-Age": browserCacheMaxAge.toString() }); if (debug) console.log("CORS OPTIONS response headers", responseHeaders); return new Response(null, { status: 204, headers: responseHeaders }); } /** * If the method is not OPTIONS, it must pass a handler */ if (!originalHandler) throw new Error("No PublicHttpAction provider to CORS handler"); const originalResponse = await ("_handler" in originalHandler ? originalHandler["_handler"] : originalHandler)(ctx, request); /** * Second, get a copy of the original response's headers and add the * allow origin header if it's allowed */ const newHeaders = new Headers(originalResponse.headers); if (allowOrigins) newHeaders.set("Access-Control-Allow-Origin", allowOrigins); /** * Third, add or update our other CORS headers */ Object.entries(commonHeaders).forEach(([key, value]) => { newHeaders.set(key, value); }); if (debug) console.log("CORS response headers", newHeaders); /** * Fourth, return the modified Response. * A Response object is immutable, so we create a new one to return here. */ return new Response(originalResponse.body, { status: originalResponse.status, statusText: originalResponse.statusText, headers: newHeaders }); }); }; //#endregion //#region src/auth/registerRoutes.ts const LOCAL_AUTH_HOSTS = new Set([ "127.0.0.1", "::1", "localhost" ]); const LOCAL_CONVEX_AUTH_IP_PATHS = new Set([ "/convex/.well-known/openid-configuration", "/convex/jwks", "/convex/token" ]); const restoreOriginalForwardedHeaders = (request) => { const originalHost = request.headers.get("x-better-auth-forwarded-host"); const originalProto = request.headers.get("x-better-auth-forwarded-proto"); if (!originalHost && !originalProto) return request; const headers = new Headers(request.headers); if (originalHost) headers.set("x-forwarded-host", originalHost); if (originalProto) headers.set("x-forwarded-proto", originalProto); const init = { headers, method: request.method }; if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; init.duplex = "half"; } return new Request(request.url, init); }; const withLocalConvexAuthIp = (request, basePath) => { if (request.headers.get("x-forwarded-for")) return request; let url; try { url = new URL(request.url); } catch { return request; } if (!LOCAL_AUTH_HOSTS.has(url.hostname)) return request; if (request.method !== "GET") return request; const normalizedBasePath = basePath.length > 1 && basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; const normalizedPath = normalizedBasePath === "/" ? url.pathname : url.pathname.startsWith(normalizedBasePath) ? url.pathname.slice(normalizedBasePath.length) || "/" : url.pathname; if (!LOCAL_CONVEX_AUTH_IP_PATHS.has(normalizedPath)) return request; const headers = new Headers(request.headers); headers.set("x-forwarded-for", "127.0.0.1"); return new Request(url.toString(), { headers, method: request.method }); }; const registerAuthRoutes = (http, registration, opts = {}) => { const { getAuth, getRegistrationAuth, path } = registration; const authRequestHandler = httpActionGeneric(async (ctx, request) => { if (opts?.verbose) { console.log("options.baseURL", getRegistrationAuth().options.baseURL); console.log("request headers", request.headers); } const auth = getAuth(ctx); const authRequest = restoreOriginalForwardedHeaders(withLocalConvexAuthIp(request, path)); let response; try { response = await auth.handler(authRequest); } catch (error) { const errorResponse = toAuthErrorResponse(error); if (errorResponse) return errorResponse; throw error; } if (opts?.verbose) console.log("response headers", response.headers); return response; }); if (!http.lookup("/.well-known/openid-configuration", "GET")) http.route({ handler: httpActionGeneric(async () => { const url = `${process.env.CONVEX_SITE_URL}${path}/convex/.well-known/openid-configuration`; return Response.redirect(url); }), method: "GET", path: "/.well-known/openid-configuration" }); if (!opts.cors) { http.route({ handler: authRequestHandler, method: "GET", pathPrefix: `${path}/` }); http.route({ handler: authRequestHandler, method: "POST", pathPrefix: `${path}/` }); return; } const corsOpts = typeof opts.cors === "boolean" ? { allowedHeaders: [], allowedOrigins: [], exposedHeaders: [] } : opts.cors; let trustedOriginsOption; const cors = corsRouter(http, { allowCredentials: true, allowedHeaders: [ "Content-Type", "Better-Auth-Cookie", "Authorization" ].concat(corsOpts.allowedHeaders ?? []), debug: opts?.verbose, enforceAllowOrigins: false, exposedHeaders: ["Set-Better-Auth-Cookie"].concat(corsOpts.exposedHeaders ?? []), allowedOrigins: async (request) => { const resolvedTrustedOrigins = trustedOriginsOption ?? (await getRegistrationAuth().$context).options.trustedOrigins ?? []; trustedOriginsOption = resolvedTrustedOrigins; return (Array.isArray(resolvedTrustedOrigins) ? resolvedTrustedOrigins : await resolvedTrustedOrigins(request) ?? []).filter((origin) => typeof origin === "string").map((origin) => origin.endsWith("*") && origin.length > 1 ? origin.slice(0, -1) : origin).concat(corsOpts.allowedOrigins ?? []); } }); cors.route({ handler: authRequestHandler, method: "GET", pathPrefix: `${path}/` }); cors.route({ handler: authRequestHandler, method: "POST", pathPrefix: `${path}/` }); }; const registerRoutes = (http, getAuth, opts = {}) => { return registerAuthRoutes(http, { getAuth, getRegistrationAuth: (() => { let registrationAuth; return () => { registrationAuth ??= getAuth({}); return registrationAuth; }; })(), path: opts.basePath ?? "/api/auth" }, opts); }; //#endregion //#region src/auth-http/index.ts /** * Install Convex-safe polyfills required by Better Auth's HTTP handling. * This runs automatically when importing `kitcn/auth/http`. */ function installAuthHttpPolyfills() { if (typeof MessageChannel !== "undefined") return; class MockMessagePort { onmessage; onmessageerror; addEventListener() {} close() {} dispatchEvent(_event) { return false; } postMessage(_message, _transfer = []) {} removeEventListener() {} start() {} } class MockMessageChannel { port1; port2; constructor() { this.port1 = new MockMessagePort(); this.port2 = new MockMessagePort(); } } globalThis.MessageChannel = MockMessageChannel; } installAuthHttpPolyfills(); //#endregion export { authMiddleware, installAuthHttpPolyfills, registerRoutes };