Merge MCP API access
This commit is contained in:
@@ -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");
|
||||||
@@ -64,6 +64,13 @@
|
|||||||
"when": 1747526400000,
|
"when": 1747526400000,
|
||||||
"tag": "0008_payments_recurring_public_links",
|
"tag": "0008_payments_recurring_public_links",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780617600000,
|
||||||
|
"tag": "0009_api_keys",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
themePresets,
|
||||||
type InterfaceTheme,
|
type InterfaceTheme,
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
|
import { ApiAccessSettings } from "./api-access-settings";
|
||||||
|
|
||||||
const PdfPreviewFrame = dynamic(
|
const PdfPreviewFrame = dynamic(
|
||||||
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
|
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
|
||||||
@@ -492,10 +493,11 @@ export function SettingsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="general">
|
<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="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
||||||
<TabsTrigger value="data">Data</TabsTrigger>
|
<TabsTrigger value="data">Data</TabsTrigger>
|
||||||
|
<TabsTrigger value="api">API</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="general" className="space-y-8">
|
<TabsContent value="general" className="space-y-8">
|
||||||
@@ -1648,6 +1650,10 @@ export function SettingsContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="api" className="space-y-8">
|
||||||
|
<ApiAccessSettings />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
|
||||||
import { paymentsRouter } from "~/server/api/routers/payments";
|
import { paymentsRouter } from "~/server/api/routers/payments";
|
||||||
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
|
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
|
||||||
|
import { apiKeysRouter } from "~/server/api/routers/apiKeys";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
invoiceTemplates: invoiceTemplatesRouter,
|
invoiceTemplates: invoiceTemplatesRouter,
|
||||||
payments: paymentsRouter,
|
payments: paymentsRouter,
|
||||||
recurringInvoices: recurringInvoicesRouter,
|
recurringInvoices: recurringInvoicesRouter,
|
||||||
|
apiKeys: apiKeysRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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 { auth } from "~/lib/auth";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. CONTEXT
|
* 1. CONTEXT
|
||||||
@@ -27,6 +28,25 @@ import { db } from "~/server/db";
|
|||||||
* @see https://trpc.io/docs/server/context
|
* @see https://trpc.io/docs/server/context
|
||||||
*/
|
*/
|
||||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
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({
|
const session = await auth.api.getSession({
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
});
|
});
|
||||||
@@ -34,6 +54,8 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
|||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
session,
|
session,
|
||||||
|
authSource: session?.user ? ("session" as const) : ("none" as const),
|
||||||
|
apiKeyId: null,
|
||||||
...opts,
|
...opts,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const platformSettings = createTable("platform_setting", (d) => ({
|
|||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
accounts: many(accounts),
|
accounts: many(accounts),
|
||||||
|
apiKeys: many(apiKeys),
|
||||||
clients: many(clients),
|
clients: many(clients),
|
||||||
businesses: many(businesses),
|
businesses: many(businesses),
|
||||||
invoices: many(invoices),
|
invoices: many(invoices),
|
||||||
@@ -155,6 +156,42 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
|
|||||||
user: one(users, { fields: [sessions.userId], references: [users.id] }),
|
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(
|
export const verificationTokens = createTable(
|
||||||
"verification_token",
|
"verification_token",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user