aboutsummaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-03 23:41:22 -0800
committerFuwn <[email protected]>2026-02-04 00:29:28 -0800
commit3ae36eeadd8b1f6287ed55060b46ce82947b56b7 (patch)
tree51b0183580aaf12ccb80c0792cc5b185994179ce /packages
parentstyle: Organise imports across packages (diff)
downloadarchived-imemio-3ae36eeadd8b1f6287ed55060b46ce82947b56b7.tar.xz
archived-imemio-3ae36eeadd8b1f6287ed55060b46ce82947b56b7.zip
feat: Add API key authentication for MCP server
Diffstat (limited to 'packages')
-rw-r--r--packages/mcp/src/index.test.ts220
-rw-r--r--packages/mcp/src/index.ts86
-rw-r--r--packages/sdk/src/api-key-validator.ts59
-rw-r--r--packages/sdk/src/api-key.test.ts239
-rw-r--r--packages/sdk/src/api-key.ts40
-rw-r--r--packages/sdk/src/index.ts12
-rw-r--r--packages/web/src/app/api/auth/signout/route.ts12
-rw-r--r--packages/web/src/app/dashboard/dashboard-content.tsx20
-rw-r--r--packages/web/src/app/dashboard/settings/api-key-settings.tsx139
-rw-r--r--packages/web/src/app/dashboard/settings/page.tsx38
-rw-r--r--packages/web/src/app/layout.tsx5
-rw-r--r--packages/web/src/app/page.tsx10
-rw-r--r--packages/web/src/lib/supabase/middleware.ts37
-rw-r--r--packages/web/src/lib/supabase/server.ts8
-rw-r--r--packages/web/src/middleware.ts12
-rw-r--r--packages/web/src/server/api/root.ts7
-rw-r--r--packages/web/src/server/api/routers/api-key.ts132
-rw-r--r--packages/web/src/server/api/routers/memory.ts14
-rw-r--r--packages/web/src/server/api/trpc.ts1
-rw-r--r--packages/web/src/server/auth/index.ts8
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]">&gt;</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]">&gt;</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;
-});