diff --git a/cloud/db/database.ts b/cloud/db/database.ts index 57cb6d2f0e..ca7ac8c4e1 100644 --- a/cloud/db/database.ts +++ b/cloud/db/database.ts @@ -51,6 +51,7 @@ import { ApiKeys } from "@/db/api-keys"; import { Traces } from "@/db/traces"; import { Functions } from "@/db/functions"; import { Annotations } from "@/db/annotations"; +import { RouterRequests } from "@/db/router-requests"; import { Payments, type StripeConfig } from "@/payments"; /** @@ -62,17 +63,27 @@ export interface TracesService extends Ready { readonly annotations: Ready; } +/** + * Type definition for the API keys service with nested router requests. + * + * Access pattern: `db.organizations.projects.environments.apiKeys.routerRequests.create(...)` + */ +export interface ApiKeysService extends Ready { + readonly routerRequests: Ready; +} + /** * Type definition for the environments service with nested API keys, traces, and functions. * * Access pattern: * - API Keys: `db.organizations.projects.environments.apiKeys.create(...)` + * - Router Requests: `db.organizations.projects.environments.apiKeys.routerRequests.create(...)` * - Traces: `db.organizations.projects.environments.traces.create(...)` * - Annotations: `db.organizations.projects.environments.traces.annotations.create(...)` * - Functions: `db.organizations.projects.environments.functions.create(...)` */ export interface EnvironmentsService extends Ready { - readonly apiKeys: Ready; + readonly apiKeys: ApiKeysService; readonly traces: TracesService; readonly functions: Ready; } @@ -164,6 +175,7 @@ export class Database extends Context.Tag("Database")< const tracesService = new Traces(projectMemberships); const functionsService = new Functions(projectMemberships); const annotationsService = new Annotations(projectMemberships); + const routerRequestsService = new RouterRequests(projectMemberships); return { users: provideDependencies(new Users()), @@ -176,7 +188,10 @@ export class Database extends Context.Tag("Database")< memberships: provideDependencies(projectMemberships), environments: { ...provideDependencies(environmentsService), - apiKeys: provideDependencies(apiKeysService), + apiKeys: { + ...provideDependencies(apiKeysService), + routerRequests: provideDependencies(routerRequestsService), + }, traces: { ...provideDependencies(tracesService), annotations: provideDependencies(annotationsService), diff --git a/cloud/db/router-requests.test.ts b/cloud/db/router-requests.test.ts new file mode 100644 index 0000000000..7357645754 --- /dev/null +++ b/cloud/db/router-requests.test.ts @@ -0,0 +1,1159 @@ +import { + describe, + it, + expect, + TestEnvironmentFixture, + TestApiKeyFixture, + MockDrizzleORM, +} from "@/tests/db"; +import { Effect } from "effect"; +import { Database } from "@/db"; +import { DatabaseError, NotFoundError, PermissionDeniedError } from "@/errors"; +import type { CreateRouterRequest } from "@/db/router-requests"; + +describe("RouterRequests", () => { + const createRouterRequestInput = ( + overrides: Partial = {}, + ): CreateRouterRequest => ({ + provider: "anthropic", + model: "claude-3-opus-20240229", + status: "pending", + ...overrides, + }); + + describe("create", () => { + it.effect("creates router request successfully", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + // Create an API key first + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const input = createRouterRequestInput(); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: input, + }, + ); + + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-3-opus-20240229"); + expect(result.organizationId).toBe(envFixture.org.id); + expect(result.status).toBe("pending"); + }), + ); + + it.effect("creates router request with all optional fields", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const input = createRouterRequestInput({ + requestId: "req_123", + inputTokens: 100n, + outputTokens: 50n, + cacheReadTokens: 10n, + cacheWriteTokens: 5n, + cacheWriteBreakdown: { ephemeral5m: 5 }, + costCenticents: 12345n, + errorMessage: null, + }); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: input, + }, + ); + + expect(result.requestId).toBe("req_123"); + expect(result.inputTokens).toBe(100n); + expect(result.outputTokens).toBe(50n); + expect(result.cacheReadTokens).toBe(10n); + expect(result.cacheWriteTokens).toBe(5n); + expect(result.cacheWriteBreakdown).toEqual({ ephemeral5m: 5 }); + expect(result.costCenticents).toBe(12345n); + }), + ); + + describe("authorization", () => { + it.effect("allows OWNER role", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const input = createRouterRequestInput(); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: input, + }, + ); + + expect(result.provider).toBe("anthropic"); + }), + ); + + it.effect("allows ADMIN role", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const input = createRouterRequestInput(); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.admin.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: input, + }, + ); + + expect(result.provider).toBe("anthropic"); + }), + ); + + it.effect("returns PermissionDeniedError for VIEWER role", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const input = createRouterRequestInput(); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .create({ + userId: envFixture.projectViewer.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: input, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(PermissionDeniedError); + expect(result.message).toContain("permission to create"); + }), + ); + + it.effect("returns NotFoundError for non-member", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const input = createRouterRequestInput(); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .create({ + userId: envFixture.nonMember.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: input, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + }), + ); + }); + }); + + describe("findAll", () => { + it.effect("returns all router requests for organization", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey1 = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key-1" }, + }); + + const apiKey2 = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key-2" }, + }); + + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey1.id, + data: createRouterRequestInput({ + provider: "anthropic", + model: "claude-3-opus", + }), + }, + ); + + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey2.id, + data: createRouterRequestInput({ + provider: "openai", + model: "gpt-4", + }), + }, + ); + + const results = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findAll( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey1.id, + }, + ); + + expect(results.length).toBe(2); + }), + ); + + it.effect("returns empty array when no router requests exist", () => + Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = + yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "test-key" }, + }); + + const results = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findAll( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + }, + ); + + expect(results.length).toBe(0); + }), + ); + + describe("authorization", () => { + it.effect("allows OWNER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const results = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findAll( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + }, + ); + + expect(results.length).toBe(1); + }), + ); + + it.effect("allows ADMIN role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const results = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findAll( + { + userId: envFixture.admin.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + }, + ); + + expect(results.length).toBe(1); + }), + ); + + it.effect("allows VIEWER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const results = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findAll( + { + userId: envFixture.projectViewer.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + }, + ); + + expect(results.length).toBe(1); + }), + ); + + it.effect("returns NotFoundError for non-member", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .findAll({ + userId: envFixture.nonMember.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + }), + ); + }); + }); + + describe("findById", () => { + it.effect("returns router request by ID", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput({ + provider: "anthropic", + model: "claude-3-opus", + }), + }, + ); + + const found = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findById( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }, + ); + + expect(found.id).toBe(created.id); + expect(found.provider).toBe("anthropic"); + expect(found.model).toBe("claude-3-opus"); + }), + ); + + it.effect("returns NotFoundError when router request not found", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .findById({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: "00000000-0000-0000-0000-000000000000", + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + expect(result.message).toContain("not found"); + }), + ); + + describe("authorization", () => { + it.effect("allows OWNER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const found = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findById( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }, + ); + + expect(found.id).toBe(created.id); + }), + ); + + it.effect("allows ADMIN role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const found = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findById( + { + userId: envFixture.admin.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }, + ); + + expect(found.id).toBe(created.id); + }), + ); + + it.effect("allows VIEWER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const found = + yield* db.organizations.projects.environments.apiKeys.routerRequests.findById( + { + userId: envFixture.projectViewer.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }, + ); + + expect(found.id).toBe(created.id); + }), + ); + + it.effect("returns NotFoundError for non-member", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .findById({ + userId: envFixture.nonMember.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + }), + ); + }); + }); + + describe("update", () => { + it.effect("updates router request successfully", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const updated = + yield* db.organizations.projects.environments.apiKeys.routerRequests.update( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + data: { + status: "success", + inputTokens: 100n, + outputTokens: 50n, + costCenticents: 12345n, + completedAt: new Date(), + }, + }, + ); + + expect(updated.id).toBe(created.id); + expect(updated.status).toBe("success"); + expect(updated.inputTokens).toBe(100n); + expect(updated.outputTokens).toBe(50n); + expect(updated.costCenticents).toBe(12345n); + }), + ); + + it.effect("allows DEVELOPER role to update", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const updated = + yield* db.organizations.projects.environments.apiKeys.routerRequests.update( + { + userId: envFixture.projectDeveloper.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + data: { status: "success" }, + }, + ); + + expect(updated.status).toBe("success"); + }), + ); + + it.effect("returns PermissionDeniedError for VIEWER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .update({ + userId: envFixture.projectViewer.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + data: { status: "success" }, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(PermissionDeniedError); + expect(result.message).toContain("permission to update"); + }), + ); + + it.effect("returns NotFoundError for non-member", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .update({ + userId: envFixture.nonMember.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + data: { status: "success" }, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + }), + ); + }); + + describe("delete", () => { + it.effect("returns PermissionDeniedError for OWNER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .delete({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(PermissionDeniedError); + expect(result.message).toContain("permission to delete"); + }), + ); + + it.effect("returns PermissionDeniedError for ADMIN role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .delete({ + userId: envFixture.projectAdmin.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(PermissionDeniedError); + expect(result.message).toContain("permission to delete"); + }), + ); + + it.effect("returns PermissionDeniedError for DEVELOPER role", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .delete({ + userId: envFixture.projectDeveloper.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(PermissionDeniedError); + expect(result.message).toContain("permission to delete"); + }), + ); + + it.effect("returns NotFoundError for non-member", () => + Effect.gen(function* () { + const { apiKey, ...envFixture } = yield* TestApiKeyFixture; + const db = yield* Database; + + const created = + yield* db.organizations.projects.environments.apiKeys.routerRequests.create( + { + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + data: createRouterRequestInput(), + }, + ); + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .delete({ + userId: envFixture.nonMember.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + apiKeyId: apiKey.id, + routerRequestId: created.id, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + }), + ); + }); + + describe("DatabaseError", () => { + it.effect("returns DatabaseError when create fails", () => + Effect.gen(function* () { + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .create({ + userId: "owner-id", + organizationId: "org-id", + projectId: "project-id", + environmentId: "env-id", + apiKeyId: "key-id", + data: { + provider: "anthropic", + model: "claude-3-opus", + }, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(DatabaseError); + expect(result.message).toBe("Failed to create router request"); + }).pipe( + Effect.provide( + new MockDrizzleORM() + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([{ id: "project-id" }]) + .insert(new Error("insert failed")) + .build(), + ), + ), + ); + + it.effect("returns DatabaseError when findAll fails", () => + Effect.gen(function* () { + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .findAll({ + userId: "owner-id", + organizationId: "org-id", + projectId: "project-id", + environmentId: "env-id", + apiKeyId: "key-id", + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(DatabaseError); + expect(result.message).toBe("Failed to find all router requests"); + }).pipe( + Effect.provide( + new MockDrizzleORM() + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([{ id: "project-id" }]) + .select(new Error("select failed")) + .build(), + ), + ), + ); + + it.effect("returns DatabaseError when findById fails", () => + Effect.gen(function* () { + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .findById({ + userId: "owner-id", + organizationId: "org-id", + projectId: "project-id", + environmentId: "env-id", + apiKeyId: "key-id", + routerRequestId: "request-id", + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(DatabaseError); + expect(result.message).toBe("Failed to find router request"); + }).pipe( + Effect.provide( + new MockDrizzleORM() + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([{ id: "project-id" }]) + .select(new Error("select failed")) + .build(), + ), + ), + ); + + it.effect("returns DatabaseError when update fails", () => + Effect.gen(function* () { + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .update({ + userId: "owner-id", + organizationId: "org-id", + projectId: "project-id", + environmentId: "env-id", + apiKeyId: "key-id", + routerRequestId: "request-id", + data: { status: "success" }, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(DatabaseError); + expect(result.message).toBe("Failed to update router request"); + }).pipe( + Effect.provide( + new MockDrizzleORM() + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([{ id: "project-id" }]) + .update(new Error("update failed")) + .build(), + ), + ), + ); + + it.effect("returns NotFoundError when update finds no router request", () => + Effect.gen(function* () { + const db = yield* Database; + + const result = + yield* db.organizations.projects.environments.apiKeys.routerRequests + .update({ + userId: "owner-id", + organizationId: "org-id", + projectId: "project-id", + environmentId: "env-id", + apiKeyId: "key-id", + routerRequestId: "nonexistent-id", + data: { status: "success" }, + }) + .pipe(Effect.flip); + + expect(result).toBeInstanceOf(NotFoundError); + expect(result.message).toContain("not found"); + }).pipe( + Effect.provide( + new MockDrizzleORM() + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([ + { + role: "OWNER", + organizationId: "org-id", + memberId: "owner-id", + createdAt: new Date(), + }, + ]) + .select([{ id: "project-id" }]) + .update([]) + .build(), + ), + ), + ); + }); +}); diff --git a/cloud/db/router-requests.ts b/cloud/db/router-requests.ts new file mode 100644 index 0000000000..9043374c4b --- /dev/null +++ b/cloud/db/router-requests.ts @@ -0,0 +1,616 @@ +/** + * @fileoverview Effect-native Router Requests service. + * + * Provides authenticated CRUD operations for router requests with role-based + * access control. Router requests are immutable financial records that track + * all LLM requests made through the router. + * + * ## Architecture + * + * ``` + * RouterRequests (authenticated) + * └── nested under API Keys + * └── authorization via ProjectMemberships.getRole() + * ``` + * + * ## Router Request Roles + * + * Router requests use the project's role system: + * - `ADMIN` - Full access (create, read, update) + * - `DEVELOPER` - Full access (create, read, update) + * - `VIEWER` - Read-only access (read) + * - `ANNOTATOR` - Read-only access (read) + * + * ## Implicit Access + * + * Organization OWNER and ADMIN roles have implicit ADMIN access to all projects + * (and thus all environments and router requests) within their organization. + * + * ## Immutability + * + * Router requests are financial records: + * - Can only update certain fields (status, usage, cost, completion data) + * - Core identity fields (provider, model, IDs) are immutable + * - Cannot be deleted (regulatory compliance requires audit trail) + * + * ## Security Model + * + * - Non-members cannot see router requests (returns NotFoundError) + * - Router requests are queried by environment (not API key) + * - Any user with environment access can see all requests for that environment + * - apiKeyId is part of the path structure but not used for filtering + * - Created via API key authentication during router operations + * + * @example + * ```ts + * const db = yield* Database; + * + * // Create a router request (typically done by router, not end users) + * // Path parameters are injected automatically + * const request = yield* db.organizations.projects.environments.apiKeys.routerRequests.create({ + * userId: "user-123", + * organizationId: "org-456", + * projectId: "proj-789", + * environmentId: "env-012", + * apiKeyId: "key-345", + * data: { + * provider: "anthropic", + * model: "claude-3-opus-20240229", + * status: "pending", + * }, + * }); + * + * // List all router requests for an environment + * const requests = yield* db.organizations.projects.environments.apiKeys.routerRequests.findAll({ + * userId: "user-123", + * organizationId: "org-456", + * projectId: "proj-789", + * environmentId: "env-012", + * apiKeyId: "key-345", + * }); + * + * // Get a specific router request + * const request = yield* db.organizations.projects.environments.apiKeys.routerRequests.findById({ + * userId: "user-123", + * organizationId: "org-456", + * projectId: "proj-789", + * environmentId: "env-012", + * apiKeyId: "key-345", + * routerRequestId: "req-123", + * }); + * ``` + */ + +import { Effect } from "effect"; +import { eq, and, desc } from "drizzle-orm"; +import { + BaseAuthenticatedEffectService, + type PermissionTable, +} from "@/db/base"; +import { DrizzleORM } from "@/db/client"; +import { ProjectMemberships } from "@/db/project-memberships"; +import { DatabaseError, NotFoundError, PermissionDeniedError } from "@/errors"; +import { + routerRequests, + type NewRouterRequest, + type RouterRequest, + type ProjectRole, +} from "@/db/schema"; + +/** + * Public fields to select from the router_requests table. + */ +const publicFields = { + id: routerRequests.id, + provider: routerRequests.provider, + model: routerRequests.model, + requestId: routerRequests.requestId, + inputTokens: routerRequests.inputTokens, + outputTokens: routerRequests.outputTokens, + cacheReadTokens: routerRequests.cacheReadTokens, + cacheWriteTokens: routerRequests.cacheWriteTokens, + cacheWriteBreakdown: routerRequests.cacheWriteBreakdown, + costCenticents: routerRequests.costCenticents, + status: routerRequests.status, + errorMessage: routerRequests.errorMessage, + organizationId: routerRequests.organizationId, + projectId: routerRequests.projectId, + environmentId: routerRequests.environmentId, + apiKeyId: routerRequests.apiKeyId, + userId: routerRequests.userId, + createdAt: routerRequests.createdAt, + completedAt: routerRequests.completedAt, +}; + +/** + * Type for creating router requests. + * Omits path parameters (organizationId, projectId, environmentId, apiKeyId, userId) + * since they are provided via the path and injected automatically. + */ +export type CreateRouterRequest = Omit< + NewRouterRequest, + | "organizationId" + | "projectId" + | "environmentId" + | "apiKeyId" + | "userId" + | "createdAt" +>; + +/** + * Type for updating router requests. + * Only allows updating status, usage metrics, cost, and completion data. + * Core identity fields (provider, model, IDs) are immutable. + */ +export type UpdateRouterRequest = Partial< + Pick< + NewRouterRequest, + | "status" + | "requestId" + | "inputTokens" + | "outputTokens" + | "cacheReadTokens" + | "cacheWriteTokens" + | "cacheWriteBreakdown" + | "costCenticents" + | "errorMessage" + | "completedAt" + > +>; + +/** + * Effect-native Router Requests service. + * + * Provides CRUD operations with role-based access control for router requests. + * Router requests can be updated to add usage data and change status, but cannot + * be deleted (regulatory compliance). Authorization is inherited from project membership. + * + * ## Permission Matrix + * + * | Action | ADMIN | DEVELOPER | VIEWER | ANNOTATOR | + * |----------|-------|-----------|--------|-----------| + * | create | ✓ | ✓ | ✗ | ✗ | + * | read | ✓ | ✓ | ✓ | ✓ | + * | update | ✓ | ✓ | ✗ | ✗ | + * | delete | ✗ | ✗ | ✗ | ✗ | + * + * Note: Delete always fails with PermissionDeniedError (financial records must be retained). + */ +export class RouterRequests extends BaseAuthenticatedEffectService< + RouterRequest, + "organizations/:organizationId/projects/:projectId/environments/:environmentId/api-keys/:apiKeyId/router-requests/:routerRequestId", + CreateRouterRequest, + UpdateRouterRequest, + ProjectRole +> { + private readonly projectMemberships: ProjectMemberships; + + constructor(projectMemberships: ProjectMemberships) { + super(); + this.projectMemberships = projectMemberships; + } + + // --------------------------------------------------------------------------- + // Base Implementation + // --------------------------------------------------------------------------- + + protected getResourceName(): string { + return "router_request"; + } + + protected getPermissionTable(): PermissionTable { + return { + create: ["ADMIN", "DEVELOPER"], + read: ["ADMIN", "DEVELOPER", "VIEWER", "ANNOTATOR"], + update: ["ADMIN", "DEVELOPER"], // For updating status/usage after request completes + delete: [], // No one can delete (financial records must be retained) + }; + } + + // --------------------------------------------------------------------------- + // Role Resolution + // --------------------------------------------------------------------------- + + /** + * Determines the user's effective role for router requests. + * + * Delegates to `ProjectMemberships.getRole` which handles: + * - Org OWNER → treated as project ADMIN + * - Org ADMIN → treated as project ADMIN + * - Explicit project membership role + * - No access → NotFoundError (hides router request existence) + */ + getRole({ + userId, + organizationId, + projectId, + }: { + userId: string; + organizationId: string; + projectId: string; + environmentId?: string; + apiKeyId?: string; + routerRequestId?: string; + }): Effect.Effect< + ProjectRole, + NotFoundError | PermissionDeniedError | DatabaseError, + DrizzleORM + > { + return this.projectMemberships.getRole({ + userId, + organizationId, + projectId, + }); + } + + // --------------------------------------------------------------------------- + // CRUD Operations + // --------------------------------------------------------------------------- + + /** + * Creates a new router request. + * + * Requires ADMIN or DEVELOPER role on the project. + * Router requests are typically created by the router during API request processing, + * not directly by end users. + * + * Path parameters (organizationId, projectId, environmentId, apiKeyId, userId) are + * injected automatically and should not be included in the data parameter. + * + * @param args.userId - The authenticated user (from API key) + * @param args.organizationId - The organization containing the project + * @param args.projectId - The project containing the environment + * @param args.environmentId - The environment making the request + * @param args.apiKeyId - The API key making the request + * @param args.data - Router request data (provider, model, tokens, cost, etc.) + * @returns The created router request + * @throws PermissionDeniedError - If user lacks create permission + * @throws NotFoundError - If the environment doesn't exist or user lacks access + * @throws DatabaseError - If the database operation fails + */ + create({ + userId, + organizationId, + projectId, + environmentId, + apiKeyId, + data, + }: { + userId: string; + organizationId: string; + projectId: string; + environmentId: string; + apiKeyId: string; + data: CreateRouterRequest; + }): Effect.Effect< + RouterRequest, + NotFoundError | PermissionDeniedError | DatabaseError, + DrizzleORM + > { + return Effect.gen(this, function* () { + const client = yield* DrizzleORM; + + // Authorize + yield* this.authorize({ + userId, + action: "create", + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId: "", // Not used for create + }); + + const [routerRequest] = yield* client + .insert(routerRequests) + .values({ + ...data, + userId, + organizationId, + projectId, + environmentId, + apiKeyId, + }) + .returning(publicFields) + .pipe( + Effect.mapError( + (e) => + new DatabaseError({ + message: "Failed to create router request", + cause: e, + }), + ), + ); + + return routerRequest; + }); + } + + /** + * Retrieves all router requests for an environment. + * + * Requires any project role (ADMIN, DEVELOPER, VIEWER, or ANNOTATOR). + * Results are ordered by creation time (most recent first). + * + * Note: apiKeyId is part of the path structure but queries are scoped to + * environment, allowing any user with environment access to see all requests. + * + * @param args.userId - The authenticated user + * @param args.organizationId - The organization containing the project + * @param args.projectId - The project containing the environment + * @param args.environmentId - The environment to list requests for + * @param args.apiKeyId - Part of path structure (not used for filtering) + * @returns Array of router requests for the environment + * @throws NotFoundError - If the environment doesn't exist or user lacks access + * @throws PermissionDeniedError - If the user lacks read permission + * @throws DatabaseError - If the database query fails + */ + findAll({ + userId, + organizationId, + projectId, + environmentId, + apiKeyId, + }: { + userId: string; + organizationId: string; + projectId: string; + environmentId: string; + apiKeyId: string; + }): Effect.Effect< + RouterRequest[], + NotFoundError | PermissionDeniedError | DatabaseError, + DrizzleORM + > { + return Effect.gen(this, function* () { + const client = yield* DrizzleORM; + + // Authorize + yield* this.authorize({ + userId, + action: "read", + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId: "", // Not used for findAll + }); + + return yield* client + .select(publicFields) + .from(routerRequests) + // NOTE: environment ID constrains us to the correct org/project already + .where(eq(routerRequests.environmentId, environmentId)) + .orderBy(desc(routerRequests.createdAt)) + .pipe( + Effect.mapError( + (e) => + new DatabaseError({ + message: "Failed to find all router requests", + cause: e, + }), + ), + ); + }); + } + + /** + * Retrieves a router request by ID. + * + * Requires any project role (ADMIN, DEVELOPER, VIEWER, or ANNOTATOR). + * + * @param args.userId - The authenticated user + * @param args.organizationId - The organization containing the project + * @param args.projectId - The project containing the environment + * @param args.environmentId - The environment containing the request + * @param args.apiKeyId - Part of path structure (not used for filtering) + * @param args.routerRequestId - The router request to retrieve + * @returns The router request + * @throws NotFoundError - If the router request doesn't exist or user lacks access + * @throws PermissionDeniedError - If the user lacks read permission + * @throws DatabaseError - If the database query fails + */ + findById({ + userId, + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId, + }: { + userId: string; + organizationId: string; + projectId: string; + environmentId: string; + apiKeyId: string; + routerRequestId: string; + }): Effect.Effect< + RouterRequest, + NotFoundError | PermissionDeniedError | DatabaseError, + DrizzleORM + > { + return Effect.gen(this, function* () { + const client = yield* DrizzleORM; + + // Authorize + yield* this.authorize({ + userId, + action: "read", + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId, + }); + + const [routerRequest] = yield* client + .select(publicFields) + .from(routerRequests) + .where( + and( + eq(routerRequests.id, routerRequestId), + // NOTE: environment ID constrains us to the correct org/project already + eq(routerRequests.environmentId, environmentId), + ), + ) + .limit(1) + .pipe( + Effect.mapError( + (e) => + new DatabaseError({ + message: "Failed to find router request", + cause: e, + }), + ), + ); + + if (!routerRequest) { + return yield* Effect.fail( + new NotFoundError({ + message: `Router request with routerRequestId ${routerRequestId} not found`, + resource: this.getResourceName(), + }), + ); + } + + return routerRequest; + }); + } + + /** + * Updates a router request with usage data and status. + * + * Requires ADMIN or DEVELOPER role on the project. + * Typically called by the router after a request completes to update: + * - status (pending → success/failure) + * - usage metrics (inputTokens, outputTokens, cacheReadTokens, etc.) + * - cost (costCenticents) + * - completion time (completedAt) + * - error message (errorMessage, if failed) + * + * Core identity fields (provider, model, IDs, createdAt) cannot be modified. + * + * @param args.userId - The authenticated user + * @param args.organizationId - The organization containing the project + * @param args.projectId - The project containing the environment + * @param args.environmentId - The environment containing the request + * @param args.apiKeyId - Part of path structure (not used for filtering) + * @param args.routerRequestId - The router request to update + * @param args.data - Fields to update (status, usage metrics, cost, etc.) + * @returns The updated router request + * @throws NotFoundError - If the router request doesn't exist + * @throws PermissionDeniedError - If the user lacks update permission + * @throws DatabaseError - If the database operation fails + */ + update({ + userId, + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId, + data, + }: { + userId: string; + organizationId: string; + projectId: string; + environmentId: string; + apiKeyId: string; + routerRequestId: string; + data: UpdateRouterRequest; + }): Effect.Effect< + RouterRequest, + NotFoundError | PermissionDeniedError | DatabaseError, + DrizzleORM + > { + return Effect.gen(this, function* () { + const client = yield* DrizzleORM; + + yield* this.authorize({ + userId, + action: "update", + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId, + }); + + const [updated] = yield* client + .update(routerRequests) + .set(data) + .where( + and( + eq(routerRequests.id, routerRequestId), + // NOTE: environment ID constrains us to the correct org/project already + eq(routerRequests.environmentId, environmentId), + ), + ) + .returning(publicFields) + .pipe( + Effect.mapError( + (e) => + new DatabaseError({ + message: "Failed to update router request", + cause: e, + }), + ), + ); + + if (!updated) { + return yield* Effect.fail( + new NotFoundError({ + message: `Router request with routerRequestId ${routerRequestId} not found`, + resource: this.getResourceName(), + }), + ); + } + + return updated; + }); + } + + /** + * Deletes a router request (not supported - financial records must be retained). + * + * Always fails with PermissionDeniedError because no one has permission to delete + * router requests. They are financial records that must be retained for regulatory + * compliance and audit trails. + * + * @throws PermissionDeniedError - Always (no one can delete financial records) + * @throws NotFoundError - If user lacks project access (hides request existence) + */ + delete({ + userId, + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId, + }: { + userId: string; + organizationId: string; + projectId: string; + environmentId: string; + apiKeyId: string; + routerRequestId: string; + }): Effect.Effect< + void, + NotFoundError | PermissionDeniedError | DatabaseError, + DrizzleORM + > { + return Effect.gen(this, function* () { + // Authorize - this will always error since the user is either not a member (NotFoundError) + // or they are a member, but no one has permission to delete (PermissionDeniedError) + yield* this.authorize({ + userId, + action: "delete", + organizationId, + projectId, + environmentId, + apiKeyId, + routerRequestId, + }); + }); + } +} diff --git a/cloud/tests/db.ts b/cloud/tests/db.ts index 6395dba790..972f218541 100644 --- a/cloud/tests/db.ts +++ b/cloud/tests/db.ts @@ -522,6 +522,38 @@ export const TestEnvironmentFixture = Effect.gen(function* () { }; }); +/** + * Effect-native test fixture for API keys. + * + * Creates a test API key within an environment using the Effect-native + * `Database` service. + * + * Reuses `TestEnvironmentFixture` to set up the organization, project, and environment. + * + * Returns all properties from TestEnvironmentFixture plus: + * - apiKey: the created API key (includes the plaintext key) + * + * Requires Database - call `yield* Database` in your test + * if you need to perform additional database operations. + */ +export const TestApiKeyFixture = Effect.gen(function* () { + const envFixture = yield* TestEnvironmentFixture; + const db = yield* Database; + + const apiKey = yield* db.organizations.projects.environments.apiKeys.create({ + userId: envFixture.owner.id, + organizationId: envFixture.org.id, + projectId: envFixture.project.id, + environmentId: envFixture.environment.id, + data: { name: "Test API Key" }, + }); + + return { + ...envFixture, + apiKey, + }; +}); + /** * Effect-native test fixture for spans. *