Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/team-project-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shelve/app": patch
---

Align nested project API handlers with the team from the URL path.
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
import { TeamRole } from '@types'
import { projectIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.OWNER })
const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.OWNER })

const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)

const project = await new ProjectsService().getProject(projectId)

await new ProjectsService().deleteProject(projectId, team.id)
await new ProjectsService().deleteProject(project.id, team.id)

await logAudit(event, {
teamId: team.id,
action: 'project.delete',
resourceType: 'project',
resourceId: projectId,
resourceId: project.id,
metadata: { name: project.name },
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import { projectIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)
return await new ProjectsService().getProject(projectId)
const { project } = await requireUserTeamProject(event)
return project
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,13 @@ const updateProjectSchema = z.object({
syncPolicy: syncPolicySchema,
})

const projectIdParamsSchema = z.object({
projectId: z.coerce.number({
error: 'Project ID is required',
}).int().positive(),
})

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN })

const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)

const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN })
const body = await readValidatedBody(event, updateProjectSchema.parse)

return await new ProjectsService().updateProject({
id: projectId,
id: project.id,
...body,
teamId: team.id
teamId: team.id,
})
})
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { TeamRole } from '@types'
import { projectIdParamsSchema, groupIdParamsSchema } from '~~/server/db/zod'
import { groupIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN })
await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN })
const { groupId } = await getValidatedRouterParams(event, groupIdParamsSchema.parse)

await new VariableGroupsService().getGroupForProject(groupId, project.id)
await new VariableGroupsService().deleteGroup(groupId)
Comment on lines +8 to 9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 '\b(getGroupForProject|deleteGroup)\s*\(' \
  apps/shelve/server/services/variable-groups.ts \
  "apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups"

Repository: HugoRCD/shelve

Length of output: 3421


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,120p' apps/shelve/server/services/variable-groups.ts | nl -ba | sed -n '1,120p'

Repository: HugoRCD/shelve

Length of output: 101


🏁 Script executed:

#!/bin/bash
set -euo pipefail
nl -ba apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts | sed -n '1,120p'

Repository: HugoRCD/shelve

Length of output: 101


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== variable-groups.ts: getGroupForProject + deleteGroup ==="
cat -n apps/shelve/server/services/variable-groups.ts | sed -n '1,120p'

echo "=== variable-groups.ts: deleteGroup (tail section) ==="
cat -n apps/shelve/server/services/variable-groups.ts | sed -n '40,100p'

echo "=== index.delete.ts handler ==="
cat -n "apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts" | sed -n '1,120p'

Repository: HugoRCD/shelve

Length of output: 4459


Make deleteGroup enforce projectId in the delete query (atomic scope)

VariableGroupsService.deleteGroup(id: number) deletes solely by variableGroups.id (no projectId), while the handler deletes via bare groupId after a separate getGroupForProject(...) read. That two-step, non-atomic flow weakens the project-scoped write contract.

