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) viaservices/media/assetDto.ts. Persisted content references assets astendsocial-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.ts→ensureVariant(asset, platform, placement), driven byplatformVariantPolicy.ts. Do not call Sharp to resize/crop/encode an asset anywhere else. - Variants are renditions, not library assets. A transform produces a
PlatformVariantrow + a private R2 object, keyed by content hash + policy version. It does not create a newCompanyAssetin 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 viaplatformMediaUploader.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-introduced | Already 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 lineage | Content-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 leaseHard rules (enforced by assertPrivateMediaDelivery + verify-deployment.ts):
- No public bucket,
r2.dev, custom public domain, orCDN_*/*_PUBLIC_*env var. - DTOs never expose
s3Key/s3KeyOptimized. UsetoAssetDTO/toAssetDTOs. - In-app
<img>/<video>use the signedviewUrl. Because it expires, any long-lived view must be able to refetch a fresh URL on load failure (seeMediaPage.tsx'sAssetThumbnailretry).
The transform pipeline: ensureVariant
ensureVariant(asset, platform, placement?) is the single entry point. It:
- Selects a policy from
platformVariantPolicy.POLICY_TABLE({platform, placement} → {variant, format, maxBytes}); fails closed on unknown platform/placement. - Downloads the source, computes its SHA-256 content hash, and validates it is a decodable image (fail closed).
- Computes a content-addressed object key:
variants/{companyId}/{assetId}/{platform}-{placement}-{policyVersion}-{hashPrefix}.{ext}. Identical inputs → identical key → identical bytes. - Reuses the existing
PlatformVariantrow + 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. - Transforms once with Sharp (
fit: 'cover'crop to the catalog ratio, re-encode to the policy format, quality-reduce to meetmaxBytesor throwMAXBYTES_EXCEEDED), uploads the deterministic object, and upserts the row.
Returns { objectKey, format, maxBytes }. Helpers:
resolveSocialMediaKey(asset, platform, placement?)→ just theobjectKey(used by publish paths).describeVariant(asset, platform, placement?)→ display metadata (ratio/width/height/format/maxBytes+objectKey) for in-app preview. The caller signs aviewUrlfromobjectKeyand never returns the key to the client (seeroutes/assets.ts→POST /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):
- Derive output identity from the encoded bytes, never the source. MIME, extension, and dimensions come from the transform result. Use
contentTypeFromObjectKeyfor the Content-Type. - Be idempotent and leak-free. Prefer content-addressed (deterministic) keys. Never
upload()then rely on a content-hash dedup increateAsset— 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). - Fail closed. A transform failure must fail the operation. Never fall back to publishing/serving the original, un-adapted object.
- Enforce size/format limits and re-validate the output against the target before claiming success.
- Stay tenant-scoped. Resolve the asset under
{id, companyId}first; authorize object keys viaobjectKeyAuthz.isAuthorizedObjectKey(asset key or a tenant-scopedPlatformVariantrow — never a string prefix). - Use
safeFetchfor 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
CompanyAssetwithparentAssetIdset (asset version lineage — already hardened, ownership is validated inasset.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 ofensureVariantrather 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
| Concern | File |
|---|---|
Private delivery DTO (viewUrl) | apps/backend/src/services/media/assetDto.ts |
| Signed URL + content-type-from-key + private-delivery guard | apps/backend/src/infra/s3.ts |
| Image transform pipeline | apps/backend/src/services/media/variant.service.ts |
Variant policy ({platform,placement} → spec) | apps/backend/src/services/media/platformVariantPolicy.ts |
| Object-key authorization | apps/backend/src/services/media/objectKeyAuthz.ts |
| External handoff leases + proxy | apps/backend/src/services/media/mediaHandoffLease.service.ts, apps/backend/src/routes/mediaProxy.ts |
| Binary platform uploads | apps/backend/src/services/media/platformMediaUploader.ts |
| Persisted asset references | apps/backend/src/services/media/assetReference.ts |
| SSRF-safe fetch | apps/backend/src/utils/safeFetch.ts |
| In-app remediation/preview UI | apps/frontend/src/features/media/components/MediaRemediationFlow.tsx |
| Variant-preview endpoint | apps/backend/src/routes/assets.ts → POST /assets/:id/variant-preview |
| Manual crop primitive | apps/backend/src/services/media/imageTransform.ts → transformImage() |
| Manual crop service | apps/backend/src/services/media/asset.service.ts → createCroppedAsset() |
| Manual crop endpoint | apps/backend/src/routes/assets.ts → POST /assets/:id/crop |
| Manual crop UI | apps/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()inimageTransform.tsis the shared Sharp primitive called by bothensureVariant(viaproduceVariant) andcreateCroppedAsset. It is the single Sharp chain — do not add another.createCroppedAsset()inasset.service.tsproduces a childCompanyAsset(library record) withparentAssetIdset to the source. This is distinct fromensureVariant, which produces aPlatformVariant(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:
createAssetdedupwhereincludesparentAssetIdso 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 inCompanyPostMedia - Variants are now content-addressed including the crop region hash:
variants/{companyId}/{assetId}/{platform}-{placement}-{policyVersion}-{sourceHashPrefix}-{cropHashPrefix}.{ext} - The
ensureVariantfunction validates crop bounds against actual source dimensions (not trustingCompanyAsset.width/height) - All social service publish functions (
publishToFacebook,publishToInstagram, etc.) now acceptmediaCropRegionsparameter - 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).