Implementation plan · no application changes

Auditor profile images on private Vercel Blob

A focused plan for avatar upload, server-side normalization, admin approval, private delivery through signed URLs, and next/image integration.

Status: proposed Updated: 15 July 2026 Target: APSCA Next.js app

Recommendation

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.

Delivery

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.

UI

Avatar opens the upload dialog

Make the existing profile-header avatar a button. Open the shared action dialog and render the shared Dropzone as the upload surface.

Approval

One approved image per user

Enforce state transitions in a resource service and add a database partial unique index so concurrent approvals cannot produce two active images.

Security

Never expose stored Blob credentials

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.

Confirmed current state

  • 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.
  • The object-storage abstraction already supports private objects, presigned PUT/GET URLs, stable pathnames, and an expiresAt value.
  • The Vercel provider defaults signed URLs to five minutes but already accepts a caller-provided lifetime.
  • The shared Dropzone already owns file selection, validation state, progress, retry, and caller-supplied upload controllers.
  • The auditor profile read/write boundary already lives in src/portals/auditor/profile; the route action files are thin adapters.
  • The profile header currently renders a 54×54 MUI 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.

Next.js image configuration

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,
}
  • Add a typed server/build variable such as PROFILE_IMAGE_BLOB_HOSTNAME. Its value is the exact <store-id>.private.blob.vercel-storage.com hostname, not a wildcard and not the token.
  • Use Image inside the circular avatar container with explicit dimensions, sizes="54px", quality={75}, and objectFit: 'cover'.
  • Keep one allowed quality until another surface proves it needs more. Width/height and sizes control dimensions; quality controls encoding.
  • Add the local mock origin only in development so private-image behavior remains testable without widening production hosts.

Image upload lifecycle

1. Request upload ticket
2. Direct private upload
3. Verify and normalize
4. Create pending record
5. Admin decision
  1. Open: clicking the header avatar opens ActionDialog. Its body contains the shared Dropzone configured for one image, an explicit MIME allowlist, and a conservative byte limit.
  2. Authorize: a thin server action calls auditorPortal.profile().images.createUploadTicket(). The portal resolves the current user; the browser never supplies a trusted user ID.
  3. Ticket: create an immutable staging pathname under profile-images/staging/{userId}/{uuid}. Return a private presigned PUT URL lasting about 10 minutes, constrained by pathname, MIME type, operation, and maximum bytes.
  4. Upload: a feature-owned DropzoneUploadController sends the file directly to the signed URL and reports progress. The shared Dropzone remains storage-provider-neutral.
  5. Finalize: the client submits the issued pathname/ticket reference. The server rechecks ownership, reads the private staging object, validates actual bytes—not only the browser MIME—and rejects oversized dimensions or unsupported decoders.
  6. Normalize: rotate from EXIF, strip metadata, resize within an agreed maximum, and encode a safe processed format. Upload to a new immutable pathname under profile-images/processed/{userId}/{uuid}.webp.
  7. Persist: in one database transaction, create the File row with PRIVATE access and a ProfileImage row in PENDING. Supersede any older pending candidate for that user.
  8. Clean up: delete the staging object after success. If DB persistence fails, delete the processed object as compensation. Record cleanup failures for later orphan reconciliation.
  9. Display: the owner sees “Awaiting approval” in the dialog, but the profile header continues showing the most recent approved image or initials.

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.

Signed URL and renewal policy

UseRecommended lifetimeWho can obtain itRefresh behavior
Direct upload PUT10 minutesCurrent auditor, own namespaceRequest a new ticket on expiry; never silently reuse
Pending preview GET15 minutesOwning auditor and authorized adminRenew only while review modal is open
Approved avatar GET7 daysAuthorized profile readersRenew when less than 6 hours remain

How the app decides to re-sign

  • The server read model returns { imageId, url, expiresAt }. Serialize expiresAt as ISO text at the server/client boundary.
  • A pure helper evaluates 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.
  • On image error, request one fresh URL and retry once. Do not create a retry loop.
  • The refresh action re-queries the database and rechecks authorization and APPROVED status before signing. It does not trust the old pathname sent by the browser.
  • Return signed-URL responses as non-cacheable application data. Never persist the URL in 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.

Types and data model

Reuse

  • ObjectStorageAccess, CreateObjectUploadUrlInput, CreateObjectReadUrlInput, and SignedObjectUrl.
  • DropzoneUploadController, DropzoneUploadResult, and DropzoneFileStatus.
  • Prisma payload types derived with satisfies Prisma.*Select/Include, matching the existing profile read pattern.
  • Zod schemas at action boundaries for upload-ticket, finalize, and admin-decision inputs.

Add or extend

TypePurpose
ProfileImageStatusPENDING | APPROVED | REJECTED | REPLACED | WITHDRAWN; removes the ambiguity of the current boolean.
ProfileImageUploadTicketOpaque ticket/reference plus pathname, signed upload URL, expiry, allowed MIME type, and maximum bytes.
ProfileImageViewSafe UI DTO: image ID, status, signed URL, ISO expiry, width, and height. Never includes storage tokens or arbitrary pathnames.
ProfileImageDecisionInputImage ID plus approve/reject decision and optional rejection reason; actor comes from admin context.
CreateObjectUploadUrlInput.maximumSizeInBytesPasses the Blob SDK's existing signed-token size constraint through the repository abstraction.

