kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
489 lines (483 loc) • 17.6 kB
JavaScript
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 };