From b30fee82f02c94af5d740887b40653f03733ef80 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Tue, 7 Apr 2026 15:04:38 -0500 Subject: [PATCH] feat(server): add Engine RPC API key methods Implements API key management for the Engine RPC endpoint: - apiKey.create: Create a new API key (returns raw key once) - apiKey.get: Get API key metadata by ID - apiKey.list: List API keys for a user - apiKey.revoke: Revoke an API key (soft delete) - apiKey.delete: Permanently delete an API key Adds 8 new schema tests (156 total tests passing). --- packages/server/rpc/engine/api-key.ts | 183 +++++++++++++++++++++ packages/server/rpc/engine/index.ts | 4 +- packages/server/rpc/engine/schemas.test.ts | 81 +++++++++ packages/server/rpc/engine/schemas.ts | 51 ++++++ 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 packages/server/rpc/engine/api-key.ts diff --git a/packages/server/rpc/engine/api-key.ts b/packages/server/rpc/engine/api-key.ts new file mode 100644 index 0000000..229d09d --- /dev/null +++ b/packages/server/rpc/engine/api-key.ts @@ -0,0 +1,183 @@ +/** + * Engine RPC API key methods. + * + * Implements: + * - apiKey.create: Create a new API key (returns raw key once) + * - apiKey.get: Get API key metadata by ID + * - apiKey.list: List API keys for a user + * - apiKey.revoke: Revoke an API key + * - apiKey.delete: Permanently delete an API key + */ +import type { ApiKey } from "@memory-engine/engine"; +import { AppError } from "../errors"; +import { buildRegistry } from "../registry"; +import type { HandlerContext } from "../types"; +import { + type ApiKeyCreateParams, + type ApiKeyDeleteParams, + type ApiKeyGetParams, + type ApiKeyListParams, + type ApiKeyRevokeParams, + apiKeyCreateSchema, + apiKeyDeleteSchema, + apiKeyGetSchema, + apiKeyListSchema, + apiKeyRevokeSchema, +} from "./schemas"; +import { assertEngineContext, type EngineContext } from "./types"; + +// ============================================================================= +// Response Types +// ============================================================================= + +/** + * API key response (serializable, no secret). + */ +interface ApiKeyResponse { + id: string; + userId: string; + lookupId: string; + name: string; + expiresAt: string | null; + lastUsedAt: string | null; + createdAt: string; + revokedAt: string | null; +} + +/** + * Convert an ApiKey to a serializable response. + */ +function toApiKeyResponse(apiKey: ApiKey): ApiKeyResponse { + return { + id: apiKey.id, + userId: apiKey.userId, + lookupId: apiKey.lookupId, + name: apiKey.name, + expiresAt: apiKey.expiresAt?.toISOString() ?? null, + lastUsedAt: apiKey.lastUsedAt?.toISOString() ?? null, + createdAt: apiKey.createdAt.toISOString(), + revokedAt: apiKey.revokedAt?.toISOString() ?? null, + }; +} + +/** + * API key create response (includes raw key). + */ +interface ApiKeyCreateResponse { + apiKey: ApiKeyResponse; + /** The raw API key - only returned on creation, store securely! */ + rawKey: string; +} + +// ============================================================================= +// Method Handlers +// ============================================================================= + +/** + * apiKey.create - Create a new API key. + * Returns the raw key once - it cannot be retrieved again. + */ +async function apiKeyCreate( + params: ApiKeyCreateParams, + context: HandlerContext, +): Promise { + assertEngineContext(context); + const { db } = context as EngineContext; + + const result = await db.createApiKey({ + userId: params.userId, + name: params.name, + expiresAt: params.expiresAt ? new Date(params.expiresAt) : undefined, + }); + + return { + apiKey: toApiKeyResponse(result.apiKey), + rawKey: result.rawKey, + }; +} + +/** + * apiKey.get - Get API key metadata by ID. + */ +async function apiKeyGet( + params: ApiKeyGetParams, + context: HandlerContext, +): Promise { + assertEngineContext(context); + const { db } = context as EngineContext; + + const apiKey = await db.getApiKey(params.id); + if (!apiKey) { + throw new AppError("NOT_FOUND", `API key not found: ${params.id}`); + } + + return toApiKeyResponse(apiKey); +} + +/** + * apiKey.list - List API keys for a user. + */ +async function apiKeyList( + params: ApiKeyListParams, + context: HandlerContext, +): Promise<{ apiKeys: ApiKeyResponse[] }> { + assertEngineContext(context); + const { db } = context as EngineContext; + + const apiKeys = await db.listApiKeys(params.userId); + return { apiKeys: apiKeys.map(toApiKeyResponse) }; +} + +/** + * apiKey.revoke - Revoke an API key (soft delete). + */ +async function apiKeyRevoke( + params: ApiKeyRevokeParams, + context: HandlerContext, +): Promise<{ revoked: boolean }> { + assertEngineContext(context); + const { db } = context as EngineContext; + + const revoked = await db.revokeApiKey(params.id); + if (!revoked) { + throw new AppError( + "NOT_FOUND", + `API key not found or already revoked: ${params.id}`, + ); + } + + return { revoked }; +} + +/** + * apiKey.delete - Permanently delete an API key. + */ +async function apiKeyDelete( + params: ApiKeyDeleteParams, + context: HandlerContext, +): Promise<{ deleted: boolean }> { + assertEngineContext(context); + const { db } = context as EngineContext; + + const deleted = await db.deleteApiKey(params.id); + if (!deleted) { + throw new AppError("NOT_FOUND", `API key not found: ${params.id}`); + } + + return { deleted }; +} + +// ============================================================================= +// Registry +// ============================================================================= + +/** + * Build the API key methods registry. + */ +export const apiKeyMethods = buildRegistry() + .register("apiKey.create", apiKeyCreateSchema, apiKeyCreate) + .register("apiKey.get", apiKeyGetSchema, apiKeyGet) + .register("apiKey.list", apiKeyListSchema, apiKeyList) + .register("apiKey.revoke", apiKeyRevokeSchema, apiKeyRevoke) + .register("apiKey.delete", apiKeyDeleteSchema, apiKeyDelete) + .build(); diff --git a/packages/server/rpc/engine/index.ts b/packages/server/rpc/engine/index.ts index 0654928..2dd0047 100644 --- a/packages/server/rpc/engine/index.ts +++ b/packages/server/rpc/engine/index.ts @@ -1,4 +1,5 @@ import { buildRegistry } from "../registry"; +import { apiKeyMethods } from "./api-key"; import { grantMethods } from "./grant"; import { memoryMethods } from "./memory"; import { roleMethods } from "./role"; @@ -17,13 +18,14 @@ import { userMethods } from "./user"; * - role.create, role.addMember, role.removeMember, role.listMembers, role.listForUser * * API key methods (chunk 5): - * - apiKey.create, apiKey.list, apiKey.revoke + * - apiKey.create, apiKey.get, apiKey.list, apiKey.revoke, apiKey.delete */ export const engineMethods = buildRegistry() .merge(memoryMethods) .merge(userMethods) .merge(grantMethods) .merge(roleMethods) + .merge(apiKeyMethods) .build(); // Re-export types for consumers diff --git a/packages/server/rpc/engine/schemas.test.ts b/packages/server/rpc/engine/schemas.test.ts index e026e92..7183ee6 100644 --- a/packages/server/rpc/engine/schemas.test.ts +++ b/packages/server/rpc/engine/schemas.test.ts @@ -3,6 +3,11 @@ */ import { describe, expect, test } from "bun:test"; import { + apiKeyCreateSchema, + apiKeyDeleteSchema, + apiKeyGetSchema, + apiKeyListSchema, + apiKeyRevokeSchema, grantCheckSchema, grantCreateSchema, grantListSchema, @@ -636,3 +641,79 @@ describe("roleListForUserSchema", () => { expect(result.success).toBe(true); }); }); + +// ============================================================================= +// API Key Schema Tests +// ============================================================================= + +describe("apiKeyCreateSchema", () => { + test("accepts minimal params", () => { + const result = apiKeyCreateSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + name: "my-api-key", + }); + expect(result.success).toBe(true); + }); + + test("accepts with expiration", () => { + const result = apiKeyCreateSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + name: "my-api-key", + expiresAt: "2025-12-31T23:59:59Z", + }); + expect(result.success).toBe(true); + }); + + test("rejects empty name", () => { + const result = apiKeyCreateSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + name: "", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid expiration timestamp", () => { + const result = apiKeyCreateSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + name: "my-api-key", + expiresAt: "not-a-timestamp", + }); + expect(result.success).toBe(false); + }); +}); + +describe("apiKeyGetSchema", () => { + test("accepts valid UUID", () => { + const result = apiKeyGetSchema.safeParse({ + id: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(true); + }); +}); + +describe("apiKeyListSchema", () => { + test("accepts valid userId", () => { + const result = apiKeyListSchema.safeParse({ + userId: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(true); + }); +}); + +describe("apiKeyRevokeSchema", () => { + test("accepts valid UUID", () => { + const result = apiKeyRevokeSchema.safeParse({ + id: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(true); + }); +}); + +describe("apiKeyDeleteSchema", () => { + test("accepts valid UUID", () => { + const result = apiKeyDeleteSchema.safeParse({ + id: "019d694f-79f6-7595-8faf-b70b01c11f98", + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/server/rpc/engine/schemas.ts b/packages/server/rpc/engine/schemas.ts index 2c182c2..b6b0176 100644 --- a/packages/server/rpc/engine/schemas.ts +++ b/packages/server/rpc/engine/schemas.ts @@ -375,3 +375,54 @@ export const roleListForUserSchema = z.object({ }); export type RoleListForUserParams = z.infer; + +// ============================================================================= +// API Key Method Schemas +// ============================================================================= + +/** + * apiKey.create params. + */ +export const apiKeyCreateSchema = z.object({ + userId: uuidv7Schema, + name: z.string().min(1, "name is required"), + expiresAt: timestampSchema.optional().nullable(), +}); + +export type ApiKeyCreateParams = z.infer; + +/** + * apiKey.get params. + */ +export const apiKeyGetSchema = z.object({ + id: uuidv7Schema, +}); + +export type ApiKeyGetParams = z.infer; + +/** + * apiKey.list params. + */ +export const apiKeyListSchema = z.object({ + userId: uuidv7Schema, +}); + +export type ApiKeyListParams = z.infer; + +/** + * apiKey.revoke params. + */ +export const apiKeyRevokeSchema = z.object({ + id: uuidv7Schema, +}); + +export type ApiKeyRevokeParams = z.infer; + +/** + * apiKey.delete params. + */ +export const apiKeyDeleteSchema = z.object({ + id: uuidv7Schema, +}); + +export type ApiKeyDeleteParams = z.infer;