Files
MOPC-Portal/docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md
Matt dd48db5eea docs: PR 2 plan — mentor workspace files end-to-end (security + UI)
Bundle backend security (HMAC-signed upload tokens, server-built
objectKeys, mentor-or-team-member auth) with the actual file UI
that didn't exist yet (Files tab placeholder, file-promotion-panel
mock array, and applicant-side gap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:27:45 +02:00

41 KiB

PR 2 — Mentor Workspace Files: secure backend + end-to-end UI (§F.1)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make file sharing between mentors and project teams work end-to-end. Files land at <projectName>/mentorship/<file> in the configured MinIO bucket via a server-controlled path (no client-supplied bucket/objectKey). Mentors AND project team members can upload, list, download, comment, and delete files in a workspace.

Architecture: Server-controlled object keys via a new generateMentorObjectKey() helper (mirrors existing generateObjectKey()). Upload flow: presign procedure issues an HMAC-signed token containing the server-built objectKey + bucket + assignmentId; client uses the presigned URL to PUT the file to MinIO; client then calls workspaceUploadFile(token, description?) which verifies the HMAC and writes the DB row. Token-binding eliminates client-controlled keys without adding new infrastructure (HMAC uses Node crypto, secret is NEXTAUTH_SECRET). UI: a shared <WorkspaceFilesPanel> component renders the list, upload, download, and comment surface for both mentor and applicant sides — wired into the existing mentor workspace Files tab and the existing applicant /applicant/mentor page.

Tech Stack: TypeScript (strict), tRPC 11, Prisma 6, Node crypto.createHmac, existing getPresignedUrl from src/lib/minio.ts, shadcn/ui components, Vitest 4.

Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1 (revised after walkthrough discovered upload UI is unimplemented).


File map

File Action Responsibility
src/lib/minio.ts Modify Export new generateMentorObjectKey(projectTitle, fileName)
src/lib/mentor-upload-token.ts Create HMAC sign/verify helpers for upload tokens
src/server/services/mentor-workspace.ts Modify Add getFiles, getFileDownloadInfo, deleteFile. uploadFile keeps its current signature (server already controls input).
src/server/routers/mentor.ts Modify Add workspaceGetUploadUrl, workspaceGetFiles, workspaceGetFileDownloadUrl, workspaceDeleteFile. Replace workspaceUploadFile input schema with { uploadToken, description? }. Broaden auth so project team members can also upload/list/comment/delete.
src/components/mentor/workspace-files-panel.tsx Create Shared file-panel component (file list + upload + per-file download/delete + comment thread). Used by both mentor and applicant pages.
src/app/(mentor)/mentor/workspace/[projectId]/page.tsx Modify Replace "coming soon" Files tab with <WorkspaceFilesPanel>
src/components/mentor/file-promotion-panel.tsx Modify Replace mock workspaceFiles: any[] = [] with real mentor.workspaceGetFiles query
src/app/(applicant)/applicant/mentor/page.tsx Modify Add a Files section using <WorkspaceFilesPanel>
tests/unit/mentor-key-construction.test.ts Create Verify generateMentorObjectKey shape + sanitization
tests/unit/mentor-upload-token.test.ts Create Sign+verify roundtrip + tamper detection
tests/unit/mentor-workspace-files.test.ts Create End-to-end procedure tests: presign → upload → list → download → comment → delete; auth checks; tampered token rejection

No Prisma migration. No new dependency. Existing MentorFile and MentorFileComment tables are sufficient.


Task 1: Add generateMentorObjectKey helper

Files:

  • Modify: src/lib/minio.ts (after the existing generateObjectKey function around line 150)

  • Create: tests/unit/mentor-key-construction.test.ts

  • Step 1: Write the failing test

Create tests/unit/mentor-key-construction.test.ts:

import { describe, expect, it } from 'vitest'
import { generateMentorObjectKey } from '../../src/lib/minio'

