Archived
Add MCP API access
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user