File: apps/shelve/server/api/teams/[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts (lines 8-9)

await new VariableGroupsService().getGroupForProject(groupId, project.id)
await new VariableGroupsService().deleteGroup(groupId)

Prefer a project-scoped delete API (e.g. deleteGroup(groupId, project.id) with WHERE id = ... AND projectId = ...) and call it directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/shelve/server/api/teams/`[slug]/projects/[projectId]/variable-groups/[groupId]/index.delete.ts
around lines 8 - 9, The handler currently does a separate read via
VariableGroupsService.getGroupForProject(groupId, project.id) then calls
VariableGroupsService.deleteGroup(groupId), which makes the delete non-atomic
and allows a delete by id only; change VariableGroupsService.deleteGroup to
accept both groupId and projectId (e.g., deleteGroup(groupId, projectId)) and
implement the DB delete to include WHERE id = ? AND projectId = ? so the write
is scoped to the project atomically, then update the handler in index.delete.ts
to call deleteGroup(groupId, project.id) directly (remove the separate
getGroupForProject call).


return { statusCode: 200, message: 'Variable group deleted successfully' }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TeamRole } from '@types'
import { projectIdParamsSchema, groupIdParamsSchema } from '~~/server/db/zod'
import { groupIdParamsSchema } from '~~/server/db/zod'

const updateGroupSchema = z.object({
name: z.string().min(1).max(50).trim().optional(),
Expand All @@ -9,12 +9,12 @@ const updateGroupSchema = z.object({
})

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN })
await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN })
const { groupId } = await getValidatedRouterParams(event, groupIdParamsSchema.parse)
const body = await readValidatedBody(event, updateGroupSchema.parse)

await new VariableGroupsService().getGroupForProject(groupId, project.id)

const group = await new VariableGroupsService().updateGroup({
id: groupId,
...body,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import { projectIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)

return await new VariableGroupsService().getGroups(projectId)
const { project } = await requireUserTeamProject(event)
return await new VariableGroupsService().getGroups(project.id)
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { z } from 'zod'
import { TeamRole } from '@types'
import { projectIdParamsSchema } from '~~/server/db/zod'

const createGroupSchema = z.object({
name: z.string({ error: 'Group name is required' }).min(1).max(50).trim(),
Expand All @@ -9,14 +8,12 @@ const createGroupSchema = z.object({
})

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN })
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN })
const body = await readValidatedBody(event, createGroupSchema.parse)

const group = await new VariableGroupsService().createGroup({
...body,
projectId,
projectId: project.id,
})

return { statusCode: 201, group }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { TeamRole } from '@types'
import { projectIdParamsSchema, variableIdParamsSchema } from '~~/server/db/zod'
import { variableIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.OWNER })
const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.OWNER })
const { variableId } = await getValidatedRouterParams(event, variableIdParamsSchema.parse)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)

const existing = await db.query.variables.findFirst({
where: and(
eq(schema.variables.id, variableId),
eq(schema.variables.projectId, projectId),
eq(schema.variables.projectId, project.id),
),
})
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Variable not found' })

const project = await new ProjectsService().getProject(existing.projectId)

await new VariablesService(event).deleteVariable(variableId)

void logAudit(event, {
Expand All @@ -26,7 +22,7 @@ export default eventHandler(async (event) => {
resourceId: variableId,
metadata: {
key: existing.key,
projectId: existing.projectId,
projectId: project.id,
projectName: project.name,
},
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TeamRole } from '@types'
import { projectIdParamsSchema, variableIdParamsSchema } from '~~/server/db/zod'
import { variableIdParamsSchema } from '~~/server/db/zod'

const updateVariableSchema = z.object({
autoUppercase: z.boolean().optional(),
Expand All @@ -18,21 +18,18 @@ const updateVariableSchema = z.object({
})

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN })
const { team, project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN })
const { variableId } = await getValidatedRouterParams(event, variableIdParamsSchema.parse)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const body = await readValidatedBody(event, updateVariableSchema.parse)

const existing = await db.query.variables.findFirst({
where: and(
eq(schema.variables.id, variableId),
eq(schema.variables.projectId, projectId),
eq(schema.variables.projectId, project.id),
),
})
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Variable not found' })

const project = await new ProjectsService().getProject(existing.projectId)
const environmentIds = [...new Set(body.values.map(v => v.environmentId))]
await assertPushAllowedForEnvironmentIds(environmentIds, team.id, project.syncPolicy)

Expand All @@ -52,7 +49,7 @@ export default eventHandler(async (event) => {
resourceId: variableId,
metadata: {
key: body.autoUppercase ? body.key.toUpperCase() : body.key,
projectId: existing.projectId,
projectId: project.id,
projectName: project.name,
},
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { z } from 'zod'
import type { EnvVarExport } from '@types'
import { projectIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const { team, project } = await requireUserTeamProject(event)
const { envId } = await getValidatedRouterParams(event, z.object({
envId: z.coerce.number({
error: 'Environment ID is required',
Expand All @@ -14,14 +11,12 @@ export default eventHandler(async (event) => {

await requireTokenScope(event, {
teamId: team.id,
projectId,
projectId: project.id,
environmentId: envId,
permission: 'read',
})

const variablesService = new VariablesService(event)

const project = await new ProjectsService().getProject(projectId)
const environmentName = await getEnvironmentName(envId, team.id)

void logAudit(event, {
Expand All @@ -30,16 +25,16 @@ export default eventHandler(async (event) => {
resourceType: 'environment',
resourceId: envId,
metadata: {
projectId,
projectId: project.id,
projectName: project.name,
environmentName,
},
})

variablesService.incrementStatAsync(team.id, 'pull')
const result = await variablesService.getVariables(projectId, envId)
const result = await variablesService.getVariables(project.id, envId)

if (!result) throw createError({ statusCode: 404, statusMessage: `Variables not found for project ${projectId} and environment ${envId}` })
if (!result) throw createError({ statusCode: 404, statusMessage: `Variables not found for project ${project.id} and environment ${envId}` })

const decryptedVariables = await variablesService.decryptVariables(result)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ const bulkAssignGroupSchema = z.object({
})

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug, { minRole: TeamRole.ADMIN })
const { project } = await requireUserTeamProject(event, { minRole: TeamRole.ADMIN })
const { variableIds, groupId } = await readValidatedBody(event, bulkAssignGroupSchema.parse)
await new VariablesService(event).bulkAssignGroup(variableIds, groupId)
if (groupId !== null) {
await new VariableGroupsService().getGroupForProject(groupId, project.id)
}
await new VariablesService(event).bulkAssignGroup(project.id, variableIds, groupId)
return {
statusCode: 200,
message: 'Variables assigned to group',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { z } from 'zod'
import { TeamRole } from '@types'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
await requireUserTeam(event, slug, { minRole: TeamRole.OWNER })
const { project } = await requireUserTeamProject(event, { minRole: TeamRole.OWNER })
const { variables } = await readValidatedBody(event, z.object({
variables: z.array(z.number()).min(1).max(100),
}).parse)
const variablesService = new VariablesService(event)
await Promise.all(variables.map(id => variablesService.deleteVariable(id)))
await variablesService.deleteVariablesInProject(project.id, variables)
return {
statusCode: 200,
message: 'Variables deleted',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { projectIdParamsSchema } from '~~/server/db/zod'

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const { team, project } = await requireUserTeamProject(event)
const variablesService = new VariablesService(event)
variablesService.incrementStatAsync(team.id, 'pull')
const encryptedVariables = await variablesService.getVariables(projectId)
const encryptedVariables = await variablesService.getVariables(project.id)
if (!encryptedVariables)
throw createError({ statusCode: 404, statusMessage: 'Project variables not found' })
return await variablesService.decryptVariables(encryptedVariables)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { z } from 'zod'
import { projectIdParamsSchema } from '~~/server/db/zod'

const createVariablesSchema = z.object({
autoUppercase: z.boolean().optional(),
Expand All @@ -19,26 +18,23 @@ const createVariablesSchema = z.object({
})

export default eventHandler(async (event) => {
const slug = await getTeamSlugFromEvent(event)
const { team } = await requireUserTeam(event, slug)
const { team, project } = await requireUserTeamProject(event)
const body = await readValidatedBody(event, createVariablesSchema.parse)
const { projectId } = await getValidatedRouterParams(event, projectIdParamsSchema.parse)
const project = await new ProjectsService().getProject(projectId)

await assertPushAllowedForEnvironmentIds(body.environmentIds, team.id, project.syncPolicy)

for (const environmentId of body.environmentIds) {
await requireTokenScope(event, {
teamId: team.id,
projectId,
projectId: project.id,
environmentId,
permission: 'write',
})
}

const variablesService = new VariablesService(event)
await variablesService.createVariables(event, {
projectId,
projectId: project.id,
autoUppercase: body.autoUppercase,
environmentIds: body.environmentIds,
variables: body.variables.map(variable => ({
Expand All @@ -55,7 +51,7 @@ export default eventHandler(async (event) => {
teamId: team.id,
action: 'variables.create',
resourceType: 'project',
resourceId: projectId,
resourceId: project.id,
metadata: {
keys: body.variables.map(v => v.key),
environmentIds: body.environmentIds,
Expand Down
16 changes: 14 additions & 2 deletions apps/shelve/server/services/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ export class ProjectsService {

const [updatedProject] = await db.update(schema.projects)
.set(input)
.where(eq(schema.projects.id, input.id))
.where(and(
eq(schema.projects.id, input.id),
eq(schema.projects.teamId, input.teamId),
))
.returning()
if (!updatedProject) throw createError({ statusCode: 422, message: 'Failed to update project' })
if (!updatedProject) throw createError({ statusCode: 404, message: `Project not found with id ${input.id}` })
Comment on lines +26 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope the pre-update lookup to the caller's team.

updateProject() still resolves existingProject by bare id before this scoped UPDATE, so a cross-team caller can reach validation against another team's project before the final 404. Resolve the project with getProjectForTeam(input.id, input.teamId) first and validate uniqueness against input.teamId.

Suggested fix
 async updateProject(input: ProjectUpdateInput): Promise<Project> {
-  const existingProject = await this.getProject(input.id)
-  if (!existingProject) throw createError({ statusCode: 404, message: `Project not found with id ${input.id}` })
+  const existingProject = await this.getProjectForTeam(input.id, input.teamId)

   if (existingProject.name !== input.name)
-    await this.validateProjectName(input.name, existingProject.teamId, input.id)
+    await this.validateProjectName(input.name, input.teamId, input.id)

   const [updatedProject] = await db.update(schema.projects)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/shelve/server/services/projects.ts` around lines 26 - 31, The pre-update
lookup in updateProject() currently resolves existingProject by id only; change
it to call getProjectForTeam(input.id, input.teamId) so the initial fetch is
scoped to the caller's team, then perform uniqueness checks (e.g., name
uniqueness) against input.teamId as well; finally keep the scoped UPDATE that
uses eq(schema.projects.id, input.id) and eq(schema.projects.teamId,
input.teamId) and preserve the 404 throw when the scoped update returns no row.

await clearCache('Projects', updatedProject.teamId)
await clearCache('Project', updatedProject.id)

Expand All @@ -40,6 +43,15 @@ export class ProjectsService {
return project
})

/** Resolves a project only when it belongs to the given team (prevents cross-tenant IDOR). */
async getProjectForTeam(projectId: number, teamId: number): Promise<Project> {
const project = await this.getProject(projectId)
if (project.teamId !== teamId) {
throw createError({ statusCode: 404, message: `Project not found with id ${projectId}` })
}
return project
}

getProjects = withCache<Project[]>('Projects', (teamId: number) => {
return db.query.projects.findMany({
where: eq(schema.projects.teamId, teamId),
Expand Down
Loading
Loading