describe('generateMentorObjectKey', () => {
  it('produces a path under <projectName>/mentorship/<timestamp>-<file>', () => {
    const key = generateMentorObjectKey('Revamp Flips', 'meeting-notes.pdf')
    expect(key).toMatch(/^Revamp_Flips\/mentorship\/\d+-meeting-notes\.pdf$/)
  })

  it('sanitizes special characters in the project title', () => {
    const key = generateMentorObjectKey('Côté & Bro 2026!', 'file.pdf')
    expect(key.startsWith('Ct_Bro_2026/mentorship/')).toBe(true)
  })

  it('sanitizes special characters in the file name', () => {
    const key = generateMentorObjectKey('Project', 'rapport final 2026 — version 2.docx')
    expect(key).toMatch(/^Project\/mentorship\/\d+-rapport_final_2026___version_2\.docx$/)
  })

  it('falls back to "unnamed" for an empty project title', () => {
    const key = generateMentorObjectKey('', 'doc.pdf')
    expect(key.startsWith('unnamed/mentorship/')).toBe(true)
  })

  it('uses a different timestamp for sequential calls in different milliseconds', async () => {
    const a = generateMentorObjectKey('P', 'a.pdf')
    await new Promise((r) => setTimeout(r, 5))
    const b = generateMentorObjectKey('P', 'a.pdf')
    expect(a).not.toEqual(b)
  })
})
  • Step 2: Run the test, expect FAIL

Run: cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-key-construction.test.ts 2>&1 | tail -10 Expected: tests fail with generateMentorObjectKey is not a function or Module has no exported member 'generateMentorObjectKey'.

  • Step 3: Add the helper to src/lib/minio.ts

Append after the generateObjectKey function (around line 150):

/**
 * Generate a unique object key for a mentor-workspace file.
 *
 * Structure: {ProjectName}/mentorship/{timestamp}-{fileName}
 * Mirrors generateObjectKey but pins the round-name slot to "mentorship"
 * so all mentor workspace files for a project live under one folder.
 */
export function generateMentorObjectKey(
  projectTitle: string,
  fileName: string,
): string {
  return generateObjectKey(projectTitle, fileName, 'mentorship')
}
  • Step 4: Re-run the test, expect PASS

Run: cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-key-construction.test.ts 2>&1 | tail -10 Expected: 5 passing.


Task 2: Add HMAC upload-token helper

Files:

  • Create: src/lib/mentor-upload-token.ts

  • Create: tests/unit/mentor-upload-token.test.ts

  • Step 1: Write the failing test

Create tests/unit/mentor-upload-token.test.ts:

import { describe, expect, it, beforeAll } from 'vitest'
import {
  signMentorUploadToken, verifyMentorUploadToken,
  type MentorUploadPayload,
} from '../../src/lib/mentor-upload-token'

const samplePayload: MentorUploadPayload = {
  mentorAssignmentId: 'ma-123',
  uploaderUserId: 'user-456',
  fileName: 'doc.pdf',
  mimeType: 'application/pdf',
  size: 12345,
  bucket: 'mopc-files',
  objectKey: 'Project/mentorship/123-doc.pdf',
  exp: Math.floor(Date.now() / 1000) + 3600,
}

beforeAll(() => {
  process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret-123'
})

