mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-09 15:22:39 +02:00
ddd refactor: api-keys
This commit is contained in:
parent
8488255d6d
commit
d4f0db1f09
15 changed files with 377 additions and 196 deletions
|
|
@ -8,17 +8,22 @@ import { revalidatePath } from "next/cache";
|
||||||
import { templates } from "../lib/project_templates";
|
import { templates } from "../lib/project_templates";
|
||||||
import { authCheck } from "./auth_actions";
|
import { authCheck } from "./auth_actions";
|
||||||
import { User, WithStringId } from "../lib/types/types";
|
import { User, WithStringId } from "../lib/types/types";
|
||||||
import { ApiKey } from "../lib/types/project_types";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { Project } from "../lib/types/project_types";
|
import { Project } from "../lib/types/project_types";
|
||||||
import { USE_AUTH } from "../lib/feature_flags";
|
import { USE_AUTH } from "../lib/feature_flags";
|
||||||
import { authorizeUserAction } from "./billing_actions";
|
import { authorizeUserAction } from "./billing_actions";
|
||||||
import { Workflow } from "../lib/types/workflow_types";
|
import { Workflow } from "../lib/types/workflow_types";
|
||||||
import { container } from "@/di/container";
|
import { container } from "@/di/container";
|
||||||
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
import { ICreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller";
|
||||||
|
import { IListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller";
|
||||||
|
import { IDeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller";
|
||||||
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
|
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
|
||||||
|
|
||||||
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
|
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
|
||||||
|
const createApiKeyController = container.resolve<ICreateApiKeyController>('createApiKeyController');
|
||||||
|
const listApiKeysController = container.resolve<IListApiKeysController>('listApiKeysController');
|
||||||
|
const deleteApiKeyController = container.resolve<IDeleteApiKeyController>('deleteApiKeyController');
|
||||||
|
|
||||||
export async function listTemplates() {
|
export async function listTemplates() {
|
||||||
const templatesArray = Object.entries(templates)
|
const templatesArray = Object.entries(templates)
|
||||||
|
|
@ -180,36 +185,32 @@ export async function updateWebhookUrl(projectId: string, url: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApiKey(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>> {
|
export async function createApiKey(projectId: string): Promise<z.infer<typeof ApiKey>> {
|
||||||
await projectAuthCheck(projectId);
|
const user = await authCheck();
|
||||||
|
return await createApiKeyController.execute({
|
||||||
// count existing keys
|
caller: 'user',
|
||||||
const count = await apiKeysCollection.countDocuments({ projectId });
|
userId: user._id,
|
||||||
if (count >= 3) {
|
|
||||||
throw new Error('Maximum number of API keys reached');
|
|
||||||
}
|
|
||||||
|
|
||||||
// create key
|
|
||||||
const key = crypto.randomBytes(32).toString('hex');
|
|
||||||
const doc: z.infer<typeof ApiKey> = {
|
|
||||||
projectId,
|
projectId,
|
||||||
key,
|
});
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await apiKeysCollection.insertOne(doc);
|
|
||||||
const { _id, ...rest } = doc as WithStringId<z.infer<typeof ApiKey>>;
|
|
||||||
return { ...rest, _id: _id.toString() };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(projectId: string, id: string) {
|
export async function deleteApiKey(projectId: string, id: string) {
|
||||||
await projectAuthCheck(projectId);
|
const user = await authCheck();
|
||||||
await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) });
|
return await deleteApiKeyController.execute({
|
||||||
|
caller: 'user',
|
||||||
|
userId: user._id,
|
||||||
|
projectId,
|
||||||
|
id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listApiKeys(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>[]> {
|
export async function listApiKeys(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
await projectAuthCheck(projectId);
|
const user = await authCheck();
|
||||||
const keys = await apiKeysCollection.find({ projectId }).toArray();
|
return await listApiKeysController.execute({
|
||||||
return keys.map(k => ({ ...k, _id: k._id.toString() }));
|
caller: 'user',
|
||||||
|
userId: user._id,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProjectName(projectId: string, name: string) {
|
export async function updateProjectName(projectId: string, name: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { MongoClient } from "mongodb";
|
import { MongoClient } from "mongodb";
|
||||||
import { User, Webpage } from "./types/types";
|
import { User, Webpage } from "./types/types";
|
||||||
import { Workflow } from "./types/workflow_types";
|
import { Workflow } from "./types/workflow_types";
|
||||||
import { ApiKey } from "./types/project_types";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { ProjectMember } from "./types/project_types";
|
import { ProjectMember } from "./types/project_types";
|
||||||
import { Project } from "./types/project_types";
|
import { Project } from "./types/project_types";
|
||||||
import { EmbeddingDoc } from "./types/datasource_types";
|
import { EmbeddingDoc } from "./types/datasource_types";
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,3 @@ export const ProjectMember = z.object({
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
lastUpdatedAt: z.string().datetime(),
|
lastUpdatedAt: z.string().datetime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ApiKey = z.object({
|
|
||||||
projectId: z.string(),
|
|
||||||
key: z.string(),
|
|
||||||
createdAt: z.string().datetime(),
|
|
||||||
lastUsedAt: z.string().datetime().optional(),
|
|
||||||
});
|
|
||||||
|
|
@ -4,14 +4,10 @@ import { Metadata } from "next";
|
||||||
import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
|
import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
import { getProjectConfig, updateProjectName, updateWebhookUrl, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
||||||
import { CopyButton } from "../../../../components/common/copy-button";
|
import { CopyButton } from "../../../../components/common/copy-button";
|
||||||
import { InputField } from "../../../lib/components/input-field";
|
import { InputField } from "../../../lib/components/input-field";
|
||||||
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
|
||||||
import { WithStringId } from "../../../lib/types/types";
|
|
||||||
import { ApiKey } from "../../../lib/types/project_types";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { RelativeTime } from "@primer/react";
|
|
||||||
import { Label } from "../../../lib/components/label";
|
import { Label } from "../../../lib/components/label";
|
||||||
import { FormSection } from "../../../lib/components/form-section";
|
import { FormSection } from "../../../lib/components/form-section";
|
||||||
import { Panel } from "@/components/common/panel-common";
|
import { Panel } from "@/components/common/panel-common";
|
||||||
|
|
@ -108,156 +104,6 @@ export function BasicSettingsSection({
|
||||||
</Section>;
|
</Section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiKeysSection({
|
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [message, setMessage] = useState<{
|
|
||||||
type: 'success' | 'error' | 'info';
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadKeys = async () => {
|
|
||||||
const keys = await listApiKeys(projectId);
|
|
||||||
setKeys(keys);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
loadKeys();
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const handleCreateKey = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const key = await createApiKey(projectId);
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: 'API key created successfully',
|
|
||||||
});
|
|
||||||
setKeys([...keys, key]);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessage(null);
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : "Failed to create API key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteKey = async (id: string) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this API key? This action cannot be undone.")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
await deleteApiKey(projectId, id);
|
|
||||||
setKeys(keys.filter((k) => k._id !== id));
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'info',
|
|
||||||
text: 'API key deleted successfully',
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessage(null);
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
setLoading(false);
|
|
||||||
setMessage({
|
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : "Failed to delete API key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Section title="API keys">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
API keys are used to authenticate requests to the Rowboat API.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateKey}
|
|
||||||
size="sm"
|
|
||||||
startContent={<Plus className="h-4 w-4" />}
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Create API key
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
{loading && <Spinner size="sm" />}
|
|
||||||
{!loading && <div className="border border rounded-lg text-sm">
|
|
||||||
<div className="flex items-center border-b border p-4">
|
|
||||||
<div className="flex-3 font-normal">API Key</div>
|
|
||||||
<div className="flex-1 font-normal">Created</div>
|
|
||||||
<div className="flex-1 font-normal">Last Used</div>
|
|
||||||
<div className="w-10"></div>
|
|
||||||
</div>
|
|
||||||
{message?.type === 'success' && <div className="flex flex-col p-2">
|
|
||||||
<div className="text-sm bg-green-50 text-green-500 p-2 rounded-md">{message.text}</div>
|
|
||||||
</div>}
|
|
||||||
{message?.type === 'error' && <div className="flex flex-col p-2">
|
|
||||||
<div className="text-sm bg-red-50 text-red-500 p-2 rounded-md">{message.text}</div>
|
|
||||||
</div>}
|
|
||||||
{message?.type === 'info' && <div className="flex flex-col p-2">
|
|
||||||
<div className="text-sm bg-yellow-50 text-yellow-500 p-2 rounded-md">{message.text}</div>
|
|
||||||
</div>}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{keys.map((key) => (
|
|
||||||
<div key={key._id} className="flex items-start border-b border last:border-b-0 p-4">
|
|
||||||
<div className="flex-3 p-2">
|
|
||||||
<ApiKeyDisplay apiKey={key.key} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-2">
|
|
||||||
<RelativeTime date={new Date(key.createdAt)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-2">
|
|
||||||
{key.lastUsedAt ? <RelativeTime date={new Date(key.lastUsedAt)} /> : 'Never'}
|
|
||||||
</div>
|
|
||||||
<div className="w-10 p-2">
|
|
||||||
<Dropdown>
|
|
||||||
<DropdownTrigger>
|
|
||||||
<button className="text-muted-foreground hover:text-foreground">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownTrigger>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownItem
|
|
||||||
key='delete'
|
|
||||||
className="text-destructive"
|
|
||||||
onPress={() => handleDeleteKey(key._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{keys.length === 0 && (
|
|
||||||
<div className="p-4 text-center text-muted-foreground">
|
|
||||||
No API keys created yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</Section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SecretSection({
|
export function SecretSection({
|
||||||
projectId,
|
projectId,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { getProjectConfig, createApiKey, deleteApiKey, listApiKeys, deleteProjec
|
||||||
import { CopyButton } from "../../../../../components/common/copy-button";
|
import { CopyButton } from "../../../../../components/common/copy-button";
|
||||||
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||||
import { WithStringId } from "../../../../lib/types/types";
|
import { WithStringId } from "../../../../lib/types/types";
|
||||||
import { ApiKey } from "../../../../lib/types/project_types";
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { RelativeTime } from "@primer/react";
|
import { RelativeTime } from "@primer/react";
|
||||||
import { Label } from "../../../../lib/components/label";
|
import { Label } from "../../../../lib/components/label";
|
||||||
|
|
@ -224,7 +224,7 @@ function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => v
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApiKeysSection({ projectId }: { projectId: string }) {
|
function ApiKeysSection({ projectId }: { projectId: string }) {
|
||||||
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
|
const [keys, setKeys] = useState<z.infer<typeof ApiKey>[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: 'success' | 'error' | 'info';
|
type: 'success' | 'error' | 'info';
|
||||||
|
|
@ -270,7 +270,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await deleteApiKey(projectId, id);
|
await deleteApiKey(projectId, id);
|
||||||
setKeys(keys.filter((k) => k._id !== id));
|
setKeys(keys.filter((k) => k.id !== id));
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: 'API key deleted successfully',
|
text: 'API key deleted successfully',
|
||||||
|
|
@ -325,11 +325,11 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<div key={key._id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
|
<div key={key.id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
|
||||||
<div className="col-span-7">
|
<div className="col-span-7">
|
||||||
<ApiKeyDisplay
|
<ApiKeyDisplay
|
||||||
apiKey={key.key}
|
apiKey={key.key}
|
||||||
onDelete={() => handleDeleteKey(key._id)}
|
onDelete={() => handleDeleteKey(key.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 text-sm text-gray-500">
|
<div className="col-span-3 text-sm text-gray-500">
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,14 @@ import { ListRecurringJobRulesController } from "@/src/interface-adapters/contro
|
||||||
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
|
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
|
||||||
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
|
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
|
||||||
|
import { ListApiKeysUseCase } from "@/src/application/use-cases/api-keys/list-api-keys.use-case";
|
||||||
|
import { DeleteApiKeyUseCase } from "@/src/application/use-cases/api-keys/delete-api-key.use-case";
|
||||||
|
import { CreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller";
|
||||||
|
import { ListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller";
|
||||||
|
import { DeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller";
|
||||||
|
|
||||||
export const container = createContainer({
|
export const container = createContainer({
|
||||||
injectionMode: InjectionMode.PROXY,
|
injectionMode: InjectionMode.PROXY,
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|
@ -96,6 +104,12 @@ container.register({
|
||||||
// api keys
|
// api keys
|
||||||
// ---
|
// ---
|
||||||
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(),
|
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(),
|
||||||
|
createApiKeyUseCase: asClass(CreateApiKeyUseCase).singleton(),
|
||||||
|
listApiKeysUseCase: asClass(ListApiKeysUseCase).singleton(),
|
||||||
|
deleteApiKeyUseCase: asClass(DeleteApiKeyUseCase).singleton(),
|
||||||
|
createApiKeyController: asClass(CreateApiKeyController).singleton(),
|
||||||
|
listApiKeysController: asClass(ListApiKeysController).singleton(),
|
||||||
|
deleteApiKeyController: asClass(DeleteApiKeyController).singleton(),
|
||||||
|
|
||||||
// jobs
|
// jobs
|
||||||
// ---
|
// ---
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,41 @@
|
||||||
|
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const CreateSchema = ApiKey.pick({
|
||||||
|
projectId: true,
|
||||||
|
key: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interface for repository operations related to API keys.
|
||||||
export interface IApiKeysRepository {
|
export interface IApiKeysRepository {
|
||||||
|
/**
|
||||||
|
* Creates a new API key for a given project.
|
||||||
|
* @param data - The data required to create an API key (projectId and key).
|
||||||
|
* @returns The created ApiKey object.
|
||||||
|
*/
|
||||||
|
create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof ApiKey>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all API keys for a given project.
|
||||||
|
* @param projectId - The ID of the project whose API keys are to be listed.
|
||||||
|
* @returns A list of ApiKey objects.
|
||||||
|
*/
|
||||||
|
listAll(projectId: string): Promise<z.infer<typeof ApiKey>[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an API key by its ID for a given project.
|
||||||
|
* @param projectId - The ID of the project.
|
||||||
|
* @param id - The ID of the API key to delete.
|
||||||
|
* @returns True if the key was deleted, false if not found.
|
||||||
|
*/
|
||||||
|
delete(projectId: string, id: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an API key is valid for a project and consumes it (e.g., for rate limiting or one-time use).
|
||||||
|
* @param projectId - The ID of the project.
|
||||||
|
* @param apiKey - The API key to check and consume.
|
||||||
|
* @returns True if the key is valid and was consumed, false otherwise.
|
||||||
|
*/
|
||||||
checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean>;
|
checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class MaxKeysReachedError extends BadRequestError {
|
||||||
|
constructor(message?: string, options?: ErrorOptions) {
|
||||||
|
super(message, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateApiKeyUseCase {
|
||||||
|
execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateApiKeyUseCase implements ICreateApiKeyUseCase {
|
||||||
|
private readonly apiKeysRepository: IApiKeysRepository;
|
||||||
|
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
apiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy,
|
||||||
|
}: {
|
||||||
|
apiKeysRepository: IApiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||||
|
}) {
|
||||||
|
this.apiKeysRepository = apiKeysRepository;
|
||||||
|
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>> {
|
||||||
|
const { caller, userId, apiKey, projectId } = data;
|
||||||
|
await this.projectActionAuthorizationPolicy.authorize({
|
||||||
|
caller,
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// count existing keys
|
||||||
|
const keys = await this.apiKeysRepository.listAll(projectId);
|
||||||
|
if (keys.length >= 3) {
|
||||||
|
throw new MaxKeysReachedError("You can only have up to 3 API keys per project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random key using crypto
|
||||||
|
const key = crypto.randomBytes(32).toString('hex');
|
||||||
|
return await this.apiKeysRepository.create({ projectId, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IDeleteApiKeyUseCase {
|
||||||
|
execute(data: z.infer<typeof inputSchema>): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteApiKeyUseCase implements IDeleteApiKeyUseCase {
|
||||||
|
private readonly apiKeysRepository: IApiKeysRepository;
|
||||||
|
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
apiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy,
|
||||||
|
}: {
|
||||||
|
apiKeysRepository: IApiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||||
|
}) {
|
||||||
|
this.apiKeysRepository = apiKeysRepository;
|
||||||
|
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(data: z.infer<typeof inputSchema>): Promise<boolean> {
|
||||||
|
const { caller, userId, apiKey, projectId, id } = data;
|
||||||
|
await this.projectActionAuthorizationPolicy.authorize({
|
||||||
|
caller,
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
return await this.apiKeysRepository.delete(projectId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IListApiKeysUseCase {
|
||||||
|
execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListApiKeysUseCase implements IListApiKeysUseCase {
|
||||||
|
private readonly apiKeysRepository: IApiKeysRepository;
|
||||||
|
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
apiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy,
|
||||||
|
}: {
|
||||||
|
apiKeysRepository: IApiKeysRepository,
|
||||||
|
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||||
|
}) {
|
||||||
|
this.apiKeysRepository = apiKeysRepository;
|
||||||
|
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
|
const { caller, userId, apiKey, projectId } = data;
|
||||||
|
await this.projectActionAuthorizationPolicy.authorize({
|
||||||
|
caller,
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
return await this.apiKeysRepository.listAll(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/rowboat/src/entities/models/api-key.ts
Normal file
9
apps/rowboat/src/entities/models/api-key.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ApiKey = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
lastUsedAt: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,59 @@
|
||||||
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
import { apiKeysCollection } from "@/app/lib/mongodb";
|
import { db } from "@/app/lib/mongodb";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
import { CreateSchema } from "@/src/application/repositories/api-keys.repository.interface";
|
||||||
|
|
||||||
|
const DocSchema = ApiKey
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
});
|
||||||
|
|
||||||
export class MongoDBApiKeysRepository implements IApiKeysRepository {
|
export class MongoDBApiKeysRepository implements IApiKeysRepository {
|
||||||
|
private readonly collection = db.collection<z.infer<typeof DocSchema>>("api_keys");
|
||||||
|
|
||||||
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
|
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
|
||||||
const result = await apiKeysCollection.findOneAndUpdate(
|
const result = await this.collection.findOneAndUpdate(
|
||||||
{ projectId, key: apiKey },
|
{ projectId, key: apiKey },
|
||||||
{ $set: { lastUsedAt: new Date().toISOString() } }
|
{ $set: { lastUsedAt: new Date().toISOString() } }
|
||||||
);
|
);
|
||||||
return !!result;
|
return !!result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof ApiKey>> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const _id = new ObjectId();
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.collection.insertOne({
|
||||||
|
_id,
|
||||||
|
...doc,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
id: _id.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAll(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
|
const results = await this.collection.find({ projectId }).sort({ createdAt: -1 }).toArray();
|
||||||
|
return results.map(doc => {
|
||||||
|
const { _id, ...rest } = doc;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
id: _id.toString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(projectId: string, id: string): Promise<boolean> {
|
||||||
|
const result = await this.collection.deleteOne({ projectId, _id: new ObjectId(id) });
|
||||||
|
return result.deletedCount > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
import z from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { ICreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ICreateApiKeyController {
|
||||||
|
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateApiKeyController implements ICreateApiKeyController {
|
||||||
|
private readonly createApiKeyUseCase: ICreateApiKeyUseCase;
|
||||||
|
constructor({ createApiKeyUseCase }: { createApiKeyUseCase: ICreateApiKeyUseCase }) {
|
||||||
|
this.createApiKeyUseCase = createApiKeyUseCase;
|
||||||
|
}
|
||||||
|
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>> {
|
||||||
|
const result = inputSchema.safeParse(request);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
|
}
|
||||||
|
return await this.createApiKeyUseCase.execute(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { inputSchema as createApiKeyInputSchema };
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
import z from "zod";
|
||||||
|
import { IDeleteApiKeyUseCase } from "@/src/application/use-cases/api-keys/delete-api-key.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IDeleteApiKeyController {
|
||||||
|
execute(request: z.infer<typeof inputSchema>): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteApiKeyController implements IDeleteApiKeyController {
|
||||||
|
private readonly deleteApiKeyUseCase: IDeleteApiKeyUseCase;
|
||||||
|
constructor({ deleteApiKeyUseCase }: { deleteApiKeyUseCase: IDeleteApiKeyUseCase }) {
|
||||||
|
this.deleteApiKeyUseCase = deleteApiKeyUseCase;
|
||||||
|
}
|
||||||
|
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
|
||||||
|
const result = inputSchema.safeParse(request);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
|
}
|
||||||
|
return await this.deleteApiKeyUseCase.execute(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { inputSchema as deleteApiKeyInputSchema };
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BadRequestError } from "@/src/entities/errors/common";
|
||||||
|
import z from "zod";
|
||||||
|
import { ApiKey } from "@/src/entities/models/api-key";
|
||||||
|
import { IListApiKeysUseCase } from "@/src/application/use-cases/api-keys/list-api-keys.use-case";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
caller: z.enum(["user", "api"]),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
projectId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IListApiKeysController {
|
||||||
|
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListApiKeysController implements IListApiKeysController {
|
||||||
|
private readonly listApiKeysUseCase: IListApiKeysUseCase;
|
||||||
|
constructor({ listApiKeysUseCase }: { listApiKeysUseCase: IListApiKeysUseCase }) {
|
||||||
|
this.listApiKeysUseCase = listApiKeysUseCase;
|
||||||
|
}
|
||||||
|
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]> {
|
||||||
|
const result = inputSchema.safeParse(request);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
|
}
|
||||||
|
return await this.listApiKeysUseCase.execute(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export { inputSchema as listApiKeysInputSchema };
|
||||||
Loading…
Add table
Add a link
Reference in a new issue