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

import "server-only";

import { z } from "zod";

import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";

const NAME_MAX_LENGTH = 100;
const DESCRIPTION_MAX_LENGTH = 500;

const idSchema = z.uuid("Invalid project id");

const nameSchema = z
.string()
.trim()
.min(1, "Project name is required")
.max(
NAME_MAX_LENGTH,
`Project name must be ${NAME_MAX_LENGTH} characters or fewer`,
);

/** Trims, then collapses empty strings to null so blank input clears the field. */
const descriptionSchema = z
.string()
.trim()
.max(
DESCRIPTION_MAX_LENGTH,
`Description must be ${DESCRIPTION_MAX_LENGTH} characters or fewer`,
)
.nullable()
.transform((value) => value || null);

const updateProjectSchema = z
.object({
name: nameSchema.optional(),
description: descriptionSchema.optional(),
})
.refine((data) => data.name !== undefined || data.description !== undefined, {
message: "No fields to update",
});

function parseOrThrow<S extends z.ZodType>(
schema: S,
value: unknown,
): z.output<S> {
const result = schema.safeParse(value);
if (!result.success) {
throw new Error(result.error.issues[0].message);
}
return result.data;
}

/** True only for Prisma's "record does not exist" error, so real failures stay loud. */
function isRecordNotFound(error: unknown): boolean {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2025"
);
}

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.

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.

yeah this is on purpose. nothing imports this file yet and there's no session helper to even check against right now (only supabase thing we have is the auth callback route). AUTH-8 scopes it to "admin UI server components only" so the identity check comes with whatever ticket actually builds that UI.

name: string,
description: string | null,
): Promise<ProjectWithApiKey> {
const parsedName = parseOrThrow(nameSchema, name);
const parsedDescription = parseOrThrow(
descriptionSchema,
description ?? null,
);

const apiKey = generateApiKey();
const project = await prisma.project.create({
data: {
name: parsedName,
description: parsedDescription,
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.

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.

toMasked needs the raw key to show the last 4 (projects.ts:24), so dropping the column means either losing that or adding a prefix column. which is just the hashing ticket again, so i folded it in there rather than doing half of it here. agreed the raw value never leaves the boundary today tho.

const parsedId = parseOrThrow(idSchema, id);
const project = await prisma.project.findUnique({ where: { id: parsedId } });
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 parsedId = parseOrThrow(idSchema, id);
const parsed = parseOrThrow(updateProjectSchema, data);

const updatePayload: { name?: string; description?: string | null } = {};
if (parsed.name !== undefined) {
updatePayload.name = parsed.name;
}
if (parsed.description !== undefined) {
updatePayload.description = parsed.description;
}

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

/**
* Removes related sessions and user–project links.
*/
export async function deleteProject(id: string): Promise<boolean> {
const parsedId = parseOrThrow(idSchema, id);

try {
await prisma.$transaction(async (tx) => {
await tx.session.deleteMany({ where: { projectId: parsedId } });
await tx.userProject.deleteMany({ where: { projectId: parsedId } });
await tx.project.delete({ where: { id: parsedId } });
});
return true;
} catch (error) {
if (isRecordNotFound(error)) return false;
throw error;
}
}

/** 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.

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.

agree but prolly needs its own ticket. apiKey String @unique @default(uuid()) is already in schema.prisma:29 and shipped in the init migration.

id: string,
): Promise<ProjectWithApiKey | null> {
const parsedId = parseOrThrow(idSchema, id);
const apiKey = generateApiKey();
try {
const project = await prisma.project.update({
where: { id: parsedId },
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