Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions packages/server/rpc/engine/api-key.ts
Original file line number Diff line number Diff line change
@@ -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<ApiKeyCreateResponse> {
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<ApiKeyResponse> {
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();
4 changes: 3 additions & 1 deletion packages/server/rpc/engine/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down
81 changes: 81 additions & 0 deletions packages/server/rpc/engine/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
*/
import { describe, expect, test } from "bun:test";
import {
apiKeyCreateSchema,
apiKeyDeleteSchema,
apiKeyGetSchema,
apiKeyListSchema,
apiKeyRevokeSchema,
grantCheckSchema,
grantCreateSchema,
grantListSchema,
Expand Down Expand Up @@ -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);
});
});
51 changes: 51 additions & 0 deletions packages/server/rpc/engine/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,54 @@ export const roleListForUserSchema = z.object({
});

export type RoleListForUserParams = z.infer<typeof roleListForUserSchema>;

// =============================================================================
// 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<typeof apiKeyCreateSchema>;

/**
* apiKey.get params.
*/
export const apiKeyGetSchema = z.object({
id: uuidv7Schema,
});

export type ApiKeyGetParams = z.infer<typeof apiKeyGetSchema>;

/**
* apiKey.list params.
*/
export const apiKeyListSchema = z.object({
userId: uuidv7Schema,
});

export type ApiKeyListParams = z.infer<typeof apiKeyListSchema>;

/**
* apiKey.revoke params.
*/
export const apiKeyRevokeSchema = z.object({
id: uuidv7Schema,
});

export type ApiKeyRevokeParams = z.infer<typeof apiKeyRevokeSchema>;

/**
* apiKey.delete params.
*/
export const apiKeyDeleteSchema = z.object({
id: uuidv7Schema,
});

export type ApiKeyDeleteParams = z.infer<typeof apiKeyDeleteSchema>;