Add MCP API access

This commit is contained in:
2026-06-04 21:33:32 -04:00
parent a13992e387
commit 37eb70be65
10 changed files with 1050 additions and 2 deletions
+60
View File
@@ -0,0 +1,60 @@
import { createHash, randomBytes } from "node:crypto";
import { and, eq, isNull, or, gt } from "drizzle-orm";
import { apiKeys } from "~/server/db/schema";
import type { db } from "~/server/db";
const API_KEY_PREFIX = "bv";
const API_KEY_SECRET_BYTES = 32;
export function hashApiKey(key: string) {
return createHash("sha256").update(key).digest("hex");
}
export function createApiKeySecret() {
const secret = randomBytes(API_KEY_SECRET_BYTES).toString("base64url");
return `${API_KEY_PREFIX}_${secret}`;
}
export function getApiKeyDisplayPrefix(key: string) {
return key.slice(0, 16);
}
export function getBearerToken(headers: Headers) {
const authorization = headers.get("authorization");
if (authorization?.startsWith("Bearer ")) {
return authorization.slice("Bearer ".length).trim();
}
const xApiKey = headers.get("x-api-key");
return xApiKey?.trim() ?? null;
}
export async function getUserForApiKey(database: typeof db, apiKey: string) {
const keyHash = hashApiKey(apiKey);
const now = new Date();
const record = await database.query.apiKeys.findFirst({
where: and(
eq(apiKeys.keyHash, keyHash),
isNull(apiKeys.revokedAt),
or(isNull(apiKeys.expiresAt), gt(apiKeys.expiresAt, now)),
),
with: {
user: true,
},
});
if (!record?.user) return null;
await database
.update(apiKeys)
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(apiKeys.id, record.id));
return {
apiKeyId: record.id,
user: record.user,
};
}
+2
View File
@@ -8,6 +8,7 @@ import { expensesRouter } from "~/server/api/routers/expenses";
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
import { paymentsRouter } from "~/server/api/routers/payments";
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
invoiceTemplates: invoiceTemplatesRouter,
payments: paymentsRouter,
recurringInvoices: recurringInvoicesRouter,
apiKeys: apiKeysRouter,
});
// export type definition of API
+122
View File
@@ -0,0 +1,122 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import {
createApiKeySecret,
getApiKeyDisplayPrefix,
hashApiKey,
} from "~/server/api/api-keys";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { apiKeys } from "~/server/db/schema";
function requireSessionAuth(ctx: { authSource: "session" | "api-key" | "none" }) {
if (ctx.authSource !== "session") {
throw new TRPCError({
code: "FORBIDDEN",
message: "API keys can only be managed from an authenticated session",
});
}
}
export const apiKeysRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
requireSessionAuth(ctx);
return ctx.db.query.apiKeys.findMany({
where: eq(apiKeys.userId, ctx.session.user.id),
columns: {
id: true,
name: true,
keyPrefix: true,
lastUsedAt: true,
expiresAt: true,
revokedAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: (apiKeys, { desc }) => [desc(apiKeys.createdAt)],
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string().trim().min(1).max(100),
expiresAt: z.date().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
if (input.expiresAt && input.expiresAt <= new Date()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Expiration must be in the future",
});
}
const key = createApiKeySecret();
const [apiKey] = await ctx.db
.insert(apiKeys)
.values({
name: input.name,
keyHash: hashApiKey(key),
keyPrefix: getApiKeyDisplayPrefix(key),
userId: ctx.session.user.id,
expiresAt: input.expiresAt ?? null,
})
.returning({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
});
if (!apiKey) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create API key",
});
}
return { ...apiKey, key };
}),
revoke: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requireSessionAuth(ctx);
const now = new Date();
const [apiKey] = await ctx.db
.update(apiKeys)
.set({ revokedAt: now, updatedAt: now })
.where(
and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.session.user.id)),
)
.returning({ id: apiKeys.id });
if (!apiKey) {
throw new TRPCError({
code: "NOT_FOUND",
message: "API key not found",
});
}
return { success: true };
}),
revokeAll: protectedProcedure.mutation(async ({ ctx }) => {
requireSessionAuth(ctx);
const now = new Date();
await ctx.db
.update(apiKeys)
.set({ revokedAt: now, updatedAt: now })
.where(eq(apiKeys.userId, ctx.session.user.id));
return { success: true };
}),
});
+22
View File
@@ -13,6 +13,7 @@ import { ZodError } from "zod";
import { auth } from "~/lib/auth";
import { db } from "~/server/db";
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
/**
* 1. CONTEXT
@@ -27,6 +28,25 @@ import { db } from "~/server/db";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const bearerToken = getBearerToken(opts.headers);
if (bearerToken) {
const apiKeyAuth = await getUserForApiKey(db, bearerToken);
if (apiKeyAuth) {
return {
db,
session: {
user: apiKeyAuth.user,
session: null,
},
authSource: "api-key" as const,
apiKeyId: apiKeyAuth.apiKeyId,
...opts,
};
}
}
const session = await auth.api.getSession({
headers: opts.headers,
});
@@ -34,6 +54,8 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
return {
db,
session,
authSource: session?.user ? ("session" as const) : ("none" as const),
apiKeyId: null,
...opts,
};
};
+37
View File
@@ -81,6 +81,7 @@ export const platformSettings = createTable("platform_setting", (d) => ({
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
apiKeys: many(apiKeys),
clients: many(clients),
businesses: many(businesses),
invoices: many(invoices),
@@ -155,6 +156,42 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const apiKeys = createTable(
"api_key",
(d) => ({
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.varchar({ length: 100 }).notNull(),
keyHash: d.varchar({ length: 64 }).notNull().unique(),
keyPrefix: d.varchar({ length: 16 }).notNull(),
userId: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
lastUsedAt: d.timestamp(),
expiresAt: d.timestamp(),
revokedAt: d.timestamp(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}),
(t) => [
index("api_key_hash_idx").on(t.keyHash),
index("api_key_user_id_idx").on(t.userId),
index("api_key_revoked_at_idx").on(t.revokedAt),
],
);
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
user: one(users, { fields: [apiKeys.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
(d) => ({