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:
51
src/lib/mentor-upload-token.ts
Normal file
51
src/lib/mentor-upload-token.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user