AUTH-8 Project server actions - #11
Conversation
mahikasharma
commented
Apr 12, 2026
- add src/actions/projects.ts for server actions for projects (create, list, get, update, delete, rotate API key)
- API keys from crypto.randomUUID() (full key only on create/rotate)
- delete clears related sessions and user–project rows in one transaction
There was a problem hiding this comment.
Review written by a Claude agent.
Ran this through the repo's mandatory pre-merge security checklist (per CLAUDE.md, anything touching credential storage needs this before merge). One framing note up front: several of the findings below only matter once these actions are actually reachable — exported and imported somewhere. If there's a follow-up ticket planned to add an authorization layer before this gets wired into any UI, deferring those specific items is a reasonable sequencing choice. I've split things below so that's an explicit, visible decision rather than an assumption either way.
Must-fix regardless of sequencing:
resetProjectAPIKeyreturns the plaintext key and there's no hashing anywhere forProject.apiKey— worth storing a hash + prefix instead of the raw value (see inline comment).getProject/getProjectsload the full plaintext key into memory with noselect, even though the returned response is masked (see inline comment).- No input validation on any argument —
zodis already a dependency, and e.g.name/descriptioncurrently have no length bound. - The blanket
catch {}inupdateProject/deleteProject/resetProjectAPIKeyconflates "not found" with a real infrastructure failure — worth narrowing to Prisma'sP2025and logging anything else.
Pending authz ticket (see framing note above):
- None of the six actions currently check the caller's identity or project authority before mutating — see inline comments on
createProjectandresetProjectAPIKeyfor the two that matter most.
Worth its own ticket, not introduced by this PR: no RLS found on Project/Session/UserProject in the migrations. That's a second, independent path to the same data via the public anon key, orthogonal to whatever this file checks — probably worth tracking separately since it affects several of the open PRs at once.
(Edit: corrected a few inline comment anchors below that landed on the wrong line in my first pass — same content, right lines now.)
| }; | ||
|
|
||
| /** Registers an external app; returns the full API key once. */ | ||
| export async function createProject( |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| /** Regenerates the API key; returns the full new key once. */ | ||
| export async function resetProjectAPIKey( |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| /** Single project; API key is masked. */ | ||
| export async function getProject(id: string): Promise<ProjectMasked | null> { |
There was a problem hiding this comment.
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.