diff options
| author | Fuwn <[email protected]> | 2026-02-03 23:41:22 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-04 00:29:28 -0800 |
| commit | 3ae36eeadd8b1f6287ed55060b46ce82947b56b7 (patch) | |
| tree | 51b0183580aaf12ccb80c0792cc5b185994179ce /packages | |
| parent | style: Organise imports across packages (diff) | |
| download | archived-imemio-3ae36eeadd8b1f6287ed55060b46ce82947b56b7.tar.xz archived-imemio-3ae36eeadd8b1f6287ed55060b46ce82947b56b7.zip | |
feat: Add API key authentication for MCP server
Diffstat (limited to 'packages')
20 files changed, 1058 insertions, 41 deletions
diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts index 86c413b..66937da 100644 --- a/packages/mcp/src/index.test.ts +++ b/packages/mcp/src/index.test.ts @@ -900,4 +900,224 @@ describe("MCP Server Tool Handlers", () => { expect(result).toBeUndefined(); }); }); + describe("API Key Authentication", () => { + it("validates API key format correctly", () => { + function isValidApiKeyFormat(apiKey: string): boolean { + const prefix = "imemio_"; + + if (!apiKey.startsWith(prefix)) { + return false; + } + + const keyPart = apiKey.slice(prefix.length); + + return keyPart.length === 32 && /^[0-9a-f]+$/i.test(keyPart); + } + + expect(isValidApiKeyFormat("imemio_0cee07c39ef7ba7d8e23e760929ce7bc")).toBe( + true, + ); + expect(isValidApiKeyFormat("invalid_key")).toBe(false); + expect(isValidApiKeyFormat("imemio_short")).toBe(false); + expect(isValidApiKeyFormat("sk_0cee07c39ef7ba7d8e23e760929ce7bc")).toBe( + false, + ); + }); + it("returns userId from direct environment variable", async () => { + interface AuthResult { + userId: string; + useServiceRole: boolean; + } + + function resolveAuthFromDirectUserId( + directUserId: string | undefined, + ): AuthResult | null { + if (directUserId) { + return { userId: directUserId, useServiceRole: true }; + } + + return null; + } + + const result = resolveAuthFromDirectUserId("user-123"); + + expect(result).not.toBeNull(); + expect(result?.userId).toBe("user-123"); + expect(result?.useServiceRole).toBe(true); + }); + it("returns null when no direct userId is provided", () => { + interface AuthResult { + userId: string; + useServiceRole: boolean; + } + + function resolveAuthFromDirectUserId( + directUserId: string | undefined, + ): AuthResult | null { + if (directUserId) { + return { userId: directUserId, useServiceRole: true }; + } + + return null; + } + + const result = resolveAuthFromDirectUserId(undefined); + + expect(result).toBeNull(); + }); + it("validates API key and returns userId on success", async () => { + interface AuthResult { + userId: string; + useServiceRole: boolean; + } + interface ValidationResult { + valid: boolean; + userId?: string; + error?: string; + } + + async function resolveAuthFromApiKey( + apiKey: string, + validator: { validate: (key: string) => Promise<ValidationResult> }, + ): Promise<AuthResult> { + const result = await validator.validate(apiKey); + + if (!result.valid || !result.userId) { + throw new Error(`API key validation failed: ${result.error}`); + } + + return { userId: result.userId, useServiceRole: true }; + } + + const mockValidator = { + validate: vi.fn().mockResolvedValue({ + valid: true, + userId: "user-456", + apiKeyId: "key-789", + }), + }; + const result = await resolveAuthFromApiKey( + "imemio_0cee07c39ef7ba7d8e23e760929ce7bc", + mockValidator, + ); + + expect(result.userId).toBe("user-456"); + expect(result.useServiceRole).toBe(true); + expect(mockValidator.validate).toHaveBeenCalledWith( + "imemio_0cee07c39ef7ba7d8e23e760929ce7bc", + ); + }); + it("throws error when API key validation fails", async () => { + interface ValidationResult { + valid: boolean; + userId?: string; + error?: string; + } + + async function resolveAuthFromApiKey( + apiKey: string, + validator: { validate: (key: string) => Promise<ValidationResult> }, + ): Promise<{ userId: string; useServiceRole: boolean }> { + const result = await validator.validate(apiKey); + + if (!result.valid || !result.userId) { + throw new Error(`API key validation failed: ${result.error}`); + } + + return { userId: result.userId, useServiceRole: true }; + } + + const mockValidator = { + validate: vi.fn().mockResolvedValue({ + valid: false, + error: "Invalid or revoked API key", + }), + }; + + await expect( + resolveAuthFromApiKey("imemio_invalid", mockValidator), + ).rejects.toThrow("API key validation failed: Invalid or revoked API key"); + }); + it("throws error for invalid API key format", () => { + function validateApiKeyFormat(apiKey: string): void { + const prefix = "imemio_"; + + if (!apiKey.startsWith(prefix)) { + throw new Error( + "Invalid IMEMIO_API_KEY format. Expected: imemio_<32 hex chars>", + ); + } + + const keyPart = apiKey.slice(prefix.length); + + if (keyPart.length !== 32 || !/^[0-9a-f]+$/i.test(keyPart)) { + throw new Error( + "Invalid IMEMIO_API_KEY format. Expected: imemio_<32 hex chars>", + ); + } + } + + expect(() => validateApiKeyFormat("invalid_key")).toThrow( + "Invalid IMEMIO_API_KEY format", + ); + expect(() => validateApiKeyFormat("imemio_short")).toThrow( + "Invalid IMEMIO_API_KEY format", + ); + expect(() => + validateApiKeyFormat("imemio_0cee07c39ef7ba7d8e23e760929ce7bc"), + ).not.toThrow(); + }); + it("uses service role key when authenticated via API key", () => { + interface AuthResult { + userId: string; + useServiceRole: boolean; + } + + function selectSupabaseKey( + authResult: AuthResult, + anonKey: string, + serviceRoleKey: string, + ): string { + return authResult.useServiceRole ? serviceRoleKey : anonKey; + } + + const authResult: AuthResult = { + userId: "user-123", + useServiceRole: true, + }; + const selectedKey = selectSupabaseKey( + authResult, + "anon-key", + "service-role-key", + ); + + expect(selectedKey).toBe("service-role-key"); + }); + it("uses anon key when useServiceRole is false", () => { + interface AuthResult { + userId: string; + useServiceRole: boolean; + } + + function selectSupabaseKey( + authResult: AuthResult, + anonKey: string, + serviceRoleKey: string, + ): string { + return authResult.useServiceRole ? serviceRoleKey : anonKey; + } + + const authResult: AuthResult = { + userId: "user-123", + useServiceRole: false, + }; + const selectedKey = selectSupabaseKey( + authResult, + "anon-key", + "service-role-key", + ); + + expect(selectedKey).toBe("anon-key"); + }); + }); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d851699..e2abe5d 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,8 +2,10 @@ import { createSupabaseClient, + EdgeFunctionApiKeyValidator, type EmbeddingProvider, EmbeddingService, + isValidApiKeyFormat, type LocalEmbeddingModel, LocalEmbeddingProvider, SupabaseProjectStore, @@ -46,9 +48,57 @@ function createEmbeddingProvider(): EmbeddingProvider | null { return null; } +interface AuthResult { + userId: string; + useServiceRole: boolean; +} + +async function resolveAuth( + supabaseUrl: string, + supabaseAnonKey: string, +): Promise<AuthResult> { + const directUserId = getOptionalEnvironmentVariable("IMEMIO_USER_ID"); + + if (directUserId) { + return { userId: directUserId, useServiceRole: true }; + } + + const apiKey = getOptionalEnvironmentVariable("IMEMIO_API_KEY"); + + if (apiKey) { + if (!isValidApiKeyFormat(apiKey)) { + throw new Error( + "Invalid IMEMIO_API_KEY format. Expected: imemio_<32 hex chars>", + ); + } + + const validator = new EdgeFunctionApiKeyValidator( + supabaseUrl, + supabaseAnonKey, + ); + const result = await validator.validate(apiKey); + + if (!result.valid) { + throw new Error(`API key validation failed: ${result.error}`); + } + + console.error(`Authenticated via API key for user: ${result.userId}`); + + return { userId: result.userId, useServiceRole: true }; + } + + throw new Error( + "Missing authentication. Set either IMEMIO_API_KEY or IMEMIO_USER_ID environment variable.", + ); +} + const supabaseUrl = getRequiredEnvironmentVariable("SUPABASE_URL"); -const supabaseKey = getRequiredEnvironmentVariable("SUPABASE_ANON_KEY"); -const userId = getRequiredEnvironmentVariable("IMEMIO_USER_ID"); +const supabaseAnonKey = getRequiredEnvironmentVariable("SUPABASE_ANON_KEY"); +const authResult = await resolveAuth(supabaseUrl, supabaseAnonKey); +const supabaseKey = authResult.useServiceRole + ? getRequiredEnvironmentVariable("SUPABASE_SERVICE_ROLE_KEY") + : supabaseAnonKey; +const userId = authResult.userId; const client = createSupabaseClient(supabaseUrl, supabaseKey); const memoryStore = new SupabaseStore(client, userId); const projectStore = new SupabaseProjectStore(client, userId); @@ -232,17 +282,29 @@ server.tool( isGlobal: z.boolean().optional().describe("Whether the project is global"), }, async (parameters) => { - const result = await projectStore.create({ + const createResult = await projectStore.create({ name: parameters.name, description: parameters.description, isGlobal: parameters.isGlobal, }); + if (!createResult.success) { + return { + content: [ + { + type: "text" as const, + text: "Failed to create project", + }, + ], + isError: true, + }; + } + return { content: [ { type: "text" as const, - text: JSON.stringify(result.value, null, 2), + text: JSON.stringify(createResult.value, null, 2), }, ], }; @@ -349,13 +411,25 @@ server.tool( }, ); server.tool("list_projects", "List all projects", {}, async () => { - const result = await projectStore.list(); + const listResult = await projectStore.list(); + + if (!listResult.success) { + return { + content: [ + { + type: "text" as const, + text: "Failed to list projects", + }, + ], + isError: true, + }; + } return { content: [ { type: "text" as const, - text: JSON.stringify(result.value, null, 2), + text: JSON.stringify(listResult.value, null, 2), }, ], }; diff --git a/packages/sdk/src/api-key-validator.ts b/packages/sdk/src/api-key-validator.ts new file mode 100644 index 0000000..0aa13c0 --- /dev/null +++ b/packages/sdk/src/api-key-validator.ts @@ -0,0 +1,59 @@ +export type ApiKeyValidationResult = + | { + valid: true; + userId: string; + apiKeyId: string; + } + | { + valid: false; + error: string; + }; + +export interface ApiKeyValidator { + validate(apiKey: string): Promise<ApiKeyValidationResult>; +} + +export class EdgeFunctionApiKeyValidator implements ApiKeyValidator { + private supabaseUrl: string; + private supabaseAnonKey: string; + + constructor(supabaseUrl: string, supabaseAnonKey: string) { + this.supabaseUrl = supabaseUrl; + this.supabaseAnonKey = supabaseAnonKey; + } + + async validate(apiKey: string): Promise<ApiKeyValidationResult> { + try { + const response = await fetch( + `${this.supabaseUrl}/functions/v1/validate-api-key`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.supabaseAnonKey}`, + }, + body: JSON.stringify({ apiKey }), + }, + ); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + + return { valid: false, error: errorData.error ?? "Validation failed" }; + } + + const data = (await response.json()) as { + userId: string; + apiKeyId: string; + }; + + return { + valid: true, + userId: data.userId, + apiKeyId: data.apiKeyId, + }; + } catch { + return { valid: false, error: "Failed to validate API key" }; + } + } +} diff --git a/packages/sdk/src/api-key.test.ts b/packages/sdk/src/api-key.test.ts new file mode 100644 index 0000000..1ff815d --- /dev/null +++ b/packages/sdk/src/api-key.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from "vitest"; +import { + API_KEY_PREFIX, + extractKeyPrefix, + generateApiKey, + hashApiKey, + isValidApiKeyFormat, +} from "./api-key.js"; +import { EdgeFunctionApiKeyValidator } from "./api-key-validator.js"; + +describe("API Key Generation", () => { + describe("generateApiKey", () => { + it("generates a key with the correct prefix", () => { + const apiKey = generateApiKey(); + + expect(apiKey.startsWith(API_KEY_PREFIX)).toBe(true); + }); + it("generates a key with 32 hex characters after prefix", () => { + const apiKey = generateApiKey(); + const keyPart = apiKey.slice(API_KEY_PREFIX.length); + + expect(keyPart.length).toBe(32); + expect(/^[0-9a-f]+$/i.test(keyPart)).toBe(true); + }); + it("generates unique keys on each call", () => { + const apiKey1 = generateApiKey(); + const apiKey2 = generateApiKey(); + + expect(apiKey1).not.toBe(apiKey2); + }); + it("generates keys with total length of prefix + 32 characters", () => { + const apiKey = generateApiKey(); + + expect(apiKey.length).toBe(API_KEY_PREFIX.length + 32); + }); + }); + describe("extractKeyPrefix", () => { + it("extracts first 8 characters after prefix", () => { + const apiKey = "imemio_0cee07c39ef7ba7d8e23e760929ce7bc"; + const prefix = extractKeyPrefix(apiKey); + + expect(prefix).toBe("0cee07c3"); + }); + it("throws error for invalid key format", () => { + expect(() => extractKeyPrefix("invalid_key")).toThrow( + "Invalid API key format", + ); + }); + it("throws error for key without imemio prefix", () => { + expect(() => extractKeyPrefix("sk_0cee07c39ef7ba7d")).toThrow( + "Invalid API key format", + ); + }); + }); + describe("hashApiKey", () => { + it("returns a 64 character hex string", async () => { + const apiKey = "imemio_0cee07c39ef7ba7d8e23e760929ce7bc"; + const hash = await hashApiKey(apiKey); + + expect(hash.length).toBe(64); + expect(/^[0-9a-f]+$/i.test(hash)).toBe(true); + }); + it("returns consistent hash for same input", async () => { + const apiKey = "imemio_0cee07c39ef7ba7d8e23e760929ce7bc"; + const hash1 = await hashApiKey(apiKey); + const hash2 = await hashApiKey(apiKey); + + expect(hash1).toBe(hash2); + }); + it("returns different hashes for different inputs", async () => { + const apiKey1 = "imemio_0cee07c39ef7ba7d8e23e760929ce7bc"; + const apiKey2 = "imemio_1111111111111111111111111111111a"; + const hash1 = await hashApiKey(apiKey1); + const hash2 = await hashApiKey(apiKey2); + + expect(hash1).not.toBe(hash2); + }); + }); + describe("isValidApiKeyFormat", () => { + it("returns true for valid API key", () => { + const apiKey = "imemio_0cee07c39ef7ba7d8e23e760929ce7bc"; + + expect(isValidApiKeyFormat(apiKey)).toBe(true); + }); + it("returns false for key without prefix", () => { + const apiKey = "0cee07c39ef7ba7d8e23e760929ce7bc"; + + expect(isValidApiKeyFormat(apiKey)).toBe(false); + }); + it("returns false for key with wrong prefix", () => { + const apiKey = "sk_0cee07c39ef7ba7d8e23e760929ce7bc"; + + expect(isValidApiKeyFormat(apiKey)).toBe(false); + }); + it("returns false for key with too few hex characters", () => { + const apiKey = "imemio_0cee07c3"; + + expect(isValidApiKeyFormat(apiKey)).toBe(false); + }); + it("returns false for key with too many hex characters", () => { + const apiKey = "imemio_0cee07c39ef7ba7d8e23e760929ce7bc00"; + + expect(isValidApiKeyFormat(apiKey)).toBe(false); + }); + it("returns false for key with non-hex characters", () => { + const apiKey = "imemio_0cee07c39ef7ba7d8e23e760929cexyz"; + + expect(isValidApiKeyFormat(apiKey)).toBe(false); + }); + it("accepts uppercase hex characters", () => { + const apiKey = "imemio_0CEE07C39EF7BA7D8E23E760929CE7BC"; + + expect(isValidApiKeyFormat(apiKey)).toBe(true); + }); + }); +}); +describe("EdgeFunctionApiKeyValidator", () => { + const mockSupabaseUrl = "https://test.supabase.co"; + const mockSupabaseAnonKey = "test-anon-key"; + + describe("validate", () => { + it("returns valid result with userId and apiKeyId on success", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + userId: "user-123", + apiKeyId: "key-456", + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const validator = new EdgeFunctionApiKeyValidator( + mockSupabaseUrl, + mockSupabaseAnonKey, + ); + const result = await validator.validate( + "imemio_0cee07c39ef7ba7d8e23e760929ce7bc", + ); + + expect(result.valid).toBe(true); + + if (result.valid) { + expect(result.userId).toBe("user-123"); + expect(result.apiKeyId).toBe("key-456"); + } + }); + it("sends correct request to Edge Function", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + userId: "user-123", + apiKeyId: "key-456", + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const validator = new EdgeFunctionApiKeyValidator( + mockSupabaseUrl, + mockSupabaseAnonKey, + ); + + await validator.validate("imemio_0cee07c39ef7ba7d8e23e760929ce7bc"); + expect(fetch).toHaveBeenCalledWith( + `${mockSupabaseUrl}/functions/v1/validate-api-key`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${mockSupabaseAnonKey}`, + }, + body: JSON.stringify({ + apiKey: "imemio_0cee07c39ef7ba7d8e23e760929ce7bc", + }), + }, + ); + }); + it("returns invalid result with error on non-ok response", async () => { + const mockResponse = { + ok: false, + json: vi.fn().mockResolvedValue({ + error: "Invalid or revoked API key", + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const validator = new EdgeFunctionApiKeyValidator( + mockSupabaseUrl, + mockSupabaseAnonKey, + ); + const result = await validator.validate("imemio_invalid"); + + expect(result.valid).toBe(false); + + if (!result.valid) { + expect(result.error).toBe("Invalid or revoked API key"); + } + }); + it("returns default error message when response has no error field", async () => { + const mockResponse = { + ok: false, + json: vi.fn().mockResolvedValue({}), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const validator = new EdgeFunctionApiKeyValidator( + mockSupabaseUrl, + mockSupabaseAnonKey, + ); + const result = await validator.validate("imemio_invalid"); + + expect(result.valid).toBe(false); + + if (!result.valid) { + expect(result.error).toBe("Validation failed"); + } + }); + it("returns invalid result on network error", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + const validator = new EdgeFunctionApiKeyValidator( + mockSupabaseUrl, + mockSupabaseAnonKey, + ); + const result = await validator.validate( + "imemio_0cee07c39ef7ba7d8e23e760929ce7bc", + ); + + expect(result.valid).toBe(false); + + if (!result.valid) { + expect(result.error).toBe("Failed to validate API key"); + } + }); + }); +}); diff --git a/packages/sdk/src/api-key.ts b/packages/sdk/src/api-key.ts new file mode 100644 index 0000000..9d27e80 --- /dev/null +++ b/packages/sdk/src/api-key.ts @@ -0,0 +1,40 @@ +export const API_KEY_PREFIX = "imemio_"; + +export function generateApiKey(): string { + const randomBytes = new Uint8Array(16); + + crypto.getRandomValues(randomBytes); + + const hexString = Array.from(randomBytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + return `${API_KEY_PREFIX}${hexString}`; +} + +export function extractKeyPrefix(apiKey: string): string { + if (!apiKey.startsWith(API_KEY_PREFIX)) { + throw new Error("Invalid API key format"); + } + + return apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + 8); +} + +export async function hashApiKey(apiKey: string): Promise<string> { + const encoder = new TextEncoder(); + const data = encoder.encode(apiKey); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function isValidApiKeyFormat(apiKey: string): boolean { + if (!apiKey.startsWith(API_KEY_PREFIX)) { + return false; + } + + const keyPart = apiKey.slice(API_KEY_PREFIX.length); + + return keyPart.length === 32 && /^[0-9a-f]+$/i.test(keyPart); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 903c549..5d06f38 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,5 +1,17 @@ export const VERSION = "0.0.1"; +export { + API_KEY_PREFIX, + extractKeyPrefix, + generateApiKey, + hashApiKey, + isValidApiKeyFormat, +} from "./api-key.js"; +export type { + ApiKeyValidationResult, + ApiKeyValidator, +} from "./api-key-validator.js"; +export { EdgeFunctionApiKeyValidator } from "./api-key-validator.js"; export type { EmbeddingProvider } from "./embedding-provider.js"; export { createEmbeddingService, diff --git a/packages/web/src/app/api/auth/signout/route.ts b/packages/web/src/app/api/auth/signout/route.ts new file mode 100644 index 0000000..33288f9 --- /dev/null +++ b/packages/web/src/app/api/auth/signout/route.ts @@ -0,0 +1,12 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "~/lib/supabase/server"; + +export async function GET(request: NextRequest) { + const supabase = await createClient(); + + await supabase.auth.signOut(); + + const url = new URL("/", request.url); + + return NextResponse.redirect(url); +} diff --git a/packages/web/src/app/dashboard/dashboard-content.tsx b/packages/web/src/app/dashboard/dashboard-content.tsx index da681b8..aab2cfd 100644 --- a/packages/web/src/app/dashboard/dashboard-content.tsx +++ b/packages/web/src/app/dashboard/dashboard-content.tsx @@ -99,12 +99,20 @@ export function DashboardContent() { <h1 className="text-2xl tracking-tight text-white"> <span className="text-[#999999]">></span> memory dashboard </h1> - <Link - className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" - href="/" - > - home - </Link> + <div className="flex gap-2"> + <Link + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" + href="/dashboard/settings" + > + settings + </Link> + <Link + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" + href="/" + > + home + </Link> + </div> </div> <section className="w-full"> diff --git a/packages/web/src/app/dashboard/settings/api-key-settings.tsx b/packages/web/src/app/dashboard/settings/api-key-settings.tsx new file mode 100644 index 0000000..53158ab --- /dev/null +++ b/packages/web/src/app/dashboard/settings/api-key-settings.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { api } from "~/trpc/react"; + +function CreateApiKeyForm() { + const [name, setName] = useState(""); + const [newKey, setNewKey] = useState<string | null>(null); + const [copied, setCopied] = useState(false); + const trpcUtilities = api.useUtils(); + const createKey = api.apiKey.create.useMutation({ + onSuccess: async (data) => { + setNewKey(data.apiKey); + setName(""); + await trpcUtilities.apiKey.invalidate(); + }, + }); + const handleCopy = async () => { + if (newKey) { + await navigator.clipboard.writeText(newKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (newKey) { + return ( + <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4"> + <p className="mb-2 text-sm text-[#666666]"> + your new api key (copy it now - you will not be able to see it again): + </p> + <div className="flex gap-2"> + <code className="flex-1 overflow-x-auto border border-[#2a2a2a] bg-[#070707] p-2 text-sm text-white"> + {newKey} + </code> + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-white transition hover:border-[#666666]" + onClick={handleCopy} + type="button" + > + {copied ? "copied!" : "copy"} + </button> + </div> + <button + className="mt-3 border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-[#999999] transition hover:border-[#666666] hover:text-white" + onClick={() => setNewKey(null)} + type="button" + > + done + </button> + </div> + ); + } + + return ( + <form + className="flex w-full gap-2" + onSubmit={(formEvent) => { + formEvent.preventDefault(); + + if (name.trim()) { + createKey.mutate({ name }); + } + }} + > + <input + className="flex-1 border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-2 text-white placeholder:text-[#666666] focus:border-[#666666] focus:outline-none" + onChange={(inputEvent) => setName(inputEvent.target.value)} + placeholder="key name (e.g., development, claude-code)" + value={name} + /> + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-2 text-white transition hover:border-[#666666] disabled:text-[#666666]" + disabled={createKey.isPending || !name.trim()} + type="submit" + > + {createKey.isPending ? "creating ..." : "create key"} + </button> + </form> + ); +} + +function ApiKeyList() { + const [keys] = api.apiKey.list.useSuspenseQuery(); + const trpcUtilities = api.useUtils(); + const revokeKey = api.apiKey.revoke.useMutation({ + onSuccess: async () => { + await trpcUtilities.apiKey.invalidate(); + }, + }); + + if (keys.length === 0) { + return ( + <div className="border border-[#2a2a2a] bg-[#0f0f0f] p-4 text-center"> + <p className="text-[#666666]"> + no api keys yet. create one to use with the mcp server. + </p> + </div> + ); + } + + return ( + <div className="flex w-full flex-col gap-2"> + {keys.map((key) => ( + <div + className="flex items-center justify-between gap-4 border border-[#2a2a2a] bg-[#0f0f0f] p-3" + key={key.id} + > + <div className="flex-1"> + <p className="text-white">{key.name}</p> + <p className="mt-1 text-xs text-[#666666]"> + <code className="text-[#999999]">imemio_{key.keyPrefix} ...</code> |{" "} + {key.lastUsedAt + ? `last used ${key.lastUsedAt.toLocaleDateString()}` + : "never used"} + </p> + </div> + <button + className="border border-[#2a2a2a] bg-[#0f0f0f] px-3 py-1 text-sm text-[#999999] transition hover:border-[#666666] hover:text-white" + disabled={revokeKey.isPending} + onClick={() => revokeKey.mutate({ id: key.id })} + type="button" + > + {revokeKey.isPending ? "revoking ..." : "revoke"} + </button> + </div> + ))} + </div> + ); +} + +export function ApiKeySettings() { + return ( + <div className="flex w-full flex-col gap-4"> + <CreateApiKeyForm /> + <ApiKeyList /> + </div> + ); +} diff --git a/packages/web/src/app/dashboard/settings/page.tsx b/packages/web/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..7319a72 --- /dev/null +++ b/packages/web/src/app/dashboard/settings/page.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getUser } from "~/server/auth"; +import { ApiKeySettings } from "./api-key-settings"; + +export default async function SettingsPage() { + const user = await getUser(); + + if (!user) { + redirect("/auth/sign-in"); + } + + return ( + <main className="flex min-h-screen flex-col items-center bg-[#070707]"> + <div className="container flex max-w-2xl flex-col items-center gap-6 px-4 py-12"> + <div className="flex w-full items-center justify-between"> + <h1 className="text-2xl tracking-tight text-white"> + <span className="text-[#999999]">></span> settings + </h1> + <Link + className="border border-[#2a2a2a] bg-[#0f0f0f] px-4 py-1 text-[#999999] transition hover:border-[#666666] hover:text-white" + href="/dashboard" + > + back + </Link> + </div> + + <section className="w-full"> + <h2 className="mb-3 text-sm text-[#666666]">api keys</h2> + <p className="mb-4 text-sm text-[#666666]"> + generate api keys to authenticate the mcp server with your account. + </p> + <ApiKeySettings /> + </section> + </div> + </main> + ); +} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index bb542b8..a30804c 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -18,7 +18,10 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { return ( <html className={`${jetbrainsMono.variable}`} lang="en"> - <body className="bg-[#070707] font-mono text-white antialiased"> + <body + className="bg-[#070707] font-mono text-white antialiased" + suppressHydrationWarning + > <TRPCReactProvider>{children}</TRPCReactProvider> </body> </html> diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index efb6912..87a6ade 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; -import { getSession } from "~/server/auth"; +import { getUser } from "~/server/auth"; export default async function Home() { - const session = await getSession(); + const user = await getUser(); return ( <main className="flex min-h-screen flex-col items-center justify-center bg-[#070707]"> @@ -23,13 +23,13 @@ export default async function Home() { </Link> <div className="flex flex-col items-center gap-3"> <p className="text-center text-[#666666]"> - {session && <span>logged in as {session.user?.email}</span>} + {user && <span>logged in as {user.email}</span>} </p> <Link className="border border-[#2a2a2a] bg-[#0f0f0f] px-6 py-2 text-white transition hover:border-[#666666]" - href={session ? "/api/auth/signout" : "/auth/sign-in"} + href={user ? "/api/auth/signout" : "/auth/sign-in"} > - {session ? "sign out" : "sign in"} + {user ? "sign out" : "sign in"} </Link> </div> </div> diff --git a/packages/web/src/lib/supabase/middleware.ts b/packages/web/src/lib/supabase/middleware.ts new file mode 100644 index 0000000..45e7d93 --- /dev/null +++ b/packages/web/src/lib/supabase/middleware.ts @@ -0,0 +1,37 @@ +import { createServerClient } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; +import { env } from "~/env"; + +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }); + const supabase = createServerClient( + env.NEXT_PUBLIC_SUPABASE_URL, + env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + for (const { name, value } of cookiesToSet) { + request.cookies.set(name, value); + } + + supabaseResponse = NextResponse.next({ + request, + }); + + for (const { name, value, options } of cookiesToSet) { + supabaseResponse.cookies.set(name, value, options); + } + }, + }, + }, + ); + + await supabase.auth.getUser(); + + return supabaseResponse; +} diff --git a/packages/web/src/lib/supabase/server.ts b/packages/web/src/lib/supabase/server.ts index 86fa648..5f5fdd7 100644 --- a/packages/web/src/lib/supabase/server.ts +++ b/packages/web/src/lib/supabase/server.ts @@ -14,9 +14,11 @@ export async function createClient() { return cookieStore.getAll(); }, setAll(cookiesToSet) { - for (const { name, value, options } of cookiesToSet) { - cookieStore.set(name, value, options); - } + try { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + } catch {} }, }, }, diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts new file mode 100644 index 0000000..4292526 --- /dev/null +++ b/packages/web/src/middleware.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from "next/server"; +import { updateSession } from "~/lib/supabase/middleware"; + +export async function middleware(request: NextRequest) { + return await updateSession(request); +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/packages/web/src/server/api/root.ts b/packages/web/src/server/api/root.ts index b0fd7ba..919ba21 100644 --- a/packages/web/src/server/api/root.ts +++ b/packages/web/src/server/api/root.ts @@ -1,13 +1,10 @@ +import { apiKeyRouter } from "~/server/api/routers/api-key"; import { memoryRouter } from "~/server/api/routers/memory"; import { postRouter } from "~/server/api/routers/post"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; -/** - * This is the primary router for your server. - * - * All routers added in /api/routers should be manually added here. - */ export const appRouter = createTRPCRouter({ + apiKey: apiKeyRouter, memory: memoryRouter, post: postRouter, }); diff --git a/packages/web/src/server/api/routers/api-key.ts b/packages/web/src/server/api/routers/api-key.ts new file mode 100644 index 0000000..37c26d6 --- /dev/null +++ b/packages/web/src/server/api/routers/api-key.ts @@ -0,0 +1,132 @@ +import { extractKeyPrefix, generateApiKey, hashApiKey } from "@imemio/sdk"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createClient } from "~/lib/supabase/server"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; + +export const apiKeyRouter = createTRPCRouter({ + list: protectedProcedure.query(async ({ ctx: context }) => { + const supabase = await createClient(); + const { data, error } = await supabase + .from("api_keys") + .select( + "id, name, key_prefix, last_used_at, expires_at, revoked_at, created_at", + ) + .eq("user_id", context.user.id) + .is("revoked_at", null) + .order("created_at", { ascending: false }); + + if (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch API keys", + }); + } + + return data.map((key) => ({ + id: key.id as string, + name: key.name as string, + keyPrefix: key.key_prefix as string, + lastUsedAt: key.last_used_at + ? new Date(key.last_used_at as string) + : null, + expiresAt: key.expires_at ? new Date(key.expires_at as string) : null, + createdAt: new Date(key.created_at as string), + })); + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(100), + expiresAt: z.date().optional(), + }), + ) + .mutation(async ({ ctx: context, input }) => { + const supabase = await createClient(); + const apiKey = generateApiKey(); + const keyPrefix = extractKeyPrefix(apiKey); + const keyHash = await hashApiKey(apiKey); + const { data, error } = await supabase + .from("api_keys") + .insert({ + user_id: context.user.id, + name: input.name, + key_prefix: keyPrefix, + key_hash: keyHash, + expires_at: input.expiresAt?.toISOString() ?? null, + }) + .select("id, name, key_prefix, created_at") + .single(); + + if (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create API key", + }); + } + + return { + id: data.id as string, + name: data.name as string, + keyPrefix: data.key_prefix as string, + createdAt: new Date(data.created_at as string), + apiKey, + }; + }), + + revoke: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx: context, input }) => { + const supabase = await createClient(); + const { error, count } = await supabase + .from("api_keys") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", input.id) + .eq("user_id", context.user.id) + .is("revoked_at", null); + + if (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to revoke API key", + }); + } + + if (count === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "API key not found", + }); + } + + return { success: true }; + }), + + delete: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx: context, input }) => { + const supabase = await createClient(); + const { error, count } = await supabase + .from("api_keys") + .delete({ count: "exact" }) + .eq("id", input.id) + .eq("user_id", context.user.id); + + if (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to delete API key", + }); + } + + if (count === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "API key not found", + }); + } + + return { success: true }; + }), +}); diff --git a/packages/web/src/server/api/routers/memory.ts b/packages/web/src/server/api/routers/memory.ts index d1bb849..e6d206b 100644 --- a/packages/web/src/server/api/routers/memory.ts +++ b/packages/web/src/server/api/routers/memory.ts @@ -39,9 +39,9 @@ async function getOrCreateDefaultProject( } export const memoryRouter = createTRPCRouter({ - list: protectedProcedure.query(async ({ ctx }) => { + list: protectedProcedure.query(async ({ ctx: context }) => { const supabaseClient = await createClient(); - const memoryStore = new SupabaseStore(supabaseClient, ctx.user.id); + const memoryStore = new SupabaseStore(supabaseClient, context.user.id); const memories = await memoryStore.list(); return memories; @@ -49,13 +49,13 @@ export const memoryRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ content: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { + .mutation(async ({ context, input }) => { const supabaseClient = await createClient(); const projectId = await getOrCreateDefaultProject( supabaseClient, - ctx.user.id, + context.user.id, ); - const memoryStore = new SupabaseStore(supabaseClient, ctx.user.id); + const memoryStore = new SupabaseStore(supabaseClient, context.user.id); const memory = await memoryStore.create({ content: input.content, projectId, @@ -66,9 +66,9 @@ export const memoryRouter = createTRPCRouter({ delete: protectedProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { + .mutation(async ({ context, input }) => { const supabaseClient = await createClient(); - const memoryStore = new SupabaseStore(supabaseClient, ctx.user.id); + const memoryStore = new SupabaseStore(supabaseClient, context.user.id); const deleteResult = await memoryStore.delete(input.id); if (!deleteResult.success) { diff --git a/packages/web/src/server/api/trpc.ts b/packages/web/src/server/api/trpc.ts index 56dcb4c..62897a5 100644 --- a/packages/web/src/server/api/trpc.ts +++ b/packages/web/src/server/api/trpc.ts @@ -127,6 +127,7 @@ export const protectedProcedure = trpcInstance.procedure return next({ ctx: { + ...ctx, user: ctx.user, }, }); diff --git a/packages/web/src/server/auth/index.ts b/packages/web/src/server/auth/index.ts index f94f4e4..08ecaf7 100644 --- a/packages/web/src/server/auth/index.ts +++ b/packages/web/src/server/auth/index.ts @@ -9,11 +9,3 @@ export const getUser = cache(async () => { return user; }); -export const getSession = cache(async () => { - const supabase = await createClient(); - const { - data: { session }, - } = await supabase.auth.getSession(); - - return session; -}); |