Skip to content
Open
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
179 changes: 179 additions & 0 deletions src/actions/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"use server";

import "server-only";

import { prisma } from "@/lib/prisma";

type ProjectRow = {
id: string;
name: string;
description: string | null;
apiKey: string;
createdAt: Date;
updatedAt: Date;
};

function generateApiKey(): string {
return crypto.randomUUID();
}

function maskApiKey(apiKey: string): string {
if (apiKey.length <= 8) {
return "•".repeat(apiKey.length);
}
return `•••••••••••••••••••••••••••••••••${apiKey.slice(-4)}`;
}

function toMasked(project: ProjectRow): ProjectMasked {
return {
id: project.id,
name: project.name,
description: project.description,
apiKeyMasked: maskApiKey(project.apiKey),
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
}

export type ProjectMasked = {
id: string;
name: string;
description: string | null;
apiKeyMasked: string;
createdAt: Date;
updatedAt: Date;
};

export type ProjectWithApiKey = {
id: string;
name: string;
description: string | null;
apiKey: string;
createdAt: Date;
updatedAt: Date;
};

export type UpdateProjectData = {
name?: string;
description?: string | null;
};

/** Registers an external app; returns the full API key once. */
export async function createProject(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of the six actions in this file check the caller's identity before touching the DB — this one included. If there's a follow-up ticket adding an authorization layer before this gets exported into a client-facing flow, that's a reasonable way to sequence it; just flagging so it's a deliberate choice. Worth confirming before this (or the action layer that wraps it) goes live, since "use server" exports are reachable as soon as anything imports from this file.

name: string,
description: string | null,
): Promise<ProjectWithApiKey> {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Project name is required");
}

const apiKey = generateApiKey();
const project = await prisma.project.create({
data: {
name: trimmed,
description: description?.trim() || null,
apiKey,
},
});

return {
id: project.id,
name: project.name,
description: project.description,
apiKey: project.apiKey,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
}

/** Single project; API key is masked. */
export async function getProject(id: string): Promise<ProjectMasked | null> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getProject and getProjects (line 98) both load the full plaintext apiKey into the Node process with no select, even though the response is masked via toMasked. Not exploitable today since the raw value isn't returned, but it means the secret is unnecessarily present in memory — an easy accidental leak the day someone swaps in the raw object instead of the masked one. An explicit select that omits apiKey for these two would close it off entirely.

const project = await prisma.project.findUnique({ where: { id } });
if (!project) return null;
return toMasked(project);
}

/** All projects; API keys masked. */
export async function getProjects(): Promise<ProjectMasked[]> {
const projects = await prisma.project.findMany({
orderBy: { name: "asc" },
});
return projects.map(toMasked);
}

/** Updates name and/or description only. */
export async function updateProject(
id: string,
data: UpdateProjectData,
): Promise<ProjectMasked | null> {
const hasName = data.name !== undefined;
const hasDescription = data.description !== undefined;
if (!hasName && !hasDescription) {
throw new Error("No fields to update");
}

const updatePayload: { name?: string; description?: string | null } = {};
if (hasName) {
const trimmed = data.name!.trim();
if (!trimmed) {
throw new Error("Project name cannot be empty");
}
updatePayload.name = trimmed;
}
if (hasDescription) {
updatePayload.description =
data.description === null || data.description === ""
? null
: data.description!.trim() || null;
}

try {
const project = await prisma.project.update({
where: { id },
data: updatePayload,
});
return toMasked(project);
} catch {
return null;
}
}

/**
* Removes related sessions and user–project links.
*/
export async function deleteProject(id: string): Promise<boolean> {
try {
await prisma.$transaction(async (tx) => {
await tx.session.deleteMany({ where: { projectId: id } });
await tx.userProject.deleteMany({ where: { projectId: id } });
await tx.project.delete({ where: { id } });
});
return true;
} catch {
return false;
}
}

/** Regenerates the API key; returns the full new key once. */
export async function resetProjectAPIKey(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's worth calling out separately from the general authz note above: even once a caller-identity check is added, this returns the plaintext API key and there's no hashing anywhere for Project.apiKey (see the type at line 11). Worth storing a hash + short prefix instead of the raw value, so a DB read/backup/replica never yields a live downstream credential — that's independent of who's allowed to call this action.

id: string,
): Promise<ProjectWithApiKey | null> {
const apiKey = generateApiKey();
try {
const project = await prisma.project.update({
where: { id },
data: { apiKey },
});
return {
id: project.id,
name: project.name,
description: project.description,
apiKey: project.apiKey,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
} catch {
return null;
}
}
Loading