Schema recommendation

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.

Approval enforcement

  1. Resource invariant: profile-image module/service methods own status transitions. Repositories perform persistence only.
  2. Portal authorization: auditors may create, read, or withdraw their own pending candidates. Only an admin/superadmin ability may approve or reject.
  3. Read policy: shared profile surfaces query approved images only. The owner modal and admin review surface use separate, explicitly scoped reads for pending rows.
  4. Atomic decision: approval runs in one transaction: lock/recheck the candidate, mark the prior approved row REPLACED, mark the candidate APPROVED, and append the audit event.
  5. Database guard: the partial unique index prevents two approved rows even if two admins act concurrently.
  6. Notification seam: keep the future “notify admins/auditor” extension in the profile-image application/service method after the durable decision, not in the Blob provider or UI component.

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.

Exception management and audit risks

RiskControlEvidence to retain
Blob written, DB write failsCompensating delete plus scheduled orphan reconciliation by namespace/ageImage ID, pathname hash, stage, cleanup outcome
Content-type spoofing, huge dimensions, malicious metadataDecode server-side, cap bytes/pixels, strip EXIF, re-encodeValidation category only; do not retain raw image details in logs
Approval bypassPortal ability + module transition rules + approved-only shared read helperActor, target image, from/to status, timestamp
Concurrent approvalsTransaction plus partial unique indexConflict result and request correlation ID
Signed URL leakageNo persistence/logging, scoped path/operation, differentiated TTLsImage ID and expiry class; never the URL
Stale image after replacementImmutable pathnames, new image ID, approved read recheck; delete only when policy requiresReplacement relationship and decision event
Object delete failsCapture to Sentry with safe IDs and enqueue/manual reconciliationProvider, safe pathname or hash, error class

Audit shape

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.

Current repository patterns to follow

Storage abstraction

src/lib/object-storage/ for private upload/read signing and the local mock provider. Extend the abstraction; do not call Vercel from shared UI.

File persistence

src/modules/file/file.service.ts and file-paths.ts for immutable path generation, File metadata, and deletion behavior.

Feature boundary

src/portals/auditor/profile/ for current-auditor orchestration; add a resource-level profile-image module/service for shared invariants and admin decisions.

Thin actions

src/app/[locale]/auditor/my-profile/_components/actions.ts for parsing serializable inputs, delegating to the portal, and revalidating.

Upload UI

src/components/ui/dropzone/ with a feature-owned controller; src/components/ui/action-dialog.tsx for the modal shell.

Profile UI

auditor-profile-header.tsx remains the visual integration point. Split the interactive avatar/dialog into a focused client component.

Authorization

src/lib/abilities/types.ts, auditor rules, and admin portal requireAbility/wrappedAccess.

Errors and audit

src/lib/capture-sentry.ts for unexpected failures and the service-layer transactional audit-event pattern for durable decisions.

Implementation order

Phase 1

Contracts, schema, and config

Add status/review metadata and DB constraint; extend signed-upload constraints; add the exact private Blob image host and path to Next config.

Phase 2

Resource and portal lifecycle

Implement ticket, finalize/process, approved read, URL refresh, withdrawal, and cleanup paths behind the auditor portal and profile-image service.

Phase 3

Header upload experience

Make the avatar interactive, add the modal and Dropzone controller, show preview/progress/pending states, and render approved images with Image.

Phase 4

Admin approval seam

Add typed permissions and approve/reject service methods, transactional audit events, and the future-notification hook. A full queue UI can follow separately.

Phase 5

Hardening and reconciliation

Add failure compensation, orphan detection, URL redaction, concurrency tests, expiry tests, and private/local-provider integration tests.

Validation and exit criteria

  • Vitest covers URL renewal boundaries, upload constraints, state transitions, authorization, concurrent approval, and cleanup compensation.
  • Prisma validation and the migration test confirm backfill plus the one-approved-row constraint. Apply any new migration locally before completion.
  • Private Blob integration verifies: unsigned read fails; signed read succeeds; expired URL fails; pending image never appears in the standard profile read.
  • next/image accepts the signed private URL, the local mock URL works in development, and no wildcard storage hostname is allowed in production.
  • Uploading a replacement leaves the old approved avatar visible until approval; approval swaps it; rejection leaves it unchanged.
  • Sentry/log inspection confirms signed URLs, Blob tokens, raw EXIF, and image bytes are absent.

Open product decisions

  1. Maximum upload bytes and source pixel dimensions.
  2. Normalized output size and whether cropping is automatic or user-controlled.
  3. Whether former approved images are retained for audit, deleted immediately, or deleted after a retention window.
  4. Whether seven-day revocation latency is acceptable. If not, use an authenticated image proxy.

Public technical references: Vercel Blob private storage, Vercel Blob signed URLs, and Next.js Image configuration.