Files
kan/packages/api/src/trpc.ts

239 lines
5.9 KiB
TypeScript

import { randomUUID } from "crypto";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { NextApiRequest } from "next";
import type { OpenApiMeta } from "trpc-to-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import superjson from "superjson";
import { ZodError } from "zod";
import type { dbClient } from "@kan/db/client";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { createLogger } from "@kan/logger";
const log = createLogger("api");
const TRPC_STATUS_MAP: Partial<Record<TRPCError["code"], number>> = {
PARSE_ERROR: 400,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_SUPPORTED: 405,
TIMEOUT: 408,
CONFLICT: 409,
PRECONDITION_FAILED: 412,
PAYLOAD_TOO_LARGE: 413,
UNPROCESSABLE_CONTENT: 422,
TOO_MANY_REQUESTS: 429,
CLIENT_CLOSED_REQUEST: 499,
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
};
export interface User {
id: string;
name: string;
email: string;
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
image?: string | null | undefined;
stripeCustomerId?: string | null | undefined;
}
const createAuthWithHeaders = (
auth: ReturnType<typeof initAuth>,
headers: Headers,
) => {
return {
api: {
getSession: () => auth.api.getSession({ headers }),
signInMagicLink: (input: { email: string; callbackURL: string }) =>
auth.api.signInMagicLink({
headers,
body: { email: input.email, callbackURL: input.callbackURL },
}),
listActiveSubscriptions: (input: { workspacePublicId: string }) =>
auth.api.listActiveSubscriptions({
headers,
query: { referenceId: input.workspacePublicId },
}),
},
};
};
interface CreateContextOptions {
user: User | null | undefined;
db: dbClient;
auth: ReturnType<typeof createAuthWithHeaders>;
headers: Headers;
transport?: "trpc" | "rest";
}
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
user: opts.user,
db: opts.db,
auth: opts.auth,
headers: opts.headers,
transport: opts.transport ?? "trpc",
requestId: randomUUID(),
};
};
export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
const db = createDrizzleClient();
const baseAuth = initAuth(db);
const headers = new Headers(req.headers as Record<string, string>);
const auth = createAuthWithHeaders(baseAuth, headers);
const session = await auth.api.getSession();
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "trpc",
});
};
export const createNextApiContext = async (req: NextApiRequest) => {
const db = createDrizzleClient();
const baseAuth = initAuth(db);
const headers = new Headers(req.headers as Record<string, string>);
const auth = createAuthWithHeaders(baseAuth, headers);
const session = await auth.api.getSession();
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "trpc",
});
};
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
const db = createDrizzleClient();
const baseAuth = initAuth(db);
const headers = new Headers(req.headers as Record<string, string>);
const auth = createAuthWithHeaders(baseAuth, headers);
let session;
try {
session = await auth.api.getSession();
} catch (error) {
log.warn({ err: error }, "Failed to get session, treating as unauthenticated");
}
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "rest",
});
};
const t = initTRPC
.context<typeof createTRPCContext>()
.meta<OpenApiMeta>()
.create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
const loggingMiddleware = t.middleware(async ({ path, type, next, ctx, getRawInput }) => {
const start = Date.now();
const [result, input] = await Promise.all([next(), getRawInput().catch(() => undefined)]);
const duration = Date.now() - start;
const { user, transport, requestId } = ctx as {
user?: { id: string; email: string };
transport?: string;
requestId?: string;
};
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
const meta = {
requestId,
procedure: path,
type,
transport,
duration,
userId: user?.id,
...(isCloud && { email: user?.email }),
input,
};
const label = transport === "rest" ? "REST" : "tRPC";
if (result.ok) {
log.info({ ...meta, status: 200 }, `${label} OK`);
} else {
const status = TRPC_STATUS_MAP[result.error.code] ?? 500;
const errorCode = result.error.code;
log.error(
{ ...meta, status, errorCode, err: result.error },
`${label} error`,
);
}
return result;
});
export const publicProcedure = t.procedure.use(loggingMiddleware);
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx,
});
});
const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
if (ctx.headers.get("x-admin-api-key") !== env("KAN_ADMIN_API_KEY")) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx,
});
});
export const protectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAuthed);
export const adminProtectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAdmin)
.meta({
openapi: {
method: "GET",
path: "/admin/protected",
},
});