feat: mentor workspace files end-to-end with secure presign

Adds generateMentorObjectKey helper producing
<projectName>/mentorship/<timestamp>-<file>. Replaces the
client-supplied bucket/objectKey on workspaceUploadFile with an
HMAC-signed upload token that binds bucket, objectKey, uploader,
and a 1h expiry — paths can no longer be forged from the client.

Adds workspaceGetUploadUrl, workspaceGetFiles,
workspaceGetFileDownloadUrl, workspaceDeleteFile procedures with
mentor-or-team-member auth. Builds <WorkspaceFilesPanel> and
wires it into the mentor workspace Files tab and the applicant
/applicant/mentor page. Replaces the file-promotion-panel mock
array with a real workspaceGetFiles query.

Tests cover token sign/verify (5), key construction (5), and
end-to-end procedure flow including auth + tampered tokens (7).

Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1
Plan: docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 13:33:18 +02:00
parent dd48db5eea
commit 2e7b545a1b
11 changed files with 699 additions and 30 deletions

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
import { MentorAssignmentMethod } from '@prisma/client'
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
@@ -20,8 +20,49 @@ import {
uploadFile as workspaceUploadFile,
addFileComment as workspaceAddFileComment,
promoteFile as workspacePromoteFile,
getFiles as workspaceGetFilesService,
deleteFile as workspaceDeleteFileService,
} from '../services/mentor-workspace'
import { triggerInProgressOnActivity } from '../services/round-engine'
import {
generateMentorObjectKey,
getPresignedUrl,
BUCKET_NAME,
deleteObject,
} from '@/lib/minio'
import {
signMentorUploadToken,
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
/**
* Throws TRPCError if the given user is neither the assigned mentor
* nor a team member of the project linked to the assignment.
* Returns the loaded MentorAssignment + Project on success.
*/
async function assertWorkspaceAccess(
prisma: PrismaClient,
userId: string,
mentorAssignmentId: string,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: mentorAssignmentId },
include: { project: { select: { id: true, title: true } } },
})
if (!assignment) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor assignment not found' })
}
if (!assignment.workspaceEnabled) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
}
if (assignment.mentorId === userId) return assignment
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId: assignment.projectId, userId },
select: { id: true },
})
if (teamMembership) return assignment
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
export const mentorRouter = router({
/**
@@ -1372,36 +1413,139 @@ export const mentorRouter = router({
}),
/**
* Upload a file to a workspace
* Issue a presigned upload URL + signed token for a mentor-workspace file.
* The token binds the bucket, objectKey, and uploader so the client cannot
* forge a path; workspaceUploadFile reads the token, never the
* client-supplied path.
*/
workspaceUploadFile: mentorProcedure
workspaceGetUploadUrl: protectedProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
fileName: z.string().min(1).max(255),
mimeType: z.string(),
size: z.number().int().min(0),
bucket: z.string(),
objectKey: z.string(),
mimeType: z.string().min(1).max(200),
size: z.number().int().min(0).max(500 * 1024 * 1024),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await assertWorkspaceAccess(
ctx.prisma, ctx.user.id, input.mentorAssignmentId,
)
const objectKey = generateMentorObjectKey(assignment.project.title, input.fileName)
const uploadUrl = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)
const exp = Math.floor(Date.now() / 1000) + 3600
const uploadToken = signMentorUploadToken({
mentorAssignmentId: assignment.id,
uploaderUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket: BUCKET_NAME,
objectKey,
exp,
})
return { uploadUrl, uploadToken, bucket: BUCKET_NAME, objectKey }
}),
/**
* Record a workspace file upload. Requires a valid uploadToken issued by
* workspaceGetUploadUrl — the token contains the server-built bucket,
* objectKey, and uploader binding. The client cannot pass a path directly.
*/
workspaceUploadFile: protectedProcedure
.input(
z.object({
uploadToken: z.string(),
description: z.string().max(2000).optional(),
})
)
.mutation(async ({ ctx, input }) => {
let payload
try {
payload = verifyMentorUploadToken(input.uploadToken)
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: e instanceof Error ? e.message : 'Invalid upload token',
})
}
if (payload.uploaderUserId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Upload token does not belong to the current user',
})
}
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId)
return workspaceUploadFile(
{
workspaceId: input.mentorAssignmentId,
workspaceId: payload.mentorAssignmentId,
uploadedByUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket: input.bucket,
objectKey: input.objectKey,
fileName: payload.fileName,
mimeType: payload.mimeType,
size: payload.size,
bucket: payload.bucket,
objectKey: payload.objectKey,
description: input.description,
},
ctx.prisma,
)
}),
/**
* List files in a workspace. Authorized for the assigned mentor or any
* project team member.
*/
workspaceGetFiles: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
}),
/**
* Issue a short-lived presigned GET URL to download a workspace file.
*/
workspaceGetFileDownloadUrl: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
{ downloadFileName: file.fileName })
return { url }
}),
/**
* Delete a workspace file (uploader or assigned mentor only).
*/
workspaceDeleteFile: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { mentorAssignmentId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
try {
await workspaceDeleteFileService(
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
ctx.prisma,
deleteObject,
)
} catch (e) {
throw new TRPCError({
code: 'FORBIDDEN',
message: e instanceof Error ? e.message : 'Delete failed',
})
}
return { success: true }
}),
/**
* Add a comment to a workspace file
*/