Long-lived only after approval
Issue approved-image read URLs for the Vercel maximum of seven days. Renew six hours before expiry. Use shorter URLs for uploads and pending previews.
Implementation plan · no application changes
A focused plan for avatar upload, server-side normalization, admin approval, private delivery through signed URLs, and next/image integration.
Keep the Blob store private. Upload to a private staging pathname, validate and normalize on the server, then store an immutable processed image. A new upload is always PENDING; it does not replace the currently approved avatar until an admin approves it.
Issue approved-image read URLs for the Vercel maximum of seven days. Renew six hours before expiry. Use shorter URLs for uploads and pending previews.
Make the existing profile-header avatar a button. Open the shared action dialog and render the shared Dropzone as the upload surface.
Enforce state transitions in a resource service and add a database partial unique index so concurrent approvals cannot produce two active images.
Persist stable pathnames and file metadata only. Generate signed URLs at read boundaries; never store or log signed URLs.
Non-goals: Clerk profile images, public Blob storage, a full admin review screen in the first upload slice, or browser-side image trust/validation.
next.config.ts is on Next.js 16.2.6 and currently has an empty images configuration.User.profileImages, ProfileImage, and File already exist. ProfileImage currently has only an approved boolean.PUT/GET URLs, stable pathnames, and an expiresAt value.src/portals/auditor/profile; the route action files are thin adapters.Avatar with an empty source and initials fallback.Important mismatch: uploadAndCreateFileRecord() currently defaults files to public. Profile-image code must pass access: 'private' explicitly or use a profile-image-specific service that cannot default to public.
Use the exact private store hostname and restrict optimization to processed profile-image paths. Signed URLs contain query parameters, so do not set search: ''; that setting would reject every signed URL.
images: {
remotePatterns: [
{
protocol: 'https',
hostname: process.env.PROFILE_IMAGE_BLOB_HOSTNAME,
port: '',
pathname: '/profile-images/processed/**',
// search intentionally omitted: signed URLs require query parameters
},
// Development only: the local mock host on port 9966, same pathname
],
qualities: [75],
formats: ['image/webp'],
maximumRedirects: 0,
}
PROFILE_IMAGE_BLOB_HOSTNAME. Its value is the exact <store-id>.private.blob.vercel-storage.com hostname, not a wildcard and not the token.Image inside the circular avatar container with explicit dimensions, sizes="54px", quality={75}, and objectFit: 'cover'.sizes control dimensions; quality controls encoding.ActionDialog. Its body contains the shared Dropzone configured for one image, an explicit MIME allowlist, and a conservative byte limit.auditorPortal.profile().images.createUploadTicket(). The portal resolves the current user; the browser never supplies a trusted user ID.profile-images/staging/{userId}/{uuid}. Return a private presigned PUT URL lasting about 10 minutes, constrained by pathname, MIME type, operation, and maximum bytes.DropzoneUploadController sends the file directly to the signed URL and reports progress. The shared Dropzone remains storage-provider-neutral.profile-images/processed/{userId}/{uuid}.webp.File row with PRIVATE access and a ProfileImage row in PENDING. Supersede any older pending candidate for that user.Processing dependency: add an explicit image-decoder dependency rather than importing a transitive Next.js dependency. The server must define accepted formats, maximum source dimensions, normalized output dimensions, and metadata stripping before implementation.
| Use | Recommended lifetime | Who can obtain it | Refresh behavior |
|---|---|---|---|
Direct upload PUT | 10 minutes | Current auditor, own namespace | Request a new ticket on expiry; never silently reuse |
Pending preview GET | 15 minutes | Owning auditor and authorized admin | Renew only while review modal is open |
Approved avatar GET | 7 days | Authorized profile readers | Renew when less than 6 hours remain |
{ imageId, url, expiresAt }. Serialize expiresAt as ISO text at the server/client boundary.expiresAt - now <= renewalBuffer. Check on mount, when the tab becomes visible, when the browser comes back online, and from a one-shot timer scheduled for the renewal boundary.APPROVED status before signing. It does not trust the old pathname sent by the browser.File, Prisma audit JSON, Sentry context, analytics, or console logs.Revocation tradeoff: a seven-day URL remains usable until expiry, and an optimized derivative may remain cached. If immediate revocation becomes a policy requirement, switch approved-image delivery to an authenticated same-origin proxy with short browser caching instead of long direct signed URLs.
ObjectStorageAccess, CreateObjectUploadUrlInput, CreateObjectReadUrlInput, and SignedObjectUrl.DropzoneUploadController, DropzoneUploadResult, and DropzoneFileStatus.satisfies Prisma.*Select/Include, matching the existing profile read pattern.| Type | Purpose |
|---|---|
ProfileImageStatus | PENDING | APPROVED | REJECTED | REPLACED | WITHDRAWN; removes the ambiguity of the current boolean. |
ProfileImageUploadTicket | Opaque ticket/reference plus pathname, signed upload URL, expiry, allowed MIME type, and maximum bytes. |
ProfileImageView | Safe UI DTO: image ID, status, signed URL, ISO expiry, width, and height. Never includes storage tokens or arbitrary pathnames. |
ProfileImageDecisionInput | Image ID plus approve/reject decision and optional rejection reason; actor comes from admin context. |
CreateObjectUploadUrlInput.maximumSizeInBytes | Passes the Blob SDK's existing signed-token size constraint through the repository abstraction. |
Replace approved Boolean with status and review metadata: status, submittedAt, reviewedAt, reviewedByUserId, rejectionReason, width, and height. Make fileId unique so one stored file cannot back multiple profile-image records.
Add a migration-level partial unique index for one APPROVED row per userId. Backfill existing approved=true rows carefully before dropping the boolean.
REPLACED, mark the candidate APPROVED, and append the audit event.The current admin rules can read AuditorProfile and User but cannot approve profile images. Add ProfileImage to the typed CASL subject map and introduce explicit update/approve policy rather than relying on broad user management.
| Risk | Control | Evidence to retain |
|---|---|---|
| Blob written, DB write fails | Compensating delete plus scheduled orphan reconciliation by namespace/age | Image ID, pathname hash, stage, cleanup outcome |
| Content-type spoofing, huge dimensions, malicious metadata | Decode server-side, cap bytes/pixels, strip EXIF, re-encode | Validation category only; do not retain raw image details in logs |
| Approval bypass | Portal ability + module transition rules + approved-only shared read helper | Actor, target image, from/to status, timestamp |
| Concurrent approvals | Transaction plus partial unique index | Conflict result and request correlation ID |
| Signed URL leakage | No persistence/logging, scoped path/operation, differentiated TTLs | Image ID and expiry class; never the URL |
| Stale image after replacement | Immutable pathnames, new image ID, approved read recheck; delete only when policy requires | Replacement relationship and decision event |
| Object delete fails | Capture to Sentry with safe IDs and enqueue/manual reconciliation | Provider, safe pathname or hash, error class |
Use the previously proposed append-only auditor-profile audit event pattern: actor ID, action, source, summary, structured changes, and optional before/after snapshots. Create the event in the same Prisma transaction as the status change. Recommended actions are profile_image.submitted, approved, rejected, replaced, and withdrawn.
Expected validation errors should return stable user-safe codes. Unexpected provider, processing, and persistence failures should use the existing Sentry capture helper with image/user correlation IDs and without signed URLs or sensitive metadata.
src/lib/object-storage/ for private upload/read signing and the local mock provider. Extend the abstraction; do not call Vercel from shared UI.
src/modules/file/file.service.ts and file-paths.ts for immutable path generation, File metadata, and deletion behavior.
src/portals/auditor/profile/ for current-auditor orchestration; add a resource-level profile-image module/service for shared invariants and admin decisions.
src/app/[locale]/auditor/my-profile/_components/actions.ts for parsing serializable inputs, delegating to the portal, and revalidating.
src/components/ui/dropzone/ with a feature-owned controller; src/components/ui/action-dialog.tsx for the modal shell.
auditor-profile-header.tsx remains the visual integration point. Split the interactive avatar/dialog into a focused client component.
src/lib/abilities/types.ts, auditor rules, and admin portal requireAbility/wrappedAccess.
src/lib/capture-sentry.ts for unexpected failures and the service-layer transactional audit-event pattern for durable decisions.
Add status/review metadata and DB constraint; extend signed-upload constraints; add the exact private Blob image host and path to Next config.
Implement ticket, finalize/process, approved read, URL refresh, withdrawal, and cleanup paths behind the auditor portal and profile-image service.
Make the avatar interactive, add the modal and Dropzone controller, show preview/progress/pending states, and render approved images with Image.
Add typed permissions and approve/reject service methods, transactional audit events, and the future-notification hook. A full queue UI can follow separately.
Add failure compensation, orphan detection, URL redaction, concurrency tests, expiry tests, and private/local-provider integration tests.
next/image accepts the signed private URL, the local mock URL works in development, and no wildcard storage hostname is allowed in production.Public technical references: Vercel Blob private storage, Vercel Blob signed URLs, and Next.js Image configuration.