Skip to content

Media Delivery & Image Transforms

Read this before adding any feature that resizes, crops, re-encodes, or otherwise transforms an image (media library remediation, blog-editor manual crop, AI image saves, a new social platform, thumbnails, etc.).

There is exactly one image-transform pipeline in this codebase. Reuse it. Building a second one re-introduces an entire class of bugs that took six adversarial review rounds to eliminate in Story 6.10.1.

TL;DR for the impatient

  • Private delivery only. Asset bytes live in private R2. The API never returns storage keys or public CDN URLs — it returns short-lived signed viewUrls (TTL ≤ 15 min) via services/media/assetDto.ts. Persisted content references assets as tendsocial-asset://<assetId> (services/media/assetReference.ts), never a URL or key.
  • One transform pipeline. All platform-targeted image transforms go through services/media/variant.service.tsensureVariant(asset, platform, placement), driven by platformVariantPolicy.ts. Do not call Sharp to resize/crop/encode an asset anywhere else.
  • Variants are renditions, not library assets. A transform produces a PlatformVariant row + a private R2 object, keyed by content hash + policy version. It does not create a new CompanyAsset in the media library.
  • External handoff to URL-fetch platforms (Instagram, Threads, Google Business, TikTok) uses opaque, revocable proxy leases (mediaHandoffLease.service.ts + routes/mediaProxy.ts), never a presigned R2 URL. Binary-upload platforms receive authorized bytes via platformMediaUploader.ts.

Why this doc exists

In Story 6.11, a standalone AssetService.createResizedVariant + POST /assets/:id/resize path was written for media-library remediation. An adversarial review found it had independently re-introduced three bugs that the variant pipeline had already been hardened against:

Bug re-introducedAlready solved in ensureVariant by
Transcoded bytes stored under the source MIME/extension (e.g. a GIF re-encoded to JPEG but labelled image/gif)Deriving type/extension from the encoded output (infra/s3.ts#contentTypeFromObjectKey)
"Upload-then-create" with content-hash dedup → orphaned R2 objects + broken lineageContent-addressing: deterministic key from source hash + policy version; idempotent upload; no delete-on-failure (a failed PUT leaves no object — PUT is atomic)
Returning "success" with a derivative that still violates the platform (no maxBytes recheck)Fail-closed + maxBytes enforcement with a quality-reduction loop that throws MAXBYTES_EXCEEDED

The standalone path was deleted and remediation now reuses ensureVariant. If you are about to write sharp(...).resize(...) against a CompanyAsset, stop — you are probably re-creating this path.

The delivery model (Story 6.10.1)

CompanyAsset.s3Key / s3KeyOptimized   ← the only persisted delivery refs (private R2)

        ├─ in-app render → assetDto.toAssetDTO() → viewUrl  (signed GET, TTL ≤ 15 min, no-store)

        ├─ persisted content → tendsocial-asset://<assetId>  (resolved at publish with {companyId, assetId})

        └─ platform publish → ensureVariant() → PlatformVariant object
                                   ├─ binary platforms: platformMediaUploader downloads & uploads the bytes
                                   └─ URL-fetch platforms: mediaHandoffLease issues an opaque, revocable proxy lease

Hard rules (enforced by assertPrivateMediaDelivery + verify-deployment.ts):

  • No public bucket, r2.dev, custom public domain, or CDN_*/*_PUBLIC_* env var.
  • DTOs never expose s3Key/s3KeyOptimized. Use toAssetDTO/toAssetDTOs.
  • In-app <img>/<video> use the signed viewUrl. Because it expires, any long-lived view must be able to refetch a fresh URL on load failure (see MediaPage.tsx's AssetThumbnail retry).

The transform pipeline: ensureVariant

ensureVariant(asset, platform, placement?) is the single entry point. It:

  1. Selects a policy from platformVariantPolicy.POLICY_TABLE ({platform, placement} → {variant, format, maxBytes}); fails closed on unknown platform/placement.
  2. Downloads the source, computes its SHA-256 content hash, and validates it is a decodable image (fail closed).
  3. Computes a content-addressed object key: variants/{companyId}/{assetId}/{platform}-{placement}-{policyVersion}-{hashPrefix}.{ext}. Identical inputs → identical key → identical bytes.
  4. Reuses the existing PlatformVariant row + R2 object if both exist (self-heals a missing object). Concurrency is handled by content-addressing + in-process single-flight; there is no lock to poison and no delete-on-failure path.
  5. Transforms once with Sharp (fit: 'cover' crop to the catalog ratio, re-encode to the policy format, quality-reduce to meet maxBytes or throw MAXBYTES_EXCEEDED), uploads the deterministic object, and upserts the row.

Returns { objectKey, format, maxBytes }. Helpers:

  • resolveSocialMediaKey(asset, platform, placement?) → just the objectKey (used by publish paths).
  • describeVariant(asset, platform, placement?) → display metadata (ratio/width/height/format/maxBytes + objectKey) for in-app preview. The caller signs a viewUrl from objectKey and never returns the key to the client (see routes/assets.tsPOST /assets/:id/variant-preview).

The catalog is intentionally bounded (VARIANT_CATALOG: 1:1, 4:5, 9:16, 16:9) and policy-driven — never arbitrary user-supplied transform parameters. Blog destinations receive the original asset and are deliberately not in the policy.

Invariants any new transform MUST uphold

If you ever need a transform the current pipeline doesn't cover, extend ensureVariant/platformVariantPolicy — do not fork. Whatever you do, these invariants are non-negotiable (each maps to a real, previously-shipped bug):

  1. Derive output identity from the encoded bytes, never the source. MIME, extension, and dimensions come from the transform result. Use contentTypeFromObjectKey for the Content-Type.
  2. Be idempotent and leak-free. Prefer content-addressed (deterministic) keys. Never upload() then rely on a content-hash dedup in createAsset — that orphans the just-uploaded object and can return an unrelated row. If a create can fail after an upload, clean up the object on failure (or use atomic PUT + deterministic keys so there is nothing to clean up).
  3. Fail closed. A transform failure must fail the operation. Never fall back to publishing/serving the original, un-adapted object.
  4. Enforce size/format limits and re-validate the output against the target before claiming success.
  5. Stay tenant-scoped. Resolve the asset under {id, companyId} first; authorize object keys via objectKeyAuthz.isAuthorizedObjectKey (asset key or a tenant-scoped PlatformVariant row — never a string prefix).
  6. Use safeFetch for any remote byte fetch (SSRF-hardened: https-only, DNS-pinned, private/reserved ranges blocked, bounded size/timeout).

Extending this: user-driven crop (e.g. the blog editor)

A manual crop/resize feature (user drags a crop box, picks a size) is a legitimate future need, but it is different from the bounded auto-variant catalog because the crop parameters are user-supplied. Implement it on the shared primitives, not a new pipeline:

  • A user-curated crop that the user wants to reuse as its own library item (insert into a blog, re-tag, etc.) should be a new CompanyAsset with parentAssetId set (asset version lineage — already hardened, ownership is validated in asset.service.createAsset). Produce its bytes through a shared hardened transform that obeys every invariant above (decode → validate → transform → derive-MIME-from-output → deterministic/cleaned-up upload → create). Factor that core out of ensureVariant rather than duplicating Sharp glue.
  • A crop needed only to publish to a platform should extend the variant policy (e.g. add a placement/crop-box to the policy identity so it stays content-addressed and idempotent) — not a one-off transform in a route handler.

Either way: one decode/validate/encode core, output-derived identity, idempotency, fail-closed, tenant-scoped. If your design can't meet those on the existing primitives, raise it before writing a parallel path.

Map of the relevant code

ConcernFile
Private delivery DTO (viewUrl)apps/backend/src/services/media/assetDto.ts
Signed URL + content-type-from-key + private-delivery guardapps/backend/src/infra/s3.ts
Image transform pipelineapps/backend/src/services/media/variant.service.ts
Variant policy ({platform,placement} → spec)apps/backend/src/services/media/platformVariantPolicy.ts
Object-key authorizationapps/backend/src/services/media/objectKeyAuthz.ts
External handoff leases + proxyapps/backend/src/services/media/mediaHandoffLease.service.ts, apps/backend/src/routes/mediaProxy.ts
Binary platform uploadsapps/backend/src/services/media/platformMediaUploader.ts
Persisted asset referencesapps/backend/src/services/media/assetReference.ts
SSRF-safe fetchapps/backend/src/utils/safeFetch.ts
In-app remediation/preview UIapps/frontend/src/features/media/components/MediaRemediationFlow.tsx
Variant-preview endpointapps/backend/src/routes/assets.tsPOST /assets/:id/variant-preview
Manual crop primitiveapps/backend/src/services/media/imageTransform.tstransformImage()
Manual crop serviceapps/backend/src/services/media/asset.service.tscreateCroppedAsset()
Manual crop endpointapps/backend/src/routes/assets.tsPOST /assets/:id/crop
Manual crop UIapps/frontend/src/features/media/components/MediaResizeFlow.tsx

Manual image crop (Story 6.19)

Story 6.19 added a user-facing crop tool on top of this pipeline. Key points:

  • transformImage() in imageTransform.ts is the shared Sharp primitive called by both ensureVariant (via produceVariant) and createCroppedAsset. It is the single Sharp chain — do not add another.
  • createCroppedAsset() in asset.service.ts produces a child CompanyAsset (library record) with parentAssetId set to the source. This is distinct from ensureVariant, which produces a PlatformVariant (not a library record).
  • Content-addressed keys: ${companyId}/assets/edited/${sourceAssetId}/${outputHash}.${ext} — idempotent re-crops of the same source + parameters return the same child.
  • Lineage-aware dedup: createAsset dedup where includes parentAssetId so re-crops of the same parent are idempotent while non-lineage uploads dedup independently.
  • Df28 follow-up: surfacing manual crop output as a platform-targeted variant (same flow as ensureVariant) is deferred.

Df28: Manual crop as platform publish variant (Story 6.20)

Story 6.20 extends the variant pipeline to support manual crop regions passed at publish time:

  • Crop regions (cropLeft, cropTop, cropWidth, cropHeight) are passed through the publish flow and stored in CompanyPostMedia
  • Variants are now content-addressed including the crop region hash: variants/{companyId}/{assetId}/{platform}-{placement}-{policyVersion}-{sourceHashPrefix}-{cropHashPrefix}.{ext}
  • The ensureVariant function validates crop bounds against actual source dimensions (not trusting CompanyAsset.width/height)
  • All social service publish functions (publishToFacebook, publishToInstagram, etc.) now accept mediaCropRegions parameter
  • Crop regions are validated at publish time using Sharp metadata to ensure they fit within the source image dimensions

See also

  • Story _bmad-output/implementation-artifacts/6-10-1-private-media-delivery-hardening.md (the six-round adversarial hardening of this pipeline).
  • Story _bmad-output/implementation-artifacts/6-11-media-preview-and-remediation-actions.md (media-library preview/remediation built on this pipeline).
  • Story _bmad-output/implementation-artifacts/6-19-manual-image-resize-and-crop.md (manual crop UI and crop endpoint built on this pipeline).

TendSocial Documentation