From 37eb70be65d06eed7871acb07449f8548baf0b59 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 4 Jun 2026 21:33:32 -0400 Subject: [PATCH] Add MCP API access --- drizzle/0009_api_keys.sql | 27 + drizzle/meta/_journal.json | 9 +- src/app/api/mcp/route.ts | 526 ++++++++++++++++++ .../_components/api-access-settings.tsx | 239 ++++++++ .../settings/_components/settings-content.tsx | 8 +- src/server/api/api-keys.ts | 60 ++ src/server/api/root.ts | 2 + src/server/api/routers/apiKeys.ts | 122 ++++ src/server/api/trpc.ts | 22 + src/server/db/schema.ts | 37 ++ 10 files changed, 1050 insertions(+), 2 deletions(-) create mode 100644 drizzle/0009_api_keys.sql create mode 100644 src/app/api/mcp/route.ts create mode 100644 src/app/dashboard/settings/_components/api-access-settings.tsx create mode 100644 src/server/api/api-keys.ts create mode 100644 src/server/api/routers/apiKeys.ts diff --git a/drizzle/0009_api_keys.sql b/drizzle/0009_api_keys.sql new file mode 100644 index 0000000..48a2827 --- /dev/null +++ b/drizzle/0009_api_keys.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS "beenvoice_api_key" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "keyHash" varchar(64) NOT NULL, + "keyPrefix" varchar(16) NOT NULL, + "userId" varchar(255) NOT NULL, + "lastUsedAt" timestamp, + "expiresAt" timestamp, + "revokedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "beenvoice_api_key_keyHash_unique" UNIQUE("keyHash") +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_api_key_userId_beenvoice_user_id_fk' + ) THEN + ALTER TABLE "beenvoice_api_key" ADD CONSTRAINT "beenvoice_api_key_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_key_hash_idx" ON "beenvoice_api_key" USING btree ("keyHash"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_key_user_id_idx" ON "beenvoice_api_key" USING btree ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_key_revoked_at_idx" ON "beenvoice_api_key" USING btree ("revokedAt"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c258cc3..8012b95 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1747526400000, "tag": "0008_payments_recurring_public_links", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1780617600000, + "tag": "0009_api_keys", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts new file mode 100644 index 0000000..9897ba1 --- /dev/null +++ b/src/app/api/mcp/route.ts @@ -0,0 +1,526 @@ +import { TRPCError } from "@trpc/server"; +import { z, type ZodType } from "zod"; + +import { createCaller } from "~/server/api/root"; +import { createTRPCContext } from "~/server/api/trpc"; + +export const runtime = "nodejs"; + +type JsonRpcId = string | number | null; +type ToolResult = { + content: Array<{ type: "text"; text: string }>; +}; +type McpCaller = ReturnType; + +const dateString = z.string().min(1); +const emptyableString = z.string().optional().or(z.literal("")); +const invoiceStatus = z.enum(["draft", "sent", "paid"]); +const paymentMethod = z.enum([ + "cash", + "check", + "bank_transfer", + "credit_card", + "paypal", + "other", +]); + +const invoiceItemSchema = z.object({ + date: dateString, + description: z.string().min(1), + hours: z.number().min(0), + rate: z.number().min(0), +}); + +const clientCreateSchema = z.object({ + name: z.string().min(1).max(255), + email: z.string().email().optional().or(z.literal("")), + phone: z.string().max(50).optional().or(z.literal("")), + addressLine1: z.string().max(255).optional().or(z.literal("")), + addressLine2: z.string().max(255).optional().or(z.literal("")), + city: z.string().max(100).optional().or(z.literal("")), + state: z.string().max(50).optional().or(z.literal("")), + postalCode: z.string().max(20).optional().or(z.literal("")), + country: z.string().max(100).optional().or(z.literal("")), + defaultHourlyRate: z.number().min(0).optional(), + currency: z.string().length(3).optional(), +}); + +const businessCreateSchema = z.object({ + name: z.string().min(1).max(255), + nickname: emptyableString, + email: z.string().email().optional().or(z.literal("")), + phone: emptyableString, + addressLine1: emptyableString, + addressLine2: emptyableString, + city: emptyableString, + state: emptyableString, + postalCode: emptyableString, + country: emptyableString, + website: z.string().url().optional().or(z.literal("")), + taxId: emptyableString, + logoUrl: emptyableString, + isDefault: z.boolean().default(false), +}); + +const invoiceCreateSchema = z.object({ + invoiceNumber: z.string().min(1), + invoicePrefix: z.string().optional(), + businessId: emptyableString, + clientId: z.string().min(1), + issueDate: dateString, + dueDate: dateString, + status: invoiceStatus.default("draft"), + notes: emptyableString, + emailMessage: emptyableString, + taxRate: z.number().min(0).max(100).default(0), + currency: z.string().length(3).default("USD"), + items: z.array(invoiceItemSchema).min(1), +}); + +const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({ + id: z.string(), +}); + +const jsonSchemas = { + empty: { type: "object", properties: {}, additionalProperties: false }, + id: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }, + invoiceId: { + type: "object", + properties: { invoiceId: { type: "string" } }, + required: ["invoiceId"], + additionalProperties: false, + }, + invoiceStatus: { + type: "object", + properties: { + id: { type: "string" }, + status: { type: "string", enum: ["draft", "sent", "paid"] }, + }, + required: ["id", "status"], + additionalProperties: false, + }, + paymentCreate: { + type: "object", + properties: { + invoiceId: { type: "string" }, + amount: { type: "number", exclusiveMinimum: 0 }, + date: { type: "string", format: "date-time" }, + method: { + type: "string", + enum: ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"], + }, + notes: { type: "string", maxLength: 500 }, + }, + required: ["invoiceId", "amount", "date"], + additionalProperties: false, + }, + clientCreate: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 255 }, + email: { type: "string" }, + phone: { type: "string", maxLength: 50 }, + addressLine1: { type: "string", maxLength: 255 }, + addressLine2: { type: "string", maxLength: 255 }, + city: { type: "string", maxLength: 100 }, + state: { type: "string", maxLength: 50 }, + postalCode: { type: "string", maxLength: 20 }, + country: { type: "string", maxLength: 100 }, + defaultHourlyRate: { type: "number", minimum: 0 }, + currency: { type: "string", minLength: 3, maxLength: 3 }, + }, + required: ["name"], + additionalProperties: false, + }, + invoiceCreate: { + type: "object", + properties: { + invoiceNumber: { type: "string", minLength: 1 }, + invoicePrefix: { type: "string" }, + businessId: { type: "string" }, + clientId: { type: "string", minLength: 1 }, + issueDate: { type: "string", format: "date-time" }, + dueDate: { type: "string", format: "date-time" }, + status: { type: "string", enum: ["draft", "sent", "paid"] }, + notes: { type: "string" }, + emailMessage: { type: "string" }, + taxRate: { type: "number", minimum: 0, maximum: 100 }, + currency: { type: "string", minLength: 3, maxLength: 3 }, + items: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: { + date: { type: "string", format: "date-time" }, + description: { type: "string", minLength: 1 }, + hours: { type: "number", minimum: 0 }, + rate: { type: "number", minimum: 0 }, + }, + required: ["date", "description", "hours", "rate"], + additionalProperties: false, + }, + }, + }, + required: ["invoiceNumber", "clientId", "issueDate", "dueDate", "items"], + additionalProperties: false, + }, + businessCreate: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 255 }, + nickname: { type: "string", maxLength: 255 }, + email: { type: "string" }, + phone: { type: "string" }, + addressLine1: { type: "string" }, + addressLine2: { type: "string" }, + city: { type: "string" }, + state: { type: "string" }, + postalCode: { type: "string" }, + country: { type: "string" }, + website: { type: "string" }, + taxId: { type: "string" }, + logoUrl: { type: "string" }, + isDefault: { type: "boolean" }, + }, + required: ["name"], + additionalProperties: false, + }, +} as const; + +type ToolDefinition = { + description: string; + inputSchema: Record; + schema: ZodType; + handler: (input: unknown, caller: McpCaller) => Promise; +}; + +function defineTool(tool: { + description: string; + inputSchema: Record; + schema: ZodType; + handler: (input: TInput, caller: McpCaller) => Promise; +}): ToolDefinition { + return { + description: tool.description, + inputSchema: tool.inputSchema, + schema: tool.schema, + handler: async (input, caller) => tool.handler(input as TInput, caller), + }; +} + +function parseDate(value: string, fieldName: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${fieldName} must be a valid ISO date string`, + }); + } + return date; +} + +function parseInvoiceItems(items: z.infer[]) { + return items.map((item) => ({ + ...item, + date: parseDate(item.date, "item.date"), + })); +} + +function textResult(data: unknown): ToolResult { + return { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; +} + +const tools = { + invoices_list: defineTool({ + description: "List invoices for the authenticated beenvoice user.", + inputSchema: jsonSchemas.empty, + schema: z.object({}).optional().default({}), + handler: async (_input, caller) => caller.invoices.getAll(), + }), + invoices_get: defineTool({ + description: "Get one invoice by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.invoices.getById(input), + }), + invoices_create: defineTool({ + description: "Create an invoice with line items.", + inputSchema: jsonSchemas.invoiceCreate, + schema: invoiceCreateSchema, + handler: async (input, caller) => + caller.invoices.create({ + ...input, + issueDate: parseDate(input.issueDate, "issueDate"), + dueDate: parseDate(input.dueDate, "dueDate"), + items: parseInvoiceItems(input.items), + }), + }), + invoices_update: defineTool({ + description: "Update invoice fields and optionally replace line items.", + inputSchema: { + ...jsonSchemas.invoiceCreate, + required: ["id"], + properties: { + id: { type: "string" }, + ...jsonSchemas.invoiceCreate.properties, + }, + }, + schema: invoiceUpdateSchema, + handler: async (input, caller) => + caller.invoices.update({ + ...input, + issueDate: input.issueDate + ? parseDate(input.issueDate, "issueDate") + : undefined, + dueDate: input.dueDate ? parseDate(input.dueDate, "dueDate") : undefined, + items: input.items ? parseInvoiceItems(input.items) : undefined, + }), + }), + invoices_update_status: defineTool({ + description: "Update an invoice status to draft, sent, or paid.", + inputSchema: jsonSchemas.invoiceStatus, + schema: z.object({ id: z.string(), status: invoiceStatus }), + handler: async (input, caller) => caller.invoices.updateStatus(input), + }), + invoices_delete: defineTool({ + description: "Delete an invoice by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.invoices.delete(input), + }), + payments_list_for_invoice: defineTool({ + description: "List payments recorded for an invoice.", + inputSchema: jsonSchemas.invoiceId, + schema: z.object({ invoiceId: z.string() }), + handler: async (input, caller) => caller.payments.getByInvoice(input), + }), + payments_create: defineTool({ + description: "Record a payment for an invoice.", + inputSchema: jsonSchemas.paymentCreate, + schema: z.object({ + invoiceId: z.string(), + amount: z.number().positive(), + date: dateString, + method: paymentMethod.default("other"), + notes: z.string().max(500).optional(), + }), + handler: async (input, caller) => + caller.payments.create({ + ...input, + date: parseDate(input.date, "date"), + }), + }), + payments_delete: defineTool({ + description: "Delete a payment by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.payments.delete(input), + }), + clients_list: defineTool({ + description: "List clients for the authenticated beenvoice user.", + inputSchema: jsonSchemas.empty, + schema: z.object({}).optional().default({}), + handler: async (_input, caller) => caller.clients.getAll(), + }), + clients_get: defineTool({ + description: "Get one client by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.clients.getById(input), + }), + clients_create: defineTool({ + description: "Create a client.", + inputSchema: jsonSchemas.clientCreate, + schema: clientCreateSchema, + handler: async (input, caller) => caller.clients.create(input), + }), + clients_update: defineTool({ + description: "Update a client.", + inputSchema: { + ...jsonSchemas.clientCreate, + required: ["id"], + properties: { id: { type: "string" }, ...jsonSchemas.clientCreate.properties }, + }, + schema: clientCreateSchema.partial().extend({ id: z.string() }), + handler: async (input, caller) => caller.clients.update(input), + }), + clients_delete: defineTool({ + description: "Delete a client by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.clients.delete(input), + }), + businesses_list: defineTool({ + description: "List businesses for the authenticated beenvoice user.", + inputSchema: jsonSchemas.empty, + schema: z.object({}).optional().default({}), + handler: async (_input, caller) => caller.businesses.getAll(), + }), + businesses_get: defineTool({ + description: "Get one business by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.businesses.getById(input), + }), + businesses_get_default: defineTool({ + description: "Get the user's default business.", + inputSchema: jsonSchemas.empty, + schema: z.object({}).optional().default({}), + handler: async (_input, caller) => caller.businesses.getDefault(), + }), + businesses_create: defineTool({ + description: "Create a business profile.", + inputSchema: jsonSchemas.businessCreate, + schema: businessCreateSchema, + handler: async (input, caller) => caller.businesses.create(input), + }), + businesses_update: defineTool({ + description: "Update a business profile. All business fields should be provided.", + inputSchema: { + ...jsonSchemas.businessCreate, + required: ["id", "name"], + properties: { + id: { type: "string" }, + ...jsonSchemas.businessCreate.properties, + }, + }, + schema: businessCreateSchema.extend({ id: z.string() }), + handler: async (input, caller) => caller.businesses.update(input), + }), + businesses_set_default: defineTool({ + description: "Set a business as the default for new invoices.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.businesses.setDefault(input), + }), + businesses_delete: defineTool({ + description: "Delete a business by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.businesses.delete(input), + }), +} satisfies Record; + +function rpcResult(id: JsonRpcId, result: unknown, init?: ResponseInit) { + return Response.json({ jsonrpc: "2.0", id, result }, init); +} + +function rpcError( + id: JsonRpcId, + code: number, + message: string, + status = 400, + data?: unknown, +) { + return Response.json( + { jsonrpc: "2.0", id, error: { code, message, data } }, + { status }, + ); +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return "Unknown error"; +} + +async function handleMcpRequest(request: Request) { + const body = (await request.json().catch(() => null)) as { + jsonrpc?: unknown; + id?: JsonRpcId; + method?: unknown; + params?: unknown; + } | null; + + if (body?.jsonrpc !== "2.0" || typeof body.method !== "string") { + return rpcError(null, -32600, "Invalid JSON-RPC request"); + } + + if (body.id === undefined) { + return new Response(null, { status: 202 }); + } + + const ctx = await createTRPCContext({ headers: request.headers }); + if (!ctx.session?.user || ctx.authSource !== "api-key") { + return rpcError(body.id, -32001, "A valid beenvoice API key is required", 401); + } + + if (body.method === "initialize") { + return rpcResult(body.id, { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "beenvoice", version: "0.1.0" }, + }); + } + + if (body.method === "ping") { + return rpcResult(body.id, {}); + } + + if (body.method === "tools/list") { + return rpcResult(body.id, { + tools: Object.entries(tools).map(([name, tool]) => ({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }); + } + + if (body.method === "tools/call") { + const params = z + .object({ + name: z.string(), + arguments: z.unknown().optional(), + }) + .safeParse(body.params); + + if (!params.success) { + return rpcError(body.id, -32602, "Invalid tool call parameters", 400); + } + + const tool = tools[params.data.name as keyof typeof tools]; + if (!tool) { + return rpcError(body.id, -32602, `Unknown tool: ${params.data.name}`, 400); + } + + const input = tool.schema.safeParse(params.data.arguments ?? {}); + if (!input.success) { + return rpcError( + body.id, + -32602, + "Invalid tool arguments", + 400, + input.error.flatten(), + ); + } + + try { + const caller = createCaller(async () => ctx); + return rpcResult(body.id, textResult(await tool.handler(input.data, caller))); + } catch (error) { + return rpcError(body.id, -32000, getErrorMessage(error), 500); + } + } + + return rpcError(body.id, -32601, `Method not found: ${body.method}`, 404); +} + +export async function POST(request: Request) { + return handleMcpRequest(request); +} + +export async function GET() { + return rpcError(null, -32000, "Method not allowed", 405); +} + +export async function DELETE() { + return rpcError(null, -32000, "Method not allowed", 405); +} diff --git a/src/app/dashboard/settings/_components/api-access-settings.tsx b/src/app/dashboard/settings/_components/api-access-settings.tsx new file mode 100644 index 0000000..abdd986 --- /dev/null +++ b/src/app/dashboard/settings/_components/api-access-settings.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { Copy, Key, Plus, Trash2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { api } from "~/trpc/react"; + +function formatApiKeyDate(value: Date | string | null) { + if (!value) return "Never"; + return new Date(value).toLocaleString(); +} + +async function copyText(value: string, label: string) { + await navigator.clipboard.writeText(value); + toast.success(`${label} copied`); +} + +export function ApiAccessSettings() { + const utils = api.useUtils(); + const [keyName, setKeyName] = React.useState(""); + const [createdKey, setCreatedKey] = React.useState(null); + const endpoint = + typeof window === "undefined" ? "/api/mcp" : `${window.location.origin}/api/mcp`; + + const { data: apiKeys = [], isLoading } = api.apiKeys.list.useQuery(); + + const createApiKey = api.apiKeys.create.useMutation({ + onSuccess: (result) => { + setCreatedKey(result.key); + setKeyName(""); + toast.success("API key created"); + void utils.apiKeys.list.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Failed to create API key"); + }, + }); + + const revokeApiKey = api.apiKeys.revoke.useMutation({ + onSuccess: () => { + toast.success("API key revoked"); + void utils.apiKeys.list.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Failed to revoke API key"); + }, + }); + + const handleCreateKey = (event: React.FormEvent) => { + event.preventDefault(); + if (!keyName.trim()) { + toast.error("Enter a key name"); + return; + } + createApiKey.mutate({ name: keyName.trim() }); + }; + + return ( +
+ + + + + API Access + + + Manage API keys for MCP clients and direct tRPC access + + + +
+
+ +
+ setKeyName(event.target.value)} + placeholder="Claude Desktop" + maxLength={100} + /> + +
+
+
+ +
+ +
+ + +
+
+ + {createdKey && ( +
+
+
+

New API key

+

+ This key is shown once. +

+
+ Bearer +
+
+ + +
+
+ )} + +
+
+

Active Keys

+ {apiKeys.length} +
+ + {isLoading ? ( +
+ Loading keys... +
+ ) : apiKeys.length === 0 ? ( +
+ No API keys created. +
+ ) : ( +
+ {apiKeys.map((apiKey) => { + const revoked = Boolean(apiKey.revokedAt); + return ( +
+
+
+

+ {apiKey.name} +

+ + {revoked ? "Revoked" : apiKey.keyPrefix} + +
+

+ Created {formatApiKeyDate(apiKey.createdAt)} ยท Last + used {formatApiKeyDate(apiKey.lastUsedAt)} +

+
+ + + + + + + + Revoke API key? + + This will immediately block requests using{" "} + {apiKey.name}. + + + + Cancel + + revokeApiKey.mutate({ id: apiKey.id }) + } + > + Revoke Key + + + + +
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/dashboard/settings/_components/settings-content.tsx b/src/app/dashboard/settings/_components/settings-content.tsx index 43cd1cd..e96ac34 100644 --- a/src/app/dashboard/settings/_components/settings-content.tsx +++ b/src/app/dashboard/settings/_components/settings-content.tsx @@ -93,6 +93,7 @@ import { themePresets, type InterfaceTheme, } from "~/lib/branding"; +import { ApiAccessSettings } from "./api-access-settings"; const PdfPreviewFrame = dynamic( () => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame), @@ -492,10 +493,11 @@ export function SettingsContent() { return ( - + General Preferences Data + API @@ -1648,6 +1650,10 @@ export function SettingsContent() { + + + + ); } diff --git a/src/server/api/api-keys.ts b/src/server/api/api-keys.ts new file mode 100644 index 0000000..f1f7a12 --- /dev/null +++ b/src/server/api/api-keys.ts @@ -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, + }; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 0ea38ad..7c87ee8 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -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 diff --git a/src/server/api/routers/apiKeys.ts b/src/server/api/routers/apiKeys.ts new file mode 100644 index 0000000..90e78c8 --- /dev/null +++ b/src/server/api/routers/apiKeys.ts @@ -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 }; + }), +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index d61553a..3e9fb0e 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -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, }; }; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e96b96c..15cb0a2 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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) => ({