239 lines
5.9 KiB
TypeScript
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",
|
|
},
|
|
});
|