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

@@ -0,0 +1,51 @@
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)
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
}

View File

@@ -149,3 +149,18 @@ export function generateObjectKey(
return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}`
}
/**
* 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')
}