describe('mentor upload token', () => {
  it('round-trips a payload', () => {
    const token = signMentorUploadToken(samplePayload)
    expect(typeof token).toBe('string')
    expect(token.split('.').length).toBe(2) // payload.signature

    const verified = verifyMentorUploadToken(token)
    expect(verified.mentorAssignmentId).toBe('ma-123')
    expect(verified.objectKey).toBe('Project/mentorship/123-doc.pdf')
  })

  it('rejects a tampered payload', () => {
    const token = signMentorUploadToken(samplePayload)
    const [, sig] = token.split('.')
    const tamperedPayload = Buffer.from(JSON.stringify({ ...samplePayload, size: 999999 })).toString('base64url')
    const tampered = `${tamperedPayload}.${sig}`
    expect(() => verifyMentorUploadToken(tampered)).toThrow(/signature/i)
  })

  it('rejects a token with a different signature', () => {
    const token = signMentorUploadToken(samplePayload)
    const [payload] = token.split('.')
    const bad = `${payload}.0000000000000000000000000000000000000000000000000000000000000000`
    expect(() => verifyMentorUploadToken(bad)).toThrow(/signature/i)
  })

  it('rejects an expired token', () => {
    const expired = signMentorUploadToken({
      ...samplePayload,
      exp: Math.floor(Date.now() / 1000) - 60,
    })
    expect(() => verifyMentorUploadToken(expired)).toThrow(/expired/i)
  })

  it('rejects a malformed token', () => {
    expect(() => verifyMentorUploadToken('not-a-token')).toThrow()
    expect(() => verifyMentorUploadToken('one-segment')).toThrow()
  })
})
  • Step 2: Run, expect FAIL

Run: cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-upload-token.test.ts 2>&1 | tail -8 Expected: failures because the module doesn't exist.

  • Step 3: Create src/lib/mentor-upload-token.ts
import { createHmac, timingSafeEqual } from 'crypto'

export type MentorUploadPayload = {
  mentorAssignmentId: string
  uploaderUserId: string
  fileName: string
  mimeType: string
  size: number
  bucket: string
  objectKey: string
  /** Unix seconds. Token is rejected after this. */
  exp: number
}

function getSecret(): string {
  const s = process.env.NEXTAUTH_SECRET
  if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign mentor upload tokens')
  return s
}

function hmac(payloadB64: string): string {
  return createHmac('sha256', getSecret()).update(payloadB64).digest('hex')
}

export function signMentorUploadToken(payload: MentorUploadPayload): string {
  const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
  const sig = hmac(payloadB64)
  return `${payloadB64}.${sig}`
}

export function verifyMentorUploadToken(token: string): MentorUploadPayload {
  const parts = token.split('.')
  if (parts.length !== 2) throw new Error('Invalid mentor upload token: malformed')
  const [payloadB64, sig] = parts
  const expected = hmac(payloadB64)
  // timingSafeEqual requires equal-length buffers
  const a = Buffer.from(sig, 'hex')
  const b = Buffer.from(expected, 'hex')
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('Invalid mentor upload token: signature mismatch')
  }
  let payload: MentorUploadPayload
  try {
    payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'))
  } catch {
    throw new Error('Invalid mentor upload token: payload not parseable')
  }
  if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
    throw new Error('Invalid mentor upload token: expired')
  }
  return payload
}
  • Step 4: Re-run, expect PASS

Run: cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-upload-token.test.ts 2>&1 | tail -10 Expected: 5 passing.


Task 3: Add workspaceGetUploadUrl procedure with auth + presign + token

Files:

  • Modify: src/server/routers/mentor.ts — add procedure near the existing workspace section (around line 1377)

  • Step 1: Read the existing imports and procedure around workspaceUploadFile

sed -n '1,30p' /Users/matt/Repos/MOPC/src/server/routers/mentor.ts

Confirm the file imports protectedProcedure (it does — line 3) and that workspaceUploadFile is currently mentorProcedure. We will:

  • Convert to protectedProcedure and check team-or-mentor membership inside.

  • Add workspaceGetUploadUrl, workspaceGetFiles, workspaceGetFileDownloadUrl, workspaceDeleteFile.

  • Step 2: Add imports at the top of src/server/routers/mentor.ts

Append to the imports block at the top:

import { generateMentorObjectKey, getPresignedUrl, BUCKET_NAME, deleteObject } from '@/lib/minio'
import {
  signMentorUploadToken,
  verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'

(Confirm deleteObject is exported from @/lib/minio. If not, add it: it's a small wrapper on minio.removeObject. See src/server/routers/file.ts:289 which already calls deleteObject(bucket, key). Reuse the same export.)

  • Step 3: Add a shared auth helper inside mentorRouter (right after the imports, before mentorRouter = router({ ...)
/**
 * Throws TRPCError UNAUTHORIZED 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' })
}

(Add import type { PrismaClient } from '@prisma/client' if not present.)

  • Step 4: Add workspaceGetUploadUrl procedure inside router({ ... })

Insert immediately before the existing workspaceUploadFile:

  /**
   * 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; the upload-record procedure reads the token, never the
   * client-supplied path.
   */
  workspaceGetUploadUrl: protectedProcedure
    .input(
      z.object({
        mentorAssignmentId: z.string(),
        fileName: z.string().min(1).max(255),
        mimeType: z.string().min(1).max(200),
        size: z.number().int().min(0).max(500 * 1024 * 1024), // 500 MB cap
      })
    )
    .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 }
    }),

(bucket and objectKey are returned for client convenience/UI display — they're already in the signed token so they cannot be tampered with on the upload-record call.)


Task 4: Replace workspaceUploadFile input schema with token-based

Files:

  • Modify: src/server/routers/mentor.ts (replace the existing workspaceUploadFile definition around line 1377)

  • Modify: src/server/services/mentor-workspace.ts (no change needed — uploadFile keeps the same signature; only the procedure changes)

  • Step 1: Replace the procedure body

Find the existing definition:

  workspaceUploadFile: mentorProcedure
    .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(),
        description: z.string().max(2000).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return workspaceUploadFile(...)
    }),

Replace with:

  /**
   * 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',
        })
      }
      // Defense-in-depth: re-check workspace access in case the assignment was
      // closed or the user removed from the team since presign.
      await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId)
      return workspaceUploadFile(
        {
          workspaceId: payload.mentorAssignmentId,
          uploadedByUserId: ctx.user.id,
          fileName: payload.fileName,
          mimeType: payload.mimeType,
          size: payload.size,
          bucket: payload.bucket,
          objectKey: payload.objectKey,
          description: input.description,
        },
        ctx.prisma,
      )
    }),

Task 5: Add workspaceGetFiles, workspaceGetFileDownloadUrl, workspaceDeleteFile procedures

Files:

  • Modify: src/server/services/mentor-workspace.ts — add getFiles and deleteFile services

  • Modify: src/server/routers/mentor.ts — wire procedures

  • Step 1: Add to src/server/services/mentor-workspace.ts

Append at the end of the file (before any closing brace if the file uses one):

/**
 * List files for a workspace, newest first, with comment counts and uploader.
 */
export async function getFiles(
  workspaceId: string,
  prisma: PrismaClient,
) {
  return prisma.mentorFile.findMany({
    where: { mentorAssignmentId: workspaceId },
    orderBy: { createdAt: 'desc' },
    include: {
      uploadedBy: { select: { id: true, name: true, email: true } },
      _count: { select: { comments: true } },
    },
  })
}

/**
 * Delete a file. Caller must be either the uploader OR the assigned mentor.
 * Removes the MinIO object and the DB row + cascade-deletes comments.
 */
export async function deleteFile(
  params: { mentorFileId: string; userId: string },
  prisma: PrismaClient,
  removeStorageObject: (bucket: string, key: string) => Promise<void>,
): Promise<void> {
  const file = await prisma.mentorFile.findUnique({
    where: { id: params.mentorFileId },
    include: { mentorAssignment: { select: { mentorId: true } } },
  })
  if (!file) throw new Error('File not found')
  const isUploader = file.uploadedByUserId === params.userId
  const isMentor = file.mentorAssignment.mentorId === params.userId
  if (!isUploader && !isMentor) {
    throw new Error('Only the uploader or the assigned mentor can delete this file')
  }
  // Best-effort storage delete; DB delete is the source of truth.
  try {
    await removeStorageObject(file.bucket, file.objectKey)
  } catch (err) {
    console.error('[mentor-workspace] failed to delete storage object', file.objectKey, err)
  }
  await prisma.mentorFile.delete({ where: { id: params.mentorFileId } })
}

(Confirm mentorFile model has the relation mentorAssignment. If the relation is named differently, adjust. Schema search: grep -A 20 "model MentorFile " prisma/schema.prisma.)

  • Step 2: Wire procedures in src/server/routers/mentor.ts

Add imports:

import {
  // ... existing imports ...
  getFiles as workspaceGetFilesService,
  deleteFile as workspaceDeleteFileService,
} from '../services/mentor-workspace'

Add procedures inside router({ ... }) (right after workspaceUploadFile):

  /**
   * 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' })
      // assertWorkspaceAccess guards membership; deleteFile guards uploader/mentor authority.
      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 }
    }),

Task 6: Backend integration test

Files:

  • Create: tests/unit/mentor-workspace-files.test.ts

  • Step 1: Write the test (presign → upload → list → download → delete)

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
  createTestUser, createTestProgram, createTestProject, cleanupTestData, uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import { signMentorUploadToken } from '../../src/lib/mentor-upload-token'

describe('mentor.workspace files end-to-end', () => {
  let programId: string
  let mentor: { id: string; email: string; role: 'MENTOR' }
  let outsider: { id: string; email: string; role: 'JURY_MEMBER' }
  let assignmentId: string
  const userIds: string[] = []

  beforeAll(async () => {
    process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret-123'
    const program = await createTestProgram({ name: `mentor-files-${uid()}` })
    programId = program.id
    const project = await createTestProject(programId, { title: 'Test Project' })

    const m = await createTestUser('MENTOR')
    userIds.push(m.id)
    mentor = { id: m.id, email: m.email, role: 'MENTOR' }
    const o = await createTestUser('JURY_MEMBER')
    userIds.push(o.id)
    outsider = { id: o.id, email: o.email, role: 'JURY_MEMBER' }

    const assignment = await prisma.mentorAssignment.create({
      data: {
        id: uid('ma'), projectId: project.id, mentorId: m.id, method: 'MANUAL',
        workspaceEnabled: true,
      },
    })
    assignmentId = assignment.id
  })

  afterAll(async () => {
    await cleanupTestData(programId, userIds)
  })

  it('issues an upload URL + signed token to the assigned mentor', async () => {
    const caller = createCaller(mentorRouter, mentor)
    const result = await caller.workspaceGetUploadUrl({
      mentorAssignmentId: assignmentId,
      fileName: 'plan.pdf',
      mimeType: 'application/pdf',
      size: 1024,
    })
    expect(typeof result.uploadUrl).toBe('string')
    expect(result.uploadUrl).toContain('plan.pdf')
    expect(typeof result.uploadToken).toBe('string')
    expect(result.objectKey).toMatch(/^Test_Project\/mentorship\/\d+-plan\.pdf$/)
  })

  it('rejects upload-url request from a user who is neither mentor nor team member', async () => {
    const caller = createCaller(mentorRouter, outsider)
    await expect(
      caller.workspaceGetUploadUrl({
        mentorAssignmentId: assignmentId, fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
      })
    ).rejects.toThrow(/not a member|FORBIDDEN/i)
  })

  it('records a file when given a valid token', async () => {
    const caller = createCaller(mentorRouter, mentor)
    const presign = await caller.workspaceGetUploadUrl({
      mentorAssignmentId: assignmentId, fileName: 'a.pdf', mimeType: 'application/pdf', size: 99,
    })
    const file = await caller.workspaceUploadFile({
      uploadToken: presign.uploadToken,
      description: 'first file',
    })
    expect(file.fileName).toBe('a.pdf')
    expect(file.objectKey).toBe(presign.objectKey)
    expect(file.bucket).toBeTruthy()
  })

  it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => {
    const forged = signMentorUploadToken({
      mentorAssignmentId: assignmentId,
      uploaderUserId: 'someone-else',
      fileName: 'x.pdf', mimeType: 'application/pdf', size: 1,
      bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf',
      exp: Math.floor(Date.now() / 1000) + 60,
    })
    const caller = createCaller(mentorRouter, mentor)
    await expect(caller.workspaceUploadFile({ uploadToken: forged })).rejects.toThrow(/does not belong/i)
  })

  it('lists files for the assigned mentor, sorted newest first', async () => {
    const caller = createCaller(mentorRouter, mentor)
    const a = await caller.workspaceGetUploadUrl({
      mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50,
    })
    await caller.workspaceUploadFile({ uploadToken: a.uploadToken })
    const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
    expect(files.length).toBeGreaterThanOrEqual(2)
    // newest first
    expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual(
      new Date(files[1].createdAt).getTime(),
    )
  })

  it('refuses workspaceGetFiles to outsiders', async () => {
    const caller = createCaller(mentorRouter, outsider)
    await expect(
      caller.workspaceGetFiles({ mentorAssignmentId: assignmentId })
    ).rejects.toThrow(/FORBIDDEN|not a member/i)
  })

  it('deletes a file the user uploaded', async () => {
    const caller = createCaller(mentorRouter, mentor)
    const p = await caller.workspaceGetUploadUrl({
      mentorAssignmentId: assignmentId, fileName: 'kill.pdf', mimeType: 'application/pdf', size: 10,
    })
    const file = await caller.workspaceUploadFile({ uploadToken: p.uploadToken })
    const result = await caller.workspaceDeleteFile({ mentorFileId: file.id })
    expect(result.success).toBe(true)
    const after = await prisma.mentorFile.findUnique({ where: { id: file.id } })
    expect(after).toBeNull()
  })
})
  • Step 2: Run tests, expect PASS

Run: cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/mentor-workspace-files.test.ts 2>&1 | tail -20 Expected: 7 passing.

If any fail: read the failure carefully, fix the implementation. Common pitfall: the MentorFile schema may have a different relation name for mentorAssignment (verify with grep "model MentorFile" -A 30 prisma/schema.prisma).


Task 7: Build shared <WorkspaceFilesPanel> component

Files:

  • Create: src/components/mentor/workspace-files-panel.tsx

  • Step 1: Create the component

'use client'

import { useState, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import {
  AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
  AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
  AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'

interface Props {
  mentorAssignmentId: string
  /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */
  asApplicant?: boolean
}

function formatSize(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}

export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) {
  const utils = trpc.useUtils()
  const inputRef = useRef<HTMLInputElement>(null)
  const [uploading, setUploading] = useState(false)
  const [description, setDescription] = useState('')

  const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery(
    { mentorAssignmentId },
    { enabled: !!mentorAssignmentId }
  )

  const presign = trpc.mentor.workspaceGetUploadUrl.useMutation()
  const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({
    onSuccess: () => {
      utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
      setDescription('')
      toast.success('File uploaded')
    },
  })
  const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation()
  const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({
    onSuccess: () => {
      utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId })
      toast.success('File deleted')
    },
    onError: (e) => toast.error(e.message),
  })

  const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
    e.target.value = '' // reset so same file can be picked again later
    setUploading(true)
    try {
      const { uploadUrl, uploadToken } = await presign.mutateAsync({
        mentorAssignmentId, fileName: file.name, mimeType: file.type || 'application/octet-stream', size: file.size,
      })
      const putRes = await fetch(uploadUrl, {
        method: 'PUT', body: file,
        headers: { 'Content-Type': file.type || 'application/octet-stream' },
      })
      if (!putRes.ok) throw new Error(`Upload failed: HTTP ${putRes.status}`)
      await recordUpload.mutateAsync({ uploadToken, description: description || undefined })
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Upload failed')
    } finally {
      setUploading(false)
    }
  }

  const handleDownload = async (mentorFileId: string) => {
    try {
      const { url } = await downloadMutation.mutateAsync({ mentorFileId })
      window.open(url, '_blank')
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Download failed')
    }
  }

  if (isLoading) {
    return (
      <Card>
        <CardHeader><CardTitle>Workspace Files</CardTitle></CardHeader>
        <CardContent className="space-y-2">
          <Skeleton className="h-12 w-full" />
          <Skeleton className="h-12 w-full" />
        </CardContent>
      </Card>
    )
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>Workspace Files</CardTitle>
        <CardDescription>
          {asApplicant
            ? 'Files shared with your mentor in this workspace.'
            : 'Files you and the team have shared in this workspace.'}
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="flex flex-col sm:flex-row gap-2">
          <Input
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            placeholder="Optional description for the next upload"
            className="flex-1"
          />
          <Button
            disabled={uploading}
            onClick={() => inputRef.current?.click()}
            className="shrink-0"
          >
            <Upload className="mr-2 h-4 w-4" />
            {uploading ? 'Uploading…' : 'Upload file'}
          </Button>
          <input
            ref={inputRef} type="file" hidden onChange={handleFileSelected}
          />
        </div>

        {files && files.length === 0 && (
          <div className="text-center py-8 text-sm text-muted-foreground">
            <FileText className="h-10 w-10 mx-auto mb-2 opacity-40" />
            No files in this workspace yet.
          </div>
        )}

        <ul className="divide-y">
          {(files ?? []).map((f) => (
            <li key={f.id} className="flex items-center gap-3 py-3">
              <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
              <div className="flex-1 min-w-0">
                <div className="font-medium truncate">{f.fileName}</div>
                <div className="text-xs text-muted-foreground">
                  {f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '}
                  {formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })}
                  {f._count.comments > 0 && (
                    <span className="ml-2 inline-flex items-center gap-1">
                      <MessageSquare className="h-3 w-3" />
                      {f._count.comments}
                    </span>
                  )}
                </div>
                {f.description && (
                  <div className="text-xs text-muted-foreground mt-1">{f.description}</div>
                )}
              </div>
              <Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
                <Download className="h-4 w-4" />
              </Button>
              <AlertDialog>
                <AlertDialogTrigger asChild>
                  <Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
                    <Trash2 className="h-4 w-4" />
                  </Button>
                </AlertDialogTrigger>
                <AlertDialogContent>
                  <AlertDialogHeader>
                    <AlertDialogTitle>Delete this file?</AlertDialogTitle>
                    <AlertDialogDescription>
                      This removes the file from MinIO and the workspace. Comments on the file are deleted with it.
                    </AlertDialogDescription>
                  </AlertDialogHeader>
                  <AlertDialogFooter>
                    <AlertDialogCancel>Cancel</AlertDialogCancel>
                    <AlertDialogAction onClick={() => deleteMutation.mutate({ mentorFileId: f.id })}>
                      Delete
                    </AlertDialogAction>
                  </AlertDialogFooter>
                </AlertDialogContent>
              </AlertDialog>
            </li>
          ))}
        </ul>
      </CardContent>
    </Card>
  )
}

(If date-fns isn't installed: grep date-fns package.json — if absent, replace formatDistanceToNow(...) with a one-line relative-time util. Search for an existing one in src/lib/utils.ts first.)

(If @/components/ui/alert-dialog isn't present: drop the dialog and use a plain confirm() for now. Verify with ls src/components/ui/alert-dialog.tsx.)


Task 8: Wire mentor workspace + applicant page + file-promotion-panel

Files:

  • Modify: src/app/(mentor)/mentor/workspace/[projectId]/page.tsx

  • Modify: src/components/mentor/file-promotion-panel.tsx

  • Modify: src/app/(applicant)/applicant/mentor/page.tsx

  • Step 1: Mentor workspace Files tab

In src/app/(mentor)/mentor/workspace/[projectId]/page.tsx, add the import:

import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'

Replace the TabsContent value="files" block (lines 104-119) with:

        <TabsContent value="files" className="mt-6">
          {assignment ? (
            <WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
          ) : (
            <Card>
              <CardContent className="text-center py-8">
                <FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
                <p className="text-sm text-muted-foreground">Loading workspace</p>
              </CardContent>
            </Card>
          )}
        </TabsContent>
  • Step 2: file-promotion-panel — wire real query

In src/components/mentor/file-promotion-panel.tsx, replace the mock array (line ~36) with a real query:

// before
const workspaceFiles: any[] = [] // Placeholder

// after
const { data: workspaceFiles = [] } = trpc.mentor.workspaceGetFiles.useQuery(
  { mentorAssignmentId },
  { enabled: !!mentorAssignmentId }
)

(If the rest of the file currently treats workspaceFiles as items with { id, fileName, size, ... }, the shape from the new query already matches.)

  • Step 3: Applicant mentor page Files section

In src/app/(applicant)/applicant/mentor/page.tsx, find the existing layout (mentor info card + chat) and append a Files section. Read the file first:

sed -n '1,50p' /Users/matt/Repos/MOPC/src/app/(applicant)/applicant/mentor/page.tsx

Add at top:

import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'

In the JSX, after the chat / mentor info section, render:

{project?.mentorAssignment?.id && (
  <WorkspaceFilesPanel mentorAssignmentId={project.mentorAssignment.id} asApplicant />
)}

(Confirm the field name during impl — mentorAssignment is exposed via the dashboard query; the mentor-page may use a different prop. Adjust to match.)


Task 9: Run all checks + manual smoke + commit

  • Step 1: Run unit + integration tests
cd /Users/matt/Repos/MOPC && npx vitest run tests/unit 2>&1 | tail -20

Expected: all unit tests pass, including the 3 new test files (key construction, upload token, workspace files).

  • Step 2: Typecheck
cd /Users/matt/Repos/MOPC && npm run typecheck 2>&1 | tail -10

Expected: zero errors.

  • Step 3: Build to surface client-side TS issues
cd /Users/matt/Repos/MOPC && npm run build 2>&1 | tail -25

Expected: build succeeds. If it fails on a date-fns or alert-dialog import, swap to the fallback noted in Task 7.

  • Step 4: Manual smoke (only if dev server running)

Sign in as matt@monaco-opc.com / 195260Mp!, navigate to /admin/projects/<any project>/mentor, assign mentor1@monaco-opc.com manually via Prisma Studio (or DB) since the manual picker UI isn't in this PR. Then sign in as mentor1 (set password via the script we used earlier), open /mentor/workspace/<projectId>, open Files tab, upload a small file. Verify the file appears, downloads work, delete works.

  • Step 5: Commit
cd /Users/matt/Repos/MOPC && git add \
  src/lib/minio.ts \
  src/lib/mentor-upload-token.ts \
  src/server/routers/mentor.ts \
  src/server/services/mentor-workspace.ts \
  src/components/mentor/workspace-files-panel.tsx \
  src/components/mentor/file-promotion-panel.tsx \
  src/app/\(mentor\)/mentor/workspace/\[projectId\]/page.tsx \
  src/app/\(applicant\)/applicant/mentor/page.tsx \
  tests/unit/mentor-key-construction.test.ts \
  tests/unit/mentor-upload-token.test.ts \
  tests/unit/mentor-workspace-files.test.ts

Then:

cd /Users/matt/Repos/MOPC && git commit -m "$(cat <<'EOF'
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.

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>
EOF
)"

Acceptance criteria

  • All three new test files pass; full unit suite has no regressions.
  • npm run typecheck and npm run build succeed.
  • Backend rejects: tampered tokens, expired tokens, mismatched-uploader tokens, presign requests from non-members.
  • Manual smoke: file uploads via the mentor Files tab; same file appears in /applicant/mentor; delete works from either side as appropriate.
  • All paths in MinIO start with <projectName>/mentorship/.

Out of scope

  • File comment threads UI (DB model and a procedure exist but the panel doesn't render comments yet — leave as a follow-up).
  • Multipart / resumable uploads (single PUT for now; size cap 500 MB enforced server-side).
  • Per-file access tokens for download (using server presigned GET, 15 min expiry).
  • Migration of legacy MentorFile rows (verified no rows exist via prisma queries during walkthrough).