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
+27
View File
@@ -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");
+8 -1
View File
@@ -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
}
]
}
}
+526
View File
@@ -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<typeof createCaller>;
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<string, unknown>;
schema: ZodType;
handler: (input: unknown, caller: McpCaller) => Promise<unknown>;
};
function defineTool<TInput>(tool: {
description: string;
inputSchema: Record<string, unknown>;
schema: ZodType<TInput>;
handler: (input: TInput, caller: McpCaller) => Promise<unknown>;
}): 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<typeof invoiceItemSchema>[]) {
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<string, ToolDefinition>;
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);
}
@@ -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<string | null>(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 (
<div className="space-y-8">
<Card className="form-section bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
API Access
</CardTitle>
<CardDescription>
Manage API keys for MCP clients and direct tRPC access
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleCreateKey} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="api-key-name">Key Name</Label>
<div className="flex flex-col gap-3 sm:flex-row">
<Input
id="api-key-name"
value={keyName}
onChange={(event) => setKeyName(event.target.value)}
placeholder="Claude Desktop"
maxLength={100}
/>
<Button
type="submit"
disabled={createApiKey.isPending}
className="w-full sm:w-auto"
>
<Plus className="mr-2 h-4 w-4" />
{createApiKey.isPending ? "Creating..." : "Create"}
</Button>
</div>
</div>
</form>
<div className="space-y-2">
<Label htmlFor="mcp-endpoint">MCP Endpoint</Label>
<div className="flex flex-col gap-3 sm:flex-row">
<Input id="mcp-endpoint" value={endpoint} readOnly />
<Button
type="button"
variant="outline"
onClick={() => void copyText(endpoint, "Endpoint")}
className="w-full sm:w-auto"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
{createdKey && (
<div className="border-primary/30 bg-primary/5 space-y-3 border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">New API key</p>
<p className="text-muted-foreground text-sm">
This key is shown once.
</p>
</div>
<Badge variant="outline">Bearer</Badge>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Input value={createdKey} readOnly className="font-mono text-sm" />
<Button
type="button"
onClick={() => void copyText(createdKey, "API key")}
className="w-full sm:w-auto"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">Active Keys</h3>
<Badge variant="secondary">{apiKeys.length}</Badge>
</div>
{isLoading ? (
<div className="text-muted-foreground border p-4 text-sm">
Loading keys...
</div>
) : apiKeys.length === 0 ? (
<div className="text-muted-foreground border p-4 text-sm">
No API keys created.
</div>
) : (
<div className="divide-border border">
{apiKeys.map((apiKey) => {
const revoked = Boolean(apiKey.revokedAt);
return (
<div
key={apiKey.id}
className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">
{apiKey.name}
</p>
<Badge variant={revoked ? "destructive" : "outline"}>
{revoked ? "Revoked" : apiKey.keyPrefix}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Created {formatApiKeyDate(apiKey.createdAt)} · Last
used {formatApiKeyDate(apiKey.lastUsedAt)}
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={revoked || revokeApiKey.isPending}
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" />
Revoke
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API key?</AlertDialogTitle>
<AlertDialogDescription>
This will immediately block requests using{" "}
<span className="font-medium">{apiKey.name}</span>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
revokeApiKey.mutate({ id: apiKey.id })
}
>
Revoke Key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -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 (
<Tabs defaultValue="general">
<TabsList className="bg-muted/50 grid w-full grid-cols-3">
<TabsList className="bg-muted/50 grid w-full grid-cols-4">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="api">API</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-8">
@@ -1648,6 +1650,10 @@ export function SettingsContent() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="api" className="space-y-8">
<ApiAccessSettings />
</TabsContent>
</Tabs>
);
}
+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) => ({