diff --git a/.changeset/public-image-candidates.md b/.changeset/public-image-candidates.md
new file mode 100644
index 00000000..59163026
--- /dev/null
+++ b/.changeset/public-image-candidates.md
@@ -0,0 +1,9 @@
+---
+"@transloadit/utils": minor
+---
+
+Add `getSmartCdnImageCandidates` next to the signed candidate builder on the Node entry point.
+Share candidate validation and URL encoding while omitting signatures and expiry for genuinely
+public delivery. Callers can use receipt-derived version tags for stable, immutable cache keys.
+Include a proportional (rounded-up) fit height, or the backend limit without source metadata,
+so a Template's default height does not silently invalidate the width descriptors.
diff --git a/.changeset/storage-image-prefixes.md b/.changeset/storage-image-prefixes.md
new file mode 100644
index 00000000..ea961b80
--- /dev/null
+++ b/.changeset/storage-image-prefixes.md
@@ -0,0 +1,9 @@
+---
+"@transloadit/utils": minor
+"@transloadit/node": patch
+"transloadit": patch
+---
+
+Share Storage path and directory-prefix validation between image integrations and CLI scaffolds.
+Keep generated image recipes scoped, preserve relative imports for hidden receipt catalogs, and
+sign Storage listing requests against the endpoint belonging to the selected key credentials.
diff --git a/.changeset/storage-image-receipts.md b/.changeset/storage-image-receipts.md
new file mode 100644
index 00000000..9763e5fc
--- /dev/null
+++ b/.changeset/storage-image-receipts.md
@@ -0,0 +1,78 @@
+---
+"@transloadit/node": minor
+"transloadit": minor
+"@transloadit/mcp-server": patch
+---
+
+Release gate: publish this minor release only after transloadit/api2#9057 and
+transloadit/content#5973 are deployed. Device login, public delivery and safe CLI-key revocation
+depend on that coordinated backend/Console rollout. The image package remains private dogfood.
+At publication, switch preview-branch documentation links to their then-merged main locations.
+Require Node 20.10.0+ for JSON import attributes and composed AbortSignal cancellation. Logout
+only forgets imported and legacy application keys unless revocation is explicitly requested
+with `--revoke`.
+
+Add `client.storeImage(filePath, { path })` for one original Storage image without overwriting.
+Stream the input checksum and verify the completed receipt's path, asset ID, stored bytes and
+EXIF-oriented display dimensions. Community-plan transformations may change the stored size/MD5;
+return authoritative result metadata and expose the input comparison through `onReceipt`.
+The CLI warns about changed bytes, saves the receipt and adds bounded debug diagnostics.
+Return typed metadata suitable for saving and rendering without another lookup. Preserve Assembly
+upload progress, cancellation and errors; receipt validation after a write is not a rollback.
+
+Add `transloadit storage store ` using the CLI's existing
+Assembly credentials. Atomically append keyed receipts, preserve previous data on failures and
+reject concurrent writers, then print a ready-to-render StorageImage snippet.
+Add `storage store --hashed` for content-addressed filenames: eight MD5 hex digits before the
+extension, with catalog keys, generated types and JSX following the stored path. Retain the local
+filename as `source`; reuse matching full-checksum/size receipts without uploading. Never overwrite
+a hash conflict. Keep production-unavailable S3 recovery commands in the image reference only.
+When receipt validation fails after writing, print the destination and Assembly ID for recovery.
+Point to list/sync, not overwrite or another upload. Report pending browser approval every minute.
+Document npm-first onboarding, browser signup and free-plan watermark behavior.
+Keep receipts-file filesystem errors distinct from JSON validation failures, with the file path.
+Retain a completed temporary catalog on local replacement failures, print the verified receipt,
+and preserve an existing catalog's permissions.
+
+Add `getStoredImageReceipt({ assemblyId, expected })` to recover the same verified metadata after
+a trusted upload notification or a local file error. Add explicit `store --overwrite` and
+read-scoped `storage ls `; overwriting is never implicit.
+
+Add `storage receipts sync --receipts images.json` to recover rendering metadata from
+signed, paginated List + HEAD reads without an Assembly or original download. Rebuild
+path/width/height with an optional compatible MD5 ETag, without inventing an asset ID. Share
+atomic receipt-file writes and credential-bound endpoint resolution with the existing commands;
+preserve unmatched records and the entire previous file on metadata, listing or write failures.
+
+Add browser device authorization for `auth login`, with bounded polling, cancellation and
+owner-only credential persistence. Keep `--stdin` for an existing Auth Key, verified by a signed read.
+Keep newly entered credentials independent from project dotenv endpoint settings; save an explicit
+trusted endpoint with the key. Add `image init [--public | --private]`, with
+opt-in private `.env.local` scaffolding via `--write-env`. Never overwrite existing application files.
+Default store/sync catalogs to `transloadit.images.json`. Init writes an empty catalog and a runnable example
+for `app` or `src/app`, preserving existing files. Store prints only the saved path and component
+usage; its snippet-only public/private flags and init's dead next flag are removed. Keep upload
+asset IDs and sizes on sync only when the HEAD MD5 still matches the stored receipt.
+
+Consolidate the unpublished Next factories into `createStorageImages`; select `public`, `authorize`,
+or `delivery: 'direct'` explicitly. The authorize overload retains its typed redirect handler.
+Require Next 16.3.3 or newer in the peer range.
+
+Reuse the login workspace and combined Auth Key for optional env scaffolding without extra prompts.
+Add signed public-prefix declaration, revocation and listing methods with `storage publish`,
+`storage unpublish` and `storage publications`. Public image init declares server policy before writing
+files and explains that already cached public bytes cannot be recalled.
+
+Preserve the device key's signing algorithm in CLI credentials and subsequent API requests.
+Add `signatureAlgorithm` to SDK client options while retaining the legacy SHA-384 default and
+explicit per-call overrides. Init's env setup uses the saved key/workspace/endpoint together,
+independently of stale project or shell credentials. Public/private Template overrides are separate.
+
+Public init stores workspace and published prefixes in the committed catalog, with no app env file.
+Require public/private intent and bind Storage operations to the selected key's verified workspace.
+Support multi-file store, auth status and server-side auth logout before removing credentials.
+Infer allowed directories from
+public policy even with an empty catalog, and accepts a missing trailing slash. Storage commands
+report the winning credential source without showing credentials; store prints constrained JSX
+bounded to the receipt width. Login makes a bounded read-only Storage policy preflight and gives
+Console advice when unavailable. Keep the image quickstart concise and ship its detailed reference.
diff --git a/.changeset/storage-image-seed.md b/.changeset/storage-image-seed.md
new file mode 100644
index 00000000..3eee0a2c
--- /dev/null
+++ b/.changeset/storage-image-seed.md
@@ -0,0 +1,13 @@
+---
+'@transloadit/node': patch
+'@transloadit/types': patch
+'@transloadit/zod': patch
+'transloadit': patch
+'@transloadit/mcp-server': patch
+---
+
+Include the Transloadit Storage import and store Robots in the offline catalog and generated
+instructions, and type the optional `asset_id` in Assembly results. Recognize Storage import/store
+error codes in response validation and terminal-status helpers, preserving API errors while polling.
+Sync the canonical `recursive` option for Storage folder imports into the offline linter and
+generated instructions without adding SDK-only schema fields.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 45090311..668e68f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: Ensure yarn.lock matches dependency changes
+ - name: Ensure each package's lockfile matches dependency changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
@@ -59,13 +59,11 @@ jobs:
}
return !file.startsWith('docs/fingerprint/')
})
- const lockfileChanged = diffNames.includes('yarn.lock')
-
if (packageFiles.length === 0) {
process.exit(0)
}
- const hasDependencyChanges = packageFiles.some((file) => {
+ const changedPackages = packageFiles.filter((file) => {
let before = {}
let after = {}
try {
@@ -85,8 +83,15 @@ jobs:
})
})
- if (hasDependencyChanges && !lockfileChanged) {
- console.error('yarn.lock must be updated when dependency ranges change in package.json.')
+ // The packed Next app is a standalone npm consumer, outside the Yarn workspaces.
+ const missingLockfiles = new Set(changedPackages
+ .map((file) => file === 'scripts/fixtures/img-next/package.json'
+ ? 'scripts/fixtures/img-next/package-lock.json'
+ : 'yarn.lock')
+ .filter((lockfile) => !diffNames.includes(lockfile)))
+
+ if (missingLockfiles.size > 0) {
+ console.error(`${[...missingLockfiles].join(', ')} must be updated when dependency ranges change.`)
process.exit(1)
}
NODE
@@ -161,6 +166,12 @@ jobs:
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: img-browser-evidence
+ path: test-results/img-next
+ if-no-files-found: ignore
unit:
name: Unit tests (Node ${{ matrix.node }})
diff --git a/.gitignore b/.gitignore
index 8ec81799..5f52b340 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ sample.js
npm-debug.log
env.sh
/coverage
+/test-results/
packages/node/coverage
.pnp.*
diff --git a/biome.json b/biome.json
index 21460c51..cbc89774 100644
--- a/biome.json
+++ b/biome.json
@@ -147,6 +147,14 @@
}
},
"overrides": [
+ {
+ "includes": ["scripts/fixtures/img-next/app/**"],
+ "linter": {
+ "rules": {
+ "correctness": { "useImportExtensions": "off" }
+ }
+ }
+ },
{
"includes": ["**/package.json"],
"formatter": {
diff --git a/docs/img-dogfood.md b/docs/img-dogfood.md
new file mode 100644
index 00000000..077dc7b9
--- /dev/null
+++ b/docs/img-dogfood.md
@@ -0,0 +1,462 @@
+# `@transloadit/img` local dogfood and verification
+
+These are maintainer-only packing, SDK seed and devdock notes, not consumer prerequisites.
+Use the [package README](../packages/img/README.md) for the application integration.
+
+Responsive previews of Transloadit Storage objects, delivered through Smart CDN.
+
+Round 12's default integration is package-first: login, `storage store ./hero.jpg website/hero.jpg
+--public`, `withTransloaditImages` in Next config, and `StorageImage` imported from
+`@transloadit/img/next`. Commit both `transloadit.images.json` and `transloadit-images.d.ts`.
+`image init` and the explicit SDK/factory recipes below are optional alternatives.
+
+The package renders native ``, `srcset`, and `` elements. Image bytes travel directly
+from Smart CDN to the browser; they are never optimized or proxied by the Next.js application.
+Remote HTTP URLs are deliberately outside this package's source contract: an image must already
+belong to the configured Transloadit Storage workspace.
+
+This workspace remains private at version `0.0.0` while the API and production dogfood soak. Do not
+depend on it from npm yet.
+
+## Seed your first image
+
+This walkthrough uses Node.js 24.11 or newer and an existing Next.js 16 App Router app. The
+workspace must have Transloadit Storage writes enabled; package installation does not enable them.
+Private delivery requires `builtin/storage-preview@0.0.2`. Public delivery additionally requires
+`builtin/public-preview@0.0.1` and server-declared public prefixes. Browser login and combined
+credentials require the matching API2 #9057 and Console changes; the older canary revisions
+below predate these additions. Later owned devdock checks verified actual public delivery and
+device login with scripted signed approval. They did not test the Console UI or production Bunny.
+Private preview 0.0.1 stays unchanged and flattens transparency;
+0.0.2 accepts `bg` for alpha-preserving candidates and the opaque JPEG fallback.
+
+The server entry point needs the **Node.js runtime**, not Edge: it uses `node:crypto` and `Buffer`.
+The examples use root `app/` and `lib/` directories; adjust their relative imports for `src/app/`.
+Keep the app's stock Next.js TypeScript configuration and Node/React type dependencies.
+
+### Install the local packages
+
+Use a clean checkout of [transloadit/node-sdk](https://github.com/transloadit/node-sdk), at the
+reviewed revision of [PR #500](https://github.com/transloadit/node-sdk/pull/500). For example,
+`gh pr checkout 500` selects that PR; record `git rev-parse HEAD` before packing. From that SDK
+checkout, install its locked dependencies and pack into your own temporary directory:
+
+```bash
+corepack yarn install --immutable
+img_pack_dir=$(mktemp -d)
+corepack yarn workspace @transloadit/img pack --out "$img_pack_dir/transloadit-img.tgz"
+corepack yarn workspace @transloadit/node pack --out "$img_pack_dir/transloadit-node.tgz"
+corepack yarn workspace @transloadit/types pack --out "$img_pack_dir/transloadit-types.tgz"
+corepack yarn workspace @transloadit/utils pack --out "$img_pack_dir/transloadit-utils.tgz"
+printf '%s\n' "$img_pack_dir"
+```
+
+In the same terminal, switch to a stock Next.js app created with npm and install all four local
+tarballs. npm deduplicates the matching local workspace versions without a manual manifest edit:
+
+```bash
+npm install "$img_pack_dir/transloadit-img.tgz" "$img_pack_dir/transloadit-utils.tgz"
+npm install -D "$img_pack_dir/transloadit-node.tgz" "$img_pack_dir/transloadit-types.tgz"
+```
+
+The Assembly client is a seed-only development dependency. The optional instruction types are used
+by the packed recipe's tests and by advanced `createAssembly()` calls, not the seed helper. Utils is a real
+runtime dependency of img; the local tarballs are specific to this unpublished walkthrough.
+Img does not add the Assembly client to the browser or create an Assembly for each render. Keep
+the tarballs available for reinstalls; do not commit machine-specific paths as a production setup.
+
+For the ordinary first-image flow, return to the package README and use `npx --no transloadit` in place
+of `yarn transloadit`. The explicit SDK seed below is an advanced maintainer alternative, not a
+prerequisite for `auth login` or `storage store`.
+
+### Advanced alternative: configure a standalone seed script
+
+Skip this section when using `auth login` and `storage store`; those commands already seed the image.
+
+Use credentials from the **same workspace**. A Smart CDN-enabled Auth Key can serve both purposes
+on the combined-key API2 revision; separate keys remain optional. This advanced script retains
+explicit Assembly variable names to avoid accidentally loading a local endpoint into Next. Add both
+`.env.seed.local` and `.env.local` to the app's `.gitignore` before creating them:
+
+The seed below selects `signatureAlgorithm: 'sha256'` for a combined key. A legacy Assembly-only
+key may require `'sha384'` instead; match the algorithm configured on that key. CLI browser login
+stores this metadata automatically for subsequent CLI requests.
+
+- `TRANSLOADIT_ASSEMBLY_KEY` and `TRANSLOADIT_ASSEMBLY_SECRET`: an **Assembly Auth Key** and its
+ secret, used to sign the one-time upload/store Assembly. Put these in **`.env.seed.local`**,
+ loaded only by the seed command below.
+- `TRANSLOADIT_SMART_CDN_KEY` and `TRANSLOADIT_SMART_CDN_SECRET`: a **Smart CDN Auth Key** and its
+ secret, with Smart CDN enabled **and `smart_cdn:sign`** (`assemblies:write` is also accepted).
+ Put these in **`.env.local`** for Next.js. An Assembly-only key cannot replace this key.
+- `TRANSLOADIT_WORKSPACE`: put the workspace's URL slug in `.env.local` too. In a Console URL such
+ as `/c/my-workspace/`, the slug is `my-workspace`, not a key or workspace ID.
+
+Do not use a `NEXT_PUBLIC_` prefix or commit credentials. Public-only rendering needs just the
+workspace, not a key. Private rendering accepts `TRANSLOADIT_KEY/SECRET` or the explicit Smart CDN
+override pair above. Next.js loads `.env.local`; all keys placed there must remain server-only.
+`.env.seed.local` is outside Next's normal env-file names. Manage keys in the
+[workspace Console](https://transloadit.com/c//template-credentials/).
+
+`TRANSLOADIT_ASSEMBLY_ENDPOINT` is an optional seed-only override. Omit it for the SDK default,
+`https://api2.transloadit.com`; the local-devdock case is explained below.
+
+### Store one image and keep its verified metadata
+
+Save this as `seed.ts` in the app. `client.storeImage(filePath, { path })` uses your Assembly key
+to store one local original at an explicit complete destination path. It streams the checksum,
+waits for completion and verifies exactly one matching receipt: a nonempty typed `asset_id`, exact
+path, byte count, MD5, and positive safe-integer image dimensions. It returns `StoredImageReceipt`.
+
+Underneath, one `/transloadit/store` Assembly annotates its input: the receipt is in
+**`results[':original']`**, not `results.stored`. Those fields were verified in a real Storage canary.
+
+```ts
+import type { StoredImageReceipt } from '@transloadit/node'
+
+import { Transloadit } from '@transloadit/node'
+
+/** Seed with an Assembly key, then save the receipt for rendering without another lookup. */
+export function seedStorageImage(
+ client: Transloadit,
+ filePath: string,
+ path: string,
+): Promise {
+ return client.storeImage(filePath, { path })
+}
+
+async function main(): Promise {
+ const authKey = process.env.TRANSLOADIT_ASSEMBLY_KEY
+ const authSecret = process.env.TRANSLOADIT_ASSEMBLY_SECRET
+ const [filePath, path] = process.argv.slice(2)
+ if (!authKey || !authSecret || !filePath || !path) {
+ throw new Error(
+ 'Provide an Assembly key/secret and run: node seed.ts ./image.jpg website/image.jpg',
+ )
+ }
+ const client = new Transloadit({
+ authKey,
+ authSecret,
+ signatureAlgorithm: 'sha256',
+ endpoint: process.env.TRANSLOADIT_ASSEMBLY_ENDPOINT,
+ })
+ console.log(JSON.stringify(await seedStorageImage(client, filePath, path), null, 2))
+}
+
+if (import.meta.main) {
+ main().catch((error: unknown) => {
+ console.error(error)
+ process.exitCode = 1
+ })
+}
+```
+
+Run it once for an image you want to store, keeping the printed record as app data:
+
+
+```bash
+(
+ set -e
+ receipt_tmp=$(mktemp ./image.json.XXXXXX)
+ trap 'rm -f "$receipt_tmp"' EXIT
+ node --env-file=.env.seed.local seed.ts ./canal-house.jpg website/canal-house.jpg > "$receipt_tmp"
+ mv "$receipt_tmp" image.json
+)
+```
+
+
+The temporary sibling is renamed only after success. A failed rerun preserves the previous receipt.
+
+The helper requires the full filename, not a directory or an interpolation expression. Advanced
+`createAssembly()` instructions can use the single-quoted `'website/${file.url_name}'` literal:
+Transloadit, not JavaScript, substitutes the input's URL-safe filename in that expression. Node 24 detects
+ES module syntax when `package.json` has no `type`; explicit `"type": "commonjs"` is different.
+The stock create-next-app manifest needs no change for this native TypeScript seed. No tsx or
+ts-node runner is needed. See [Node's module detection](https://nodejs.org/download/release/v24.11.0/docs/api/packages.html#syntax-detection).
+
+Proceed only when the command exits successfully. `conflict_strategy: 'error'` makes a repeated
+upload to the same path fail rather than silently replacing an asset. Choose a different filename
+or explicitly set `overwrite: true` (CLI: `--overwrite`). Do not modify the input file while
+it is being checksummed and uploaded. Receipt validation happens **after the Storage write**:
+a validation error is not a rollback, and retrying the same path can encounter the stored object.
+An `InconsistentResponseError` retains `cause.assemblyId` for investigation without copying the
+Assembly response. Existing API, timeout and cancellation errors propagate unchanged.
+
+The helper also accepts `signal`, `chunkSize`, `onUploadProgress`, `onAssemblyProgress` and the
+existing Assembly `timeout` (upload/polling, not local checksum time). It never accepts replacement
+steps. Overwrite remains opt-in. Use `createAssembly()` for multi-file or transformation workflows.
+
+The resulting JSON contains `asset_id`, `path`, `size`, `md5hash`, `width`, and `height`. Keep it
+alongside your content or in your application's database; rendering needs no metadata request.
+The dimensions account for EXIF orientation, matching Storage preview's automatic rotation:
+a stored 450×600 photo tagged “Rotate 90 CW” returns a 600×450 display size.
+The `asset_id` identifies the stored asset. Pass the whole receipt as `src`; its path and dimensions
+drive rendering. Public URLs carry `v`, a cache-busting tag derived from the receipt hash; the origin
+does not verify it, so a cold request after an overwrite can return the replacement. Use immutable
+filenames instead of overwriting published assets. Receipt IDs and other upload-only fields do not enter markup. The rendering
+package does not import the Assembly client.
+
+### Direct devdock origin
+
+For a local devdock seed only, set `TRANSLOADIT_ASSEMBLY_ENDPOINT` to your trusted Assembly API
+endpoint. This is separate from the Smart CDN origin. For direct devdock image delivery, configure
+the image factory with the trusted URL Transform `baseUrl` (including its `{workspace}` placeholder)
+and `urlParams: { cdn: 'required' }`. This supplies API2's explicit `cdn: required` acknowledgment
+because native image requests cannot attach a custom header. It does **not** install a CDN or
+bypass access policy. Private URLs still require a Smart CDN-enabled key; public URLs require a
+published prefix. Never take either endpoint override from a request.
+Normal Smart CDN delivery needs neither local override.
+`createStorageImages` accepts these same `baseUrl` and `urlParams` fields directly,
+alongside `allowedPathPrefixes` and `authorize`.
+
+The factory exports `StorageImage`. Use one flat `createStorageImages({ images, public })`
+shape for the public Content hero, with catalog-typed src and fill/cover breakpoint ratios.
+Public direct markup is static; private direct images remain request-rendered. See the package
+README for layout and authorization policy; this document only covers maintainer setup.
+
+Before publication, a Yarn consumer may resolve a registry copy of `@transloadit/utils` under
+the image package even when the new utilities tarball is a direct dependency. For local dogfood,
+explicitly resolve that dependency to the same packed utilities. The release must bump utilities
+and the dependent minimum versions together; publishing img against the old minimum is unsafe.
+
+### Live Storage listing and rendering receipt recovery
+
+The historical oriented-receipt canary used API2 `07ec5abc2b71d449a7474391c8eeef4934ef3589`.
+It is stopped while waiting for the public/login revision. Only that internal-only, port-free devdock's
+`env.sh` custom overrides enable `API2_STORAGE_S3_ENABLED=true`. Production remains unchanged.
+
+`transloadit storage ls website/ --json` discovers the workspace and lists the existing images,
+including `website/stranger4.jpg` from the accepted stranger trial. Its 92,230-byte size and ETag
+match the saved receipt. The command succeeds using the endpoint saved with ordinary read-scoped
+Auth Key credentials, even with an unrelated bearer token present. An explicit `--endpoint`
+overrides a saved local decoy; without that override the same decoy is reached. An unmatched prefix
+returns an empty list, a different workspace returns 404, and unsigned HEAD returns 403.
+These are real signed HTTP requests to API2, not mocked listing responses or new Storage writes.
+
+The first disabled-controller probe failed with HTTP 403. After enabling it, the canary's special
+Assembly-admin shortcut was rejected with `InvalidAccessKeyId`; an existing normal read-scoped
+workspace Auth Key works. No key was created or broadened, and Smart CDN credentials are not used
+for listing. This local fixture distinction does not require an SDK authentication workaround.
+
+Public current and versioned HEAD/GET now return `dam-width=1024` and `dam-height=683` for the
+stranger image. GET bytes match its saved MD5 and byte count. This closes the missing-public-
+dimensions gap recorded against the earlier `5235a3605f` head. An asset ID is not needed for
+rendering: `storage receipts sync --receipts images.json` uses signed List + HEAD to
+recover path/width/height and an MD5 only when its ETag is compatible. No original downloads,
+Assemblies, private backing-store credentials or fabricated upload-integrity fields are needed.
+Keep committing the generated JSON before building; it is now recoverable from catalog metadata.
+
+The rotated-photo discrepancy is fixed and independently verified on `07ec5abc2b`. Two fresh
+uploads preserve their original bytes while reporting display-oriented dimensions everywhere:
+`rotated_8.jpg` is encoded 450×600 and displays at 600×450; `receipt-exif-orientation.jpg` is encoded
+616×800 and displays at 800×616. Both carry EXIF orientation 6, independently read with ExifTool.
+SDK receipts, current asset/version rows, backing object metadata, current/versioned public
+HEAD/GET and the actual packed receipts-sync CLI all agree. The recovered JSON has both expected
+display sizes and original MD5s, without fabricated asset IDs.
+
+Older `website/construction.jpg` still has no dimensions, so a sync containing it must fail without
+replacing the existing file. New uploads used fresh paths; this proof does not backfill historical
+objects. No API2 implementation or canonical schema was changed by this SDK follow-up.
+
+Current oriented proof: `/tmp/img-exif-live.log`, clone17's `tmp/img-exif-sync-result.json`
+(2026-09-13T01:20:12.868Z). The earlier failed `b4aba072ee` upload/HEAD assertion remains in
+`tmp/img-sync-oriented-receipt.json`; listing/endpoint proofs remain in `/tmp/img-sync2-head.log`
+and `/tmp/img-storage-ls-result.md` as historical evidence.
+
+The actual packed `storage receipts sync website/stranger --receipts images.json --json` failed
+first on the old package, then passed after normal installation of the new SDK tarball. It
+recovered all three stranger images at 1024×683 with the original MD5, without asset IDs. The
+explicit endpoint bypassed a saved decoy; without override the decoy was reached. Empty results
+preserved existing entries, and including the legacy object with missing dimensions failed
+without changing the complete prior file. Evidence: `/tmp/img-sync2-live-{red,green}.log` and
+clone17's `tmp/img-sync2-cli-result.json` (2026-09-12T22:22:14.120Z).
+
+## Verification
+
+```console
+corepack yarn workspace @transloadit/img check
+corepack yarn test:img:fixture
+```
+
+The fixture packs all four local artifacts and installs them with its pinned **npm** lockfile into
+a clean Next.js app. It executes this exact seed recipe against mocked Assembly receipts without
+network access and compiles it against the packed SDK/types. It builds and serves both production
+Cache Components configurations, then runs 72 Chromium/WebKit cases (18 cases × 2 engines ×
+2 configurations): native cookie authorization,
+GET/HEAD parity, explicit public-prefix caching, responsive art direction with real cropped bytes,
+separate app/CDN hosts, constrained hero/fixed avatar geometry, portrait fillcrop, optional error
+fallback and same-page sign-in/refresh recovery, the actual CLI-generated empty and populated pages,
+unsigned public Built-in policy and immutable versioned responses, the constrained public page
+without a CSS reset, private-redirect decoding before application JavaScript, hydration, bounded JPEG fallback,
+original-capability renewal, revocation, expiry and tampering. Chromium
+also verifies direct streaming before application JavaScript; direct WebKit navigation uses normal
+script loading because holding bundles can stall React's streaming reveal in the test browser.
+That extra WebKit pre-JS scenario remains unverified. The owned
+local image origin independently enforces signatures/expiry and declared public prefixes, then serves real encoded bytes; it
+never receives the application's session cookie. Transparent AVIF/WebP/PNG corners and the signed
+opaque JPEG background are checked at the pixel level. This origin emulates the Built-in contract;
+it does not execute API2's transformation pipeline. Secret scans cover rendered/client artifacts.
+The generated public-only application also builds in both modes with no signing credentials.
+
+The test records browser evidence and direct-versus-redirect HTML size and route work for 1, 20,
+and 100 images. Wall-clock measurements are diagnostic, not CI performance thresholds. This local
+proof does not measure production CDN latency/caching or prove the deployed API2 pipeline.
+The fixture checks the packed dependency graph independently of the own-devdock consumer test.
+
+## Archived PR verification through round 10
+
+The following commit-stamped ledger previously lived in #500's description. It records historical
+checks, not a claim that they ran on the latest head. Current round-11 receipts are recorded separately.
+
+## Round 8 verification
+
+Source verification on `00f5ec0` (all review findings reconciled):
+
+- `@transloadit/img check`: 258 tests plus type checks pass.
+- Full repository verification including knip/types: 537 Node tests pass (one preexisting skip),
+ plus the image, utils, schema and relay checks.
+- Packed native Chromium/WebKit fixture: 80 first-attempt passes with Cache Components enabled
+ and omitted. Independently audited local and downloaded CI evidence has no skips, retries,
+ flakes or unexpected errors.
+- [Exact-source CI](https://github.com/transloadit/node-sdk/actions/runs/34792479448) is green:
+ Verify fast/full, Node 20/22/24, E2E, release dry run and the browser fixture.
+- Final documentation-only head `5561c31` also has
+ [green CI](https://github.com/transloadit/node-sdk/actions/runs/34793045157); its downloaded
+ browser artifact independently passes the same 80-case audit, without retries or skips.
+- Live API2 `b2264e1767`: ordinary packed installation, fresh zero-env public scaffold, actual
+ device login/key metadata, five wrong-workspace refusals, upload, List + HEAD recovery, decoded
+ direct public delivery at desktop/mobile, unpublish and self-revoking logout all pass at 3e473f4,
+ including API2's unrestricted (`signature_algo: null`) key. Final fixes do not change the direct
+ image byte path; the real API2 receipt remains explicitly stamped `3e473f4`, not relabeled.
+ The unchanged consent/logout path
+ separately passes all 14 live checks, including imported-key consent and negated-flag refusal.
+- Actual OS SIGINT and SIGTERM sent to the current built CLI during stalled discovery, listing,
+ HEAD, publish, unpublish and public init: all twelve cases preserve the catalog and remove its
+ lock with a graceful exit. The packed fixture also builds generated mixed-catalog
+ public/private scaffolds rather than only hand-maintained examples.
+- Claude UX and defensive security reviews pass; their useful findings were fixed red-first.
+ Council findings are fixed with regression coverage; targeted Opus closure reports PASS with
+ no blockers. One unrelated preexisting lint warning remains; verification has no errors.
+
+The live canary uses only the owned local API2/devdock and toy object storage. Scripted signed
+device approval is not proof of the production Console UI or a human onboarding time.
+Safe CLI logout uses API2's `DELETE /auth_keys/self`; no broad Auth Key management scope is added.
+Both CLI manifests require Node 20.10.0+ for their actual JSON import/cancellation primitives.
+Temporary local keys/publications were cleaned up and task-owned servers stopped. No claim of
+production Console or Windows runtime coverage. Local report: `/tmp/img-task2-round8-report.md`.
+
+## Round 8 signup-test addendum
+
+On `ce623e2`, a completed Storage write uses the Assembly's authoritative receipt even when
+the server changes the upload bytes. The CLI saves that metadata, explains changed bytes or
+checksums, and does not suggest overwriting. External Assembly-ID recovery remains strict.
+Missing receipts get targeted, shell-quoted list/sync commands preserving the selected endpoint,
+workspace and catalog. Canceled/in-progress Assemblies retain their status-specific message.
+
+README/reference commands are npm-first, signup is explained, and store/sync help agree on
+`transloadit.images.json`. Waiting browser approval emits a safe stderr heartbeat every minute.
+Debug output is restricted to verified receipt facts, Assembly ID and comparisons; raw Assembly
+responses, signed URLs and credentials are never dumped. Async observer failures are contained.
+
+The six council findings and three minor Opus suggestions were fixed red-first. Full local
+`yarn check` passes: 549 Node tests (one preexisting skip), 258 img plus utils/schema/relay checks.
+The packed Chromium/WebKit matrix passed 80 first attempts after the six review fixes. All
+jobs in [code-head CI](https://github.com/transloadit/node-sdk/actions/runs/34795854316) are green;
+its downloaded browser artifact independently confirms 80 first attempts with zero retries,
+skips, flakes or unexpected errors, including the final warning/help/reference polish.
+Opus returned PASS/no blockers for the changed UX and defensive-security boundaries.
+
+Real Community-plan canary with an ordinary npm-installed `ce623e2` tarball: store, default
+catalog, listing, receipt sync and byte-identical signed CDN delivery pass. API2 `b2264e1767`
+already exempts stored originals from watermarking; older transformed-response compatibility is
+tested with protocol fakes, not claimed as live old-policy coverage. The temporary local DNS
+failure was traced to the owned devdock's missing tmp hostname and repaired without API2 source,
+env or production changes. Console signup/redirect fixes remain with their owner.
+Detailed evidence and limitations: `/tmp/img-task2-round8-addendum-report.md`.
+Final documentation-only head `c7017d3` also has
+[green CI](https://github.com/transloadit/node-sdk/actions/runs/34796223956); its downloaded
+browser artifact separately confirms the same 80 first-attempt passes without retries or skips.
+
+## Round 9 — stranger-test follow-ups
+
+Completed on `3b26679d4d618334e2cb58270caeac595abe6306`:
+
+- F11: development size hints wait for decoded, laid-out images, ignore transient 0/1px boxes
+ and account for native density correction. Correct cached desktop candidates reused on mobile
+ no longer blame `sizes`; genuinely oversized candidates still warn.
+- F9/F10: the generated scaffold includes an accessible visible delivery-error fallback and
+ explains decorative alt text. Diagnostics no longer prescribe an unnecessary server restart.
+- F2/F3: existing-login refusals name the safe saved identity, file and modification date and
+ offer a separate credentials file. Concurrent logins preserve the winner and distinguish newly
+ approved keys from imported application keys. Auth help lists each command once, retaining aliases.
+- README: short npm/pnpm/Yarn entry, maintainer-supplied unpublished tarballs, signup/code timing,
+ Next.js floor, named delivery overrides and the actual Console **Credentials** sidebar label.
+
+Verification:
+
+- Full `yarn check`, then sequential img check → verify → packed consumer fixture pass:
+ 263 image tests, 560 Node tests plus one preexisting skip, and utils/schema/relay/root/MCP checks.
+- [Exact-head CI](https://github.com/transloadit/node-sdk/actions/runs/34800196222) is green on
+ its first run attempt: Verify fast/full, Node 20/22/24, E2E, release dry run and browser fixture.
+- Independently audited local and downloaded CI artifacts confirm **92 native Chromium/WebKit
+ first-attempt passes**: 42 with Cache Components enabled, 42 omitted, eight in development.
+ Zero retries, skips, flakes or unexpected browser/network errors. No audit exemption was added.
+- Council closure reports no issues. Claude UX and defensive-security reviews both PASS;
+ valid findings were fixed red-first, including the concurrent-login cleanup wording.
+- Ordinary npm-installed manual desktop/mobile proof covers decoded images, cached candidate
+ reuse, accessible failure and Fast Refresh recovery with the same server PID. It uses owned
+ localhost contract fakes, not a new API2/Console signup canary or production Bunny measurement.
+
+The dev tiny-box test initially rewrote streamed HTML, inducing a WebKit reload/font cancellation.
+It now changes only CSS, asserts native resize observation and preserved hydrated state; initial
+pre-layout scheduling remains unit-tested. Failed attempts and the final green receipts are
+retained in `/tmp/img-task2-round9-report.md`, alongside `/tmp/img-r9-evidence-OWBFjJ/`.
+One preexisting lint warning and Node unit skip remain. Own services are stopped and the worktree
+is clean. The incoming **6m13** signup result remains the earlier reader's measurement.
+
+No merge/publication, dependency/schema/Built-in/auth-scope changes, API2/Content edits, env-file
+edits or production changes in round 9. `@transloadit/img` is still private at 0.0.0.
+
+### Round 9 follow-up — response-read ownership
+
+Completed on `8c39cded80ef638cde7f05db0b509fd2abab8540`. The reported
+`response.body: Test ended.` was a spec lifecycle race: the reads-array snapshot could miss
+later work. Response listeners now return their async work to Playwright; main and probe
+cleanup remove/drain those listeners before page/context closure. No product code, retry,
+timeout or browser-error exemption changed.
+
+- Red-first native regression holds the audit body read while the browser decodes normally.
+ Both engines fail on the old handler; both wait correctly after the fix.
+- Focused council: no issues found. Full `yarn check`, then sequential img check → verify →
+ packed fixture pass on this head (263 img, 560 Node plus the existing unit skip).
+- [Exact-head CI](https://github.com/transloadit/node-sdk/actions/runs/34801987887) is green on
+ attempt 1. Independently audited local and downloaded CI artifacts confirm **96 native
+ first-attempt passes**: 44 enabled, 44 omitted and eight development; no retries, skips,
+ flakes or unexpected errors.
+- Evidence: `/tmp/img-task2-round9-report.md`. The earlier 92-case receipts above remain
+ attributed to their original head. No merge or publication.
+
+## Round 10 — final reader residuals
+
+Completed on `2befc537b3736617ed28080b80891c40731c8a49`. Development diagnostics now name
+the actual probed origin/path, without query values or credentials. Generic HTTP failures and
+unreachable hosts get distinct wording and the `baseUrl/urlParams` factory hint; publication,
+auth and 404 advice, background timing and production silence remain unchanged.
+
+The six requested README clarifications cover the workspace-derived CDN host, saved login API
+endpoint, separate credential file, Console Credentials path, optional `src/` layout and supplying
+a JPEG. Council's sole P3 clarified that the credential-file override belongs in the shell;
+fixed red-first. README stays at 180 lines, Quickstart at 36. No other product work.
+
+- Seven diagnostic regressions fail before the fix; the affected suites pass afterward.
+ Real loopback HTTP 400 and closed-port probes verify the URL, distinct advice and redaction.
+- Full `yarn check`, then sequential img check → verify → packed consumer fixture pass:
+ 264 img tests, 560 Node tests plus the existing skip, and root/utils/schema/relay/MCP checks.
+- [Exact-head CI](https://github.com/transloadit/node-sdk/actions/runs/34803447265) is green on
+ attempt 1. Independent local and downloaded artifact audits confirm **96 native first-attempt
+ passes** (44 enabled, 44 omitted, 8 development), with no retries, skips, flakes or unexpected
+ errors. Browser assertions and network exemptions were not changed.
+- Report: `/tmp/img-task2-round10-report.md`. The incoming **4m58** signup result remains the
+ independent reader's measurement on 3b26679/API2 b2264e1767/Content 40210c67f1, not this head.
+
+Stop after this round. No new reader round, merge, publication, production requests,
+API2/Content edits or environment changes; `@transloadit/img` remains private at 0.0.0.
diff --git a/docs/prompts/2026-09-12-img-review.md b/docs/prompts/2026-09-12-img-review.md
new file mode 100644
index 00000000..14de7758
--- /dev/null
+++ b/docs/prompts/2026-09-12-img-review.md
@@ -0,0 +1,34 @@
+# Storage image onboarding and delivery
+
+## Why
+
+Finish the private, unpublished `@transloadit/img` integration in node-sdk #500. One original,
+one typed catalog and one factory should serve public marketing images and authorized private
+images without proxying image bytes through Next.
+
+## Contract
+
+- One `createStorageImages` factory; explicitly choose public prefixes, an authorizer or direct delivery.
+- Declared-public prefixes use permanent unsigned Smart CDN URLs; private delivery keeps bounded
+ signatures, native-cookie authorization and opaque redirect capabilities.
+- Browser CLI login saves a server-only Auth Key and workspace; init creates a runnable example
+ and catalog. Store validates upload evidence; sync preserves it only when the MD5 agrees.
+- Device creation and token polling send form-urlencoded fields; approval uses signed API params.
+ Public directory declarations are bounded to 512 UTF-8 bytes, including their trailing slash.
+- Public delivery requires the matching API2 public-prefix contract and public Built-ins.
+ API2 and Console implementation are separate work; test SDK boundaries with contract fakes
+ until the owned devdock is updated.
+- Preserve strict path scope, transparent previews, EXIF dimensions, catalog types and native
+ responsive layout. No arbitrary-origin loader, application byte proxy or silent overwrites.
+
+## Verification and release gates
+
+Write failing regressions first. Run package checks, repository verification and the packed Next
+fixture in both Cache Components modes with Chromium/WebKit. Reconcile independent council and
+security findings; verify exact-head GitHub CI. Then test actual device approval, public/private
+delivery and the Content hero against the owned devdock.
+
+Keep detailed session evidence outside the repository. Maintain reproducible consumer instructions
+in the package README and `docs/img-dogfood.md`. Do not merge or publish without Kevin's approval.
+Before release, coordinate API2/Console deployment and matching utils/types/node/img versions,
+ordinary registry installs, sustained Content dogfood and timed tests with real developers.
diff --git a/docs/prompts/2026-09-13-img-onboarding.md b/docs/prompts/2026-09-13-img-onboarding.md
new file mode 100644
index 00000000..edd891fd
--- /dev/null
+++ b/docs/prompts/2026-09-13-img-onboarding.md
@@ -0,0 +1,531 @@
+# Round 7 onboarding review
+
+Why: the public-image quickstart must remain secretless from login through the first image,
+and errors must describe the API's actual wire contract.
+
+PR: https://github.com/transloadit/node-sdk/pull/500
+
+- [x] Diagnose unpublished paths via `Transloadit-Error: NO_SIGNATURE_FIELD`; retain generic
+ HTTP 400 advice and path/template advice for 404.
+- [x] Infer allowed public prefixes with an empty catalog; preserve explicit deny-all policies.
+- [x] Public init writes only the workspace; private init retains rendering credentials.
+- [x] Print bounded, constrained JSX from store and disclose the selected credential source.
+- [x] Rename the policy listing command, normalize init prefixes and preflight Storage after login.
+- [x] Keep the quickstart focused and reference details separate; qualify test and byte counts.
+- [x] Run sequential package/repository/browser checks and council/Opus reviews.
+- [x] Test the real login key with List + HEAD sync and a fresh public-only Next app in devdock.
+
+Council's source-width clamp is covered red-first and in the packed browser matrix: a 320px
+original cannot stretch to a larger constrained maxWidth. Final exact-head CI remains a merge
+gate; package checks count executed parameterized cases, not test declarations.
+
+No merge or publication in this round. No blur placeholder, origin version selector, workspace
+picker or logout implementation. API2 owns the S3 eligibility and error-header changes; Console
+owns real browser approval. Local canaries do not establish production Bunny latency.
+
+## Round 8
+
+Why: a public project needs one committed source of truth and familiar image props; the CLI
+must not silently write into a different workspace than the application renders.
+
+- [x] Commit workspace, public prefixes and receipts in `transloadit.images.json`; keep explicit
+ `--receipts` paths, and require `--public` or `--private` at init.
+- [x] Verify project/workspace binding before store, list, sync, publish and unpublish.
+- [x] Default catalog/receipt images to constrained `width`; make `priority` eager/preload/high.
+- [x] Derive art-direction container ratios from the same map as crop candidates.
+- [x] Unify duration units, isolate experimental props, document explicit basePath and diagnose
+ oversized candidates only in development.
+- [x] Add scoped logout/status, multiple-file storage and copy-safe alt/Windows instructions.
+- [x] Document deployment and release dependencies; remove unmeasured performance claims.
+- [x] Run red-first tests, sequential package/full/browser checks, council, local UX/security,
+ the live zero-env/mismatch/logout canary and exact-head green CI.
+
+API2 b2264e1767 supplies `DELETE /auth_keys/self` and token `auth_key_id` / `description`. Real
+packed login/status/logout passes in owned devdock17 without broad Auth Key management rights.
+The zero-env public scaffold renders real unsigned images in Chromium/WebKit at desktop/mobile;
+all five bound Storage commands refuse the wrong workspace before acting.
+
+Council's large-original, height-only and bearer-token/fallback-credential findings were reproduced
+red-first and fixed. UX review found no blockers. The mismatch wording is Kevin's explicit
+contract; JSON-quoted generated imports deliberately escape arbitrary receipt-file paths safely.
+CLI next-step wording is usable but could be more copy-paste-oriented in a later polish pass.
+Security review's empty-discovery and endpoint-provenance edges are covered red-first; a changed
+shell endpoint requires fresh workspace discovery. API2 keys are workspace-scoped, so ambiguous
+multi-bucket responses fail closed with endpoint/key advice, not an ineffective override suggestion.
+Private `--write-env` only persists the saved login, never transient shell fallback credentials.
+The follow-up council caught imported application-key revocation: login now records its method;
+logout forgets imported/legacy keys unless `--revoke` is explicit, and still revokes browser-login
+keys. Docs warn that applications sharing a browser-login key also lose access on logout. Red-first
+tests cover provenance spoofing in stdin, imported opt-in, legacy files, failed cleanup preserving
+publication warnings, and actionable bearer-token advice. Actual runtime smoke tests showed JSON
+import attributes need Node 20.10.0, beyond AbortSignal.any's 20.3.0 floor; both CLI manifests agree.
+The next review's project-selected credential-file bypass is closed for both ownership flags;
+only the shell-selected login path can retain verified provenance. Local-only logout can forget
+unusable legacy credentials, but explicit revocation still requires a valid signing key.
+`--no-revoke` is rejected rather than silently ignored. Recovery links use the command's actual
+workspace. Red-first cases cover these boundaries; workspace-option precedence is documented.
+Live logout also exposed the documented asynchronous API2 cache boundary: explicit revocation
+soft-deletes immediately, while this daemon-free test uploader denied reads after 116 seconds.
+The failed immediate-denial assumption is preserved; this is not a production revocation SLA.
+Additional reviewed onboarding edges are covered red-first: nullable API2 signature algorithms,
+private initialization beside existing public directories, and examples choosing only a receipt
+under the requested directory. The packed fixture now builds these actual mixed-catalog outputs.
+Login also snapshots its default home before dotenv injection, with an OS-user fallback for
+Node's empty-HOME result, so a project cannot redirect new credentials through HOME/USERPROFILE.
+The final lifecycle review restores explicit expiry at the signed-candidate boundary, detaches
+long-lived browser openers and adds cooperative interrupt cleanup to the existing atomic catalog
+writer. Active uploads/discovery/list/HEAD cancel; completed receipts checkpoint before exit.
+An accepted Assembly may still finish remotely, and forced exits/crashes still need lock inspection.
+Relative HOME values also fall back to the OS account directory. Empty examples and init output
+now name the initialized directory. These cases are red-first and covered by real loopback S3
+requests plus the packed fixture; the latest full `yarn check` passes.
+Final review reconciliation forwards cancellation into publication HTTP calls too (publish,
+unpublish and public init), distinguishes successful checkpoints from failed receipt recovery,
+limits unversioned compatibility redirects to one shared-cache minute and clarifies JSON ETags.
+Opener failures are warnings, not false login results on JSON stdout. All have red-first coverage.
+
+Round 8 source verification is complete on `00f5ec097cb4809878343a31b6f85cb982ee796e`:
+full `yarn check`, then img check → verify → packed fixture pass (258 img, 537 Node plus one
+preexisting skip, 59 utils, 22 relay). Local and downloaded CI artifacts independently confirm
+80 native browser passes with no retries, skips, flakes or unexpected errors. All jobs in
+https://github.com/transloadit/node-sdk/actions/runs/34792479448 are green. Actual SIGINT/SIGTERM
+tests pass across six stalled CLI phases, including publication, and targeted Opus review confirms
+all final council fixes with no blockers. One preexisting Biome warning remains outside this scope.
+The last full real API2/browser canary is explicitly `3e473f4` (20 observations, unrestricted key);
+the final corrections above do not change its direct image byte path. The final report retains
+failed runs, later passes and source hashes separately, including API2's asynchronous revocation
+boundary. Task-owned services are stopped and API2's preexisting working changes are preserved.
+
+No merge, package publication, Content changes, Thumbhash or origin version selector in this round.
+Detailed local receipts, review reconciliation and remaining gates: `/tmp/img-task2-round8-report.md`.
+
+## Round 8 signup-test addendum
+
+Why: older deployments watermark Community uploads before Storage runs, so a successful write can differ
+from the local file. The CLI must save authoritative receipt metadata and explain this, not invite
+a destructive retry. Input: `/tmp/img-task2-round8-addendum.md` and the stranger signup report.
+
+- [x] Accept valid single-original Storage results from the requested write despite changed bytes;
+ preserve strict path/asset/dimension validation and separate trusted-fact Assembly recovery.
+- [x] Save the actual receipt, warn about changed bytes/plan transformations, and show bounded
+ debug comparison details. Missing receipts get list/sync recovery advice, never overwrite advice.
+- [x] Make the README npm-first, explain signup/free-plan watermarks and align CLI help examples.
+- [x] Report pending browser approval about once per minute without leaking credentials.
+- [x] Explicit init delivery choice and self-key logout already implemented and covered in round 8.
+- [x] Red-first tests, focused review, package/full/packed checks and exact-code-head green CI.
+
+Console signup/redirect/sidebar fixes remain with its owner. No merge, publication or watermark
+policy change is authorized by this addendum.
+API2 `cb23326114` already exempts Storage originals from Community watermarking; qualify that
+warning as compatibility with older deployments. Evidence: `/tmp/img-task2-round8-addendum-report.md`.
+
+Completed code head `ce623e261393f41c5c7859ceb601c54daecee6f2`: full `yarn check`, then img check
+and `verify:full` PASS (549 Node + one preexisting skip, 258 img, 59 utils, 22 relay and the
+root/schema/MCP/type checks). All jobs in
+https://github.com/transloadit/node-sdk/actions/runs/34795854316 are green. Its downloaded browser
+artifact independently confirms 80 first-attempt Chromium/WebKit passes in both Cache Components
+modes, zero retries/skips/flakes/unexpected errors. Evidence: `/tmp/img-r8a-final-ci-browser-audit.json`.
+
+The six council findings and three minor Opus suggestions are fixed red-first; both focused Opus
+UX/defensive-security reviews PASS. Recovery commands keep endpoint/workspace/catalog and use a
+filename prefix, canceled/pending Assemblies retain status-specific messages, asynchronous receipt
+observer errors are contained, and approval countdowns use a monotonic clock. Reference and sync
+help now agree with the npm-first Quickstart; checksum-only transformations are explained too.
+
+An ordinary npm-installed tarball of that exact head passes the real local Community canary on
+API2 `b2264e1767`: store, default catalog, listing, receipt sync and byte-identical signed CDN
+delivery. Its original-storage watermark exemption is confirmed. Compatibility with older
+transformed results remains explicitly protocol-fake coverage, not live old-policy proof.
+The failed extra readback was a missing tmp-hostname mapping after restarting the owned devdock;
+only that container mapping was repaired. No API2 source/env, production policy or Console edits.
+Temporary canary keys/credential files were removed, own devdock/S3rver stopped, existing API2
+working changes preserved. The PR retains `Refs #270`, not `Fixes`, and its two open follow-ups.
+
+Next gates remain unchanged: Console signup/redirect ownership, coordinated deployment/release,
+ordinary registry-install and sustained Content dogfood, then production Bunny measurements.
+This addendum does not authorize merging or publishing the private image package.
+
+## Round 9 — stranger signup test 2
+
+Input: /tmp/img-task2-round9-brief.md and /tmp/img-stranger-signup-report-r2.md (PASS in 6m13,
+against earlier tarballs). “This fires on **every** load of the untouched `image init` page”
+makes F11 the first fix. “a blank page with nothing on it at all” makes scaffold failure UI next.
+
+- [x] F11: post-layout, decoded, non-tiny candidate measurements; native dev tests added.
+- [x] F9: generated example displays delivery failures using the existing fallback API.
+- [x] F10: remove unnecessary development-server restart advice without promising retries.
+- [x] F2: identify saved credentials safely and show the non-destructive separate-file path.
+- [x] F3: unique auth help from Clipanion definitions; all existing aliases retained.
+- [x] README: short npm/pnpm entry, signup timing, tarballs, delivery override and Credentials label.
+- [x] Red-first checks, council/Claude review and sequential package/packed checks.
+
+Same PR; no merge, publication, production, Console or API2 changes. Keep Refs #270 and the
+unchanged release gates above. Detailed quotes, decisions and evidence: /tmp/img-task2-round9-report.md.
+
+Initial yarn check passes: img 262, node 558 plus one existing skip. Red-first failures cover
+pre-layout diagnostics, missing scaffold fallback, restart advice, login context, duplicated
+help and README entry points. The native dev matrix adds untouched desktop/mobile scaffolds
+and a controlled 1px-to-960px real layout; final browser/review/CI receipts follow below.
+F3 was alias expansion in prefix help, not duplicate command registration. No dependencies,
+schema files, builtin pins or auth scopes changed.
+
+First council found two valid follow-ups: scope the fallback locator past Next's route
+announcer, and explain an approved-but-unsaved key after concurrent login rather than saying
+“Nothing was changed”. Both are fixed red-first. Interactive desktop-to-mobile browser reuse
+also exposed a density-correction false positive; compare CSS-pixel naturalWidth before blaming
+sizes. Native tests cover both unchanged pages and cached-candidate reuse. The final verification
+sequence and second, focused review are pending; initial CI only failed on the alert locator.
+
+### Round 9 verification and handoff
+
+The final local sequence passes: `yarn check`, then img check → verify → packed fixture.
+Counts: 263 img, 560 Node plus one preexisting skip, 59 utils, 22 relay and the root/schema/MCP
+checks. The downloaded/installable packages pass all 92 native Chromium/WebKit cases: 42 with
+Cache Components enabled, 42 omitted and eight on the actual Next development server. An
+independent audit confirms zero retries, skips, flakes and unexpected browser/network errors.
+
+Two-reviewer council closure reports no issues; independent Opus UX and defensive-security reviews
+both PASS. Manual npm-installed desktop/mobile evidence covers working images, cached native
+candidate reuse, visible accessible failure and Fast Refresh recovery without restarting Next.
+Real installed CLI help lists six unique auth commands and refuses to overwrite toy credentials
+while identifying the saved file/workspace/description/date without printing secrets. A losing
+device login explains its approved-but-unsaved key; a losing stdin login never suggests revoking
+the existing application key it merely verified. Both races have red-first no-overwrite coverage.
+
+The first closure CI run passed 91/92 cases: rewriting streamed dev HTML for the tiny-box test
+caused WebKit to reload and cancel a devtools font. The test now changes only CSS after normal
+hydration, verifies 1px → 960px and retained client state. Initial pre-layout timing remains
+unit-tested; untouched native desktop/mobile loads and resize observation remain browser-tested.
+No failed-request exemptions or retries were added. Its test-only Node handle typing error was
+also fixed before the final full local repeat. The report preserves those failed attempts.
+
+Exact-head green CI is the final handoff gate; its current receipt and audited downloaded artifact
+are recorded in the [PR body](https://github.com/transloadit/node-sdk/pull/500) and
+`/tmp/img-task2-round9-report.md`, so this source document does not require a self-referential SHA.
+Local evidence: `/tmp/img-r9-evidence-OWBFjJ/`; final sequential logs: `/tmp/img-r9-final2-*.log`.
+Own manual browsers and servers are stopped; the packed runner cleans its own servers.
+
+The incoming 6m13 stranger-signup duration is not relabeled as a new timing on this head. Round 9
+uses owned localhost contract fakes, not a new API2/Console or Bunny canary. No dependencies,
+schemas, Built-in pins, auth scopes, Content/API2 source, env files or production settings changed.
+The release gates remain: coordinated API2/Console rollout and package release, ordinary registry
+installation, sustained Content dogfood and production Bunny measurements. No merge or publication
+in this round; `@transloadit/img` remains private at 0.0.0 and #270's two follow-ups stay open.
+
+### Round 9 follow-up — response-read ownership
+
+Kevin reported `response.body: Test ended.` in run 34788426459 at cd9430a0eb. Its rerun passed,
+but the same listener lifecycle remained at 3b26679: Promise.all snapshots a growing reads array,
+leaving later response handlers unowned during teardown. No product behavior is implicated.
+
+- [x] Reproduce deterministically in both real browser engines by holding the audit's body read
+ while the image decodes normally; old cleanup incorrectly completes before that read is released.
+- [x] Return the response handler's async work to Playwright, then remove/drain those listeners
+ before page/probe closure. Remove the manual reads array; preserve native read failures.
+- [x] Focused council: no issues found. Packed browser matrix: 44 enabled + 44 omitted + 8
+ development cases pass on their first attempts, with all response-audit attachments checked.
+
+The final gate is the required local checks and exact-head CI/artifact verification; their
+commit-stamped receipts live in the report and PR body below, not in a second docs-only CI loop.
+
+Keep this correction test-only. Evidence and current-head CI receipts are appended to
+`/tmp/img-task2-round9-report.md` and the PR body; the red browser artifact is
+`/tmp/img-r9-response-race-red-artifact`. No ignoreErrors, extra retry, timeout increase,
+product change, merge, publication or new framework is part of this fix.
+
+## Round 10 — final reader residuals
+
+Input: `/tmp/img-task2-round10-brief.md` and `/tmp/img-stranger-signup-report-r3.md`.
+The independent reader passed in 4m58 on 3b26679 (API2 b2264e1767, Content 40210c67f1).
+That is the reader's existing timing, not a new measurement by this round.
+
+- [x] F9: print the actual development HEAD origin/path, stripping query credentials. Explain
+ HTTP failure versus an unreachable host and point non-default setups at baseUrl/urlParams.
+ Preserve the existing publication/auth/404 hints, background timing and production silence.
+- [x] Six README clarifications: workspace-derived delivery host, persisted login endpoint,
+ separate credentials file by the logout warning, Console Credentials path without a placeholder
+ link, optional src/ layout, and bring-your-own hero JPEG. No new onboarding feature.
+- [x] Red-first diagnostic tests: seven failures before the fix, then all 163 affected tests pass.
+ Focused council found only the shell-versus-project-env wording; fixed red-first in the README.
+
+Required local checks, packed browser proof, final exact-head CI/artifact receipts and PR-body verification go in
+`/tmp/img-task2-round10-report.md`. Stop after this round: no merge, publication, API2/Content
+changes, environment edits, new reader round or extra product work. img stays private at 0.0.0.
+
+## Round 11 — recovery and reader follow-ups
+
+Input: `/tmp/img-task2-round11-brief.md`, with both independent round-8 Rauch reports read.
+Work remains in #500 on `img-onboard`; no merge, publication or production changes.
+
+- [x] Recover server-declared public policy atomically with receipts; preserve the catalog on
+ policy failure. Never infer publication from a folder name.
+- [x] Document a separate application key for private deployment, not the revocable CLI key.
+- [x] S3 availability/403 handling; non-production init delivery override; dev HEAD fallback.
+- [x] preload vocabulary, typed scaffold and store snippets, concise output, publication dry run.
+- [x] Quickstart-first README and reference accuracy; browser device-denial contract.
+- [x] Triage both focused councils and the Opus UX/security review; add red-first regressions.
+- [x] Condense the PR body, preserving historical evidence in maintainer documentation.
+
+Pre-review full `yarn check` passes: 267 img and 571 Node tests (one existing skip).
+Native desktop/mobile proof and independent Opus UX/security reviews pass on the initial head.
+Council's shell-quoting and custom-catalog findings are fixed red-first. The Opus follow-up fixes
+scaffold formatting, stale alt docs and missing empty-policy guidance. The workspace env override
+remains the explicit round-8 contract, now documented. Final packed verification also replaces its
+stale empty-scaffold assertion; that initial local/CI failure is retained in the report.
+The closure council's multiline assertion and portable absolute-catalog follow-up are fixed;
+full `yarn check` now passes with 268 img and 572 Node tests (one existing skip).
+Cold-checkout CI then exposed the generator's unnecessary receipt-module import. The CLI now
+owns the default catalog argument, keeping source generation independent of built packages;
+a child-process cold-load regression fails first and passes after the correction.
+The final handoff gate is img check → verify → packed browser proof → exact-head green CI.
+Its current commit-stamped result is recorded in the report and PR body rather than a
+self-referential docs-only commit. No merge or publication is authorized by passing this gate.
+
+Progress and red/green evidence: `/tmp/img-task2-round11-report.md`. Immutable identity and
+image doctor remain out of scope. `@transloadit/img` stays private at 0.0.0.
+
+## Round 12 — package-first Next.js integration
+
+Kevin's decision: `/tmp/img-task2-round12-brief.md`. Keep #500, private 0.0.0, no
+merge, publication or production changes. Current main is already an ancestor; no open GitHub
+review threads were present at preflight.
+
+- [x] Store owns the catalog, generates committed types, and explicitly publishes with --public.
+- [x] A Next config plugin binds the conventional catalog and private authorizer; direct package
+ import reuses the existing renderer. Do not promise a runtime filesystem fallback without
+ proving tracing and host portability.
+- [x] Generated module augmentation narrows src; without it string sources still use catalog sizes.
+- [x] Private convention exports one authorize function and re-exports the package route handler.
+- [x] Keep explicit factories and opt-in example/private scaffolding working.
+- [x] Quickstart/reference/dogfood and packed fixture exercise both integration paths.
+- [x] Reconcile the full council and local Opus UX/security evidence; add red-first regressions
+ for every valid finding. Keep final check receipts and the concise PR body current.
+
+The implementation was recovered after an external cleanup selected the active checkout. Work
+continues in Kevin's reserved node-sdk checkout, with no new clone or worktree. Before review,
+img check, verify and all 102 packed Chromium/WebKit cases passed; the corresponding CI is green.
+The initial packed failure caught Turbopack excluding .next as an import source; generated nonsecret
+options now live under node_modules/.cache, while the catalog stays the single source of truth.
+The generated wrapper README is synchronized. A red-first follow-up aligns CLI catalog transport
+validation with the renderer's parameter-only and repeated-query options.
+
+Council's four valid findings are fixed red-first: credential-free scaffolding preserves catalog
+delivery and validates an explicit workspace, generated types survive CRLF conversion, and the
+phase-aware Next plugin never regenerates build options during production startup. The packed
+fixture now prunes those options before next start. Opus UX and security both pass; its alt-text
+nudge is added to the CLI output. The corrected full yarn check passes (278 img, 587 Node plus
+one existing skip). Final img check → verify → packed fixture, closure review and exact-head CI
+remain the handoff gate; their receipts live in the report and PR body, without a docs-only SHA loop.
+No merge, publication, API2 restart or new blind-reader round is part of this handoff.
+
+The closure council exposed a credentialed variant of the endpoint mismatch: optional init could
+publish on development while preserving a production catalog, or save the wrong environment's
+private key. Four red-first cases cover both origin directions and both side effects; mismatches
+now fail under the catalog lock before publication or credential writes. Explicit endpoint changes
+move publication and delivery together; matching origins still work. The scaffold generator's
+unused private/endpoint branches were removed after confirming it has one public-only caller.
+Generated types intentionally stay canonical LF; Git handles checkout conversion. Final receipts
+are refreshed after this bounded correction; earlier green CI is not relabeled as its proof.
+
+Report and commit-stamped evidence: `/tmp/img-task2-round12-report.md`.
+
+## Round 13 — truthful setup and actionable diagnostics
+
+Input: /tmp/img-task2-round13-brief.md and both independent Rauch round-9 reports. Their no-init
+public setup passed in 4m14 and 3m03 including signup; these are reader timings, not this round's.
+Stay in Kevin's reserved node-sdk checkout and #500, private 0.0.0. No merge or publication.
+
+- [x] Specify Smart CDN plus assemblies:write for private keys and use the origin's error header.
+- [x] Name unknown paths, nearby catalog matches and fixes; explain unpublish and dev policy changes.
+- [x] Make optional examples use the package import, not a second factory scaffold shape.
+- [x] Show sha256 for combined keys in leading SDK examples; keep the existing default unchanged.
+- [x] Qualify cache/recovery/host claims and list known limits in the PR body.
+
+Validation gates: red-first regressions, local checks, packed fixture, independent reviews and
+exact-head CI. The PR's Verification section records the current head and receipts after these
+gates; /tmp/img-task2-round13-report.md records the full local evidence.
+
+Report: /tmp/img-task2-round13-report.md. Broader API2/Console changes and deferred product ideas
+remain outside this round; no additional clone or worktree is needed.
+
+Red-first contract evidence: scope/header + docs/CLI regressions, safe unknown-path and unpublish
+advice, HMR policy snapshots, package-only scaffold (including custom catalog/owned-code preservation),
+and SHA-256/cache/recovery examples. Focused suites are green; check, img check and verify pass
+(288 img, 594 Node with one pre-existing skip). Owned Next dev/browser desktop/mobile and real
+catalog hot reload pass; /tmp/img-r13-evidence-eGTRmZ contains screenshots and redacted evidence.
+Initial head 14a50e9 passed all 102 packed cases first attempt and CI run 34870869766. Opus UX and
+security passed. Council's five findings were then reproduced red-first and corrected: reject
+outside-app scaffold catalogs before publication, validate paths before shell advice, name the
+custom --receipts option and factory updates, give unsigned failures the right private remedy,
+and distinguish factory policy from catalog policy. The review also prompted copy-safe commands,
+an alt reminder, overwrite recovery advice and phrasing-safe development fallback annotations.
+Application authorization exceptions deliberately propagate; callbacks deny by returning false.
+The follow-up council's last P3 was fixed red-first: leading-slash/space variants of a known key
+retain a safely escaped exact-key suggestion, but are still rejected and never yield upload advice.
+
+### Round 13 addendum — scope, blur and compact URLs
+
+Kevin's ordered items 6–9 in /tmp/img-task2-round13-addendum.md, starting at 2779025. GitHub has no
+open review comments and origin/main is already an ancestor. Same checkout and PR; no API2 edits,
+new worktrees, merge or publication. Append evidence to /tmp/img-task2-round13-report.md.
+
+- [x] Prefer smart_cdn:sign in private setup/diagnostics; assemblies:write is also accepted.
+- [x] Generate bounded ThumbHash receipt metadata on store and server-decoded blur placeholders.
+- [x] Omit verified Built-in defaults, preserve custom-template behavior and explicit delivery.
+- [x] Describe v as an unverified cache-busting tag and recommend immutable filenames.
+- [x] Add red-first coverage and repeatable packed checks; reconcile council and Opus findings.
+
+API2 source confirms storage-preview@0.0.2 defaults f=jpg, q=75, r=pad and bg=#ffffff;
+public-preview@0.0.1 derives from it. Candidate dimensions remain explicit. Cache keys deliberately
+change before public release; transparent formats still need their nondefault background.
+
+ThumbHash encoding/catalog/declarations/server decoding are implemented and unit-tested. Kevin
+decided against any client load handler. Store records hasAlpha: true only for original alpha
+channels. Transparent images omit the blur background and explain why in development; opaque
+images keep it hidden beneath the loaded pixels. The generated types and matching-MD5-only sync
+preserve this metadata. Public browser cases explicitly disable JavaScript for both kinds of image.
+Private redirect markup deliberately never embeds blurred pixels before image authorization.
+Encoding is best-effort and bounded (32 MiB encoded, 40 MP decoded, 100×100 hash input, 2s decoder).
+Checksum changes drop the local hash; sync only preserves one with matching MD5.
+
+Local checkpoint: check + img check + verify pass (306 img, 602 Node + one existing skip). Packed
+Chromium/WebKit passes 50 cacheComponents-enabled + 50 omitted + 10 development cases, first
+attempt in the final run. The capture's pending-load screenshot was changed to computed-style
+evidence: Playwright's font-ready screenshot wait deadlocked while the test held image delivery.
+The loaded screenshot remains. All native AVIF/WebP/JPEG, alpha and private-authorization checks
+pass. The follow-up alpha regressions failed first (6 failures) and pass after the server-only
+decision. The report and PR Verification section record each reviewed head and its final packed/CI
+receipt; do not reuse the earlier checkpoint's counts as current evidence. Do not merge or publish;
+stop after the verification gates and documentation.
+
+Review follow-up on 0d8aa74: the strict browser audit exposed Chromium's disabled-script preload
+as a CSP failure, independently reproduced with JavaScript off and an enabled control. Record
+only that same-origin Next script cancellation as expected; image/network errors still fail.
+Council/Opus also exposed quantized ThumbHash ratios leaving permanent bands beside letterboxed
+images. Blur now requires opaque box-filling content and uses 100% 100% sizing; other fits opt out.
+Hash-header alpha provides defense in depth for older/edited receipts. PNG data input is bounded,
+Sharp is optional, and docs explain CSP/data URLs and up to about 6 KB extra inline markup.
+Public Built-in boundaries are enforced at model generation (4096 px in both dimensions, quality
+<=85), including fallbacks and art-direction crops; private/customer templates keep their limits.
+Eleven new regressions failed first, then passed. Repeated checks/review/packed/CI receipts are
+tracked in /tmp/img-task2-round13-report.md and the PR's exact-head Verification section.
+
+### Round 14 — final private-path message polish
+
+Kevin's six items in /tmp/img-task2-round14-brief.md, based on both round-10 reader reports.
+Start at e054145 in /Users/kvz/code/node-sdk, img-onboard, PR #500. Main is already an ancestor;
+GitHub has no open review comments. No new product slice, worktree, API2/Console edit or release.
+
+- [x] Missing credentials name the documented Smart CDN pair and Console preset.
+- [x] A development authorizer added after bundling explains the required restart.
+- [x] Store advice follows known publication/metadata, with private files/key/restart and no blur.
+- [x] README includes the small private recipe and create-next-app's typed config shape.
+- [x] Store reads Next config to show an absent wrapper, without executing or editing it.
+- [x] S3 403 advice names its endpoint and both disabled/denied possibilities.
+- Validation gates: red-first tests, img check, verify, packed fixture, review and exact-head green
+ CI. Per-run receipts and the final result live in the report and PR body, not a stale badge here.
+
+Evidence and final handoff: /tmp/img-task2-round14-report.md. Keep production S3-read, hosted
+Vercel and real Bunny verification as release gates; do not claim this local polish closes them.
+
+Red-first: 19 intended failures, then a separate HEAD-403 failure; all pass after the fixes.
+Check, img check and verify pass (319 img, 615 Node plus one pre-existing skip). The cold-source
+script guard caught a transitive workspace import during implementation; snippet helpers remain
+usable without built packages. README grows to 91 lines to include the requested private recipe.
+No dependency or lockfile changes. Packed browser, independent review and exact-head CI follow.
+
+The 98bcee8 live Next probes confirmed late-authorizer restart and missing-key messages; owned
+desktop/mobile public/private browser and exploratory checks passed. Opus UX passed. The initial
+packed/CI attempt exposed a stale seed assertion demanding blur for an intentionally transformed
+upload whose receipt has no hash; its JSX assertion now matches the metadata, with an explicit
+absent-hash assertion. Council/Opus's three in-scope refinements were reproduced red-first and fixed:
+explain CommonJS-to-mjs migration before ESM config advice, do not infer destination privacy from a
+foreign catalog left unchanged, and print shared setup once per upload batch. Check, img check and
+verify pass again (319 img, 618 Node plus one pre-existing skip). Full packed and exact-head CI
+results are recorded in /tmp/img-task2-round14-report.md and the PR Verification section.
+
+Deferred outside Kevin's private-message-only scope: council found an existing public-utility bug
+in packages/utils/src/node.ts getSmartCdnImageCandidates (last touched at 3e473f4, not round 14).
+With builtin/public-preview@0.0.1, widths [400] yields h=8000 without dimensions, or h=6000 for a
+400x6000 source, above that Built-in's 4096px limit. Read-only reproduction is in
+/tmp/img-r14-deferred-utility.log. No packages/img or packages/node caller uses this helper;
+StorageImage's separate model already applies public limits. This is real, not fixed or claimed
+safe. Correct the generic helper's template-specific bounds separately before advertising it for
+the public Built-in; preserve custom-template behavior and truthful width descriptors. No extra
+issue or public-delivery implementation was started in this polish round. Also keep the established
+hosted Vercel, registry install and actual Bunny/S3 rollout gates. Stop; do not merge or publish.
+
+### Round 15 — content-addressed upload names and focused recovery docs
+
+Kevin's two items in /tmp/img-task2-round15-brief.md. Starting at c56ec58 in the reserved
+/Users/kvz/code/node-sdk checkout, img-onboard, #500. Latest main is already an ancestor; no GitHub
+review threads. No other repo changes, new worktrees, merge, release or follow-on round.
+
+- [x] Add storage store --hashed: eight MD5 hex digits before the extension; catalog, generated
+ path types and JSX agree, with the original local filename retained as source.
+- [x] Prove same-byte replays skip uploads using a same-workspace catalog and complete checksum/
+ size match; changed bytes get another name and short-hash conflicts never overwrite.
+- [x] Move S3 recovery commands out of the README to the explicitly unavailable reference section.
+- Verification gates: red-first tests, a packed hashed upload rendered in browsers, required
+ checks, focused review and exact-head green CI. Final receipts/status live in the report and
+ PR body; do not use an earlier head's CI badge. Stop after these gates, without merging/releasing.
+
+Evidence and final state: /tmp/img-task2-round15-report.md. Existing hosted/release gates and the
+deferred generic public-utility finding above remain unchanged.
+
+Red-first: 16 failures / 92 passes, then 107 / 108 passed and the remaining replay test exposed
+catalog key reordering. Replays now preserve the original record/object rather than writing the
+schema's reordered projection. CLI hashing uses a bounded preflight read so a repeat can skip the
+SDK/Assembly entirely; fresh uploads retain the SDK's independent checksum/receipt verification.
+No new public SDK options or dependencies. Matching-checksum sync preserves source; the default
+unhashed path and overwrite behavior stay unchanged. No production S3 availability is assumed.
+
+Review follow-up on 222aff3: council identified same-slug cross-API replay, repeated uploads under
+an explicit foreign-workspace override, transformed-receipt retry advice and misleading S3 examples
+in both SDK READMEs. Twelve assertions failed first. Hashed receipts now record apiOrigin and only
+skip in that API environment; a per-invocation verified-receipt map also deduplicates override
+batches without changing foreign catalogs. Transformed/colliding bytes fail without writing and
+explain why restoring that same receipt cannot help. SDK README examples now link to gated recovery;
+the legacy wrapper is regenerated, not hand-edited. Opus's valid wording fix distinguishes Git
+recovery from the unavailable S3 commands; hash advice precedes the first upload.
+
+The packed seed's initial CI failure was test isolation: a new credentials-file path in the same
+CLI cwd was correctly treated as an untrusted override. Its independent CLI cwd preserves the
+ownership check and copies verified metadata back into the browser app. All 11 seed tests pass in
+the isolated reproduction. Packed builds/browsers and exact-head CI must still pass after these
+fixes; final per-run receipts live in the report and PR body.
+
+Opus post-fix review confirms all four council fixes and the README wording. Its last adjacent
+finding was valid too: the post-write CLI recovery error still printed unavailable S3 commands
+without the prerequisite. A focused regression failed first; the error now names the availability
+limit and points to Console/verified catalog recovery. No new product slice or broad refactor.
+
+### Independent DX follow-up — 2026-09-15
+
+Kevin approved the three bounded documentation improvements from the fresh Astra review, followed
+by an explicit Uppy replacement-policy check. Preserve the product API; do not restart the reader
+loop or infer merge, publication or production-rollout authority.
+
+- [x] Make CLI login the first action even without an account; signup continues in its browser flow.
+- [x] Add a discoverable application-server `storeImage()` recipe: Assembly-enabled credentials,
+ SHA-256 for new combined keys, server-chosen unique paths and persistence with the verified owner.
+- [x] Explain that fill `style`/`className` target the image, and constrain the parent in the
+ responsive example. Native browser geometry was checked during the independent review.
+- [x] Strictly typecheck the new upload snippet against the packed candidate. The first check caught
+ optional environment values; the explicit missing-credentials guard makes it pass without casts.
+- [x] Verify Uppy store parameters against the local API and read back the resulting bytes. Omitting
+ `overwrite` replaces the original; `error` rejects with `TRANSLOADIT_STORE_CONFLICT` and preserves
+ it; `rename` stores a second file and preserves both. Existing helper tests also pass (5/5).
+
+Application-image recipes should use server-owned unique paths and refuse collisions. The existing
+Uppy user-upload recipe already specifies `conflict_strategy: 'error'`. No manager-mode default was
+changed: choosing replacement as a file-management feature is a separate product decision. This
+was a real Uppy parameter-builder → Assembly → stored-bytes check, not a browser/Companion proof.
+Only disposable local-test assets in the dedicated dev bucket were touched.
+
+The SDK's required check passes (319 img, 636 Node plus one existing skip); README contract tests
+were updated and the legacy wrapper README is regenerated from the canonical source. Final verify
+and exact-head CI receipts belong in the PR Verification section and
+`/tmp/dam-dx-followup.VZ1bC7/report.md`. The independent review remains at
+`/tmp/dam-dx-astra-mZadsP/report.md`. Hosted Content/native-delivery, registry-install and API2
+deployment/S3 rollout gates above remain open; this docs follow-up does not close them.
diff --git a/knip.ts b/knip.ts
index c398c1c9..f21a6d5d 100644
--- a/knip.ts
+++ b/knip.ts
@@ -74,12 +74,14 @@ const config: KnipConfig = {
ignore: [...sharedIgnore, alphalibIgnore],
ignoreDependencies: [
// Used by generated compatibility package sources that are absent in fresh CI checkouts.
+ '@aws-sdk/client-s3',
'@transloadit/sev-logger',
'@transloadit/utils',
'cacheable-lookup',
'clipanion',
'debug',
'dotenv',
+ 'execa',
'form-data',
'got',
'into-stream',
@@ -90,6 +92,8 @@ const config: KnipConfig = {
'p-map',
'p-queue',
'recursive-readdir',
+ 'sharp',
+ 'thumbhash',
'tus-js-client',
'typanion',
'type-fest',
diff --git a/package.json b/package.json
index 5e331a85..1be6590c 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"pack": "node scripts/pack-transloadit.ts",
"parity:transloadit": "node scripts/prepare-transloadit.ts && node scripts/fingerprint-pack.ts packages/transloadit --ignore-scripts --quiet --out /tmp/transloadit-after.json && node scripts/verify-fingerprint.ts --current /tmp/transloadit-after.json --diff",
"test:img:fixture": "node scripts/test-img-next-fixture.ts",
- "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/img test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit",
+ "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts ./scripts/knip.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/img test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit",
"test:types": "yarn workspace @transloadit/zod test:types",
"test:e2e": "yarn workspace @transloadit/node test:e2e",
"test": "yarn workspace @transloadit/node test",
diff --git a/packages/img/README.md b/packages/img/README.md
index a01ad971..a5c006b7 100644
--- a/packages/img/README.md
+++ b/packages/img/README.md
@@ -1,207 +1,93 @@
# `@transloadit/img`
-Responsive previews of Transloadit Storage objects, delivered through Smart CDN.
+Responsive Storage images for Next.js. Native `picture/srcset`; bytes go straight from
+Smart CDN (`.tlcdn.com`) to the browser, never through Next's image optimizer.
-The package renders native ``, `srcset`, and `` elements. Image bytes travel directly
-from Smart CDN to the browser; they are never optimized or proxied by the Next.js application.
-Remote HTTP URLs are deliberately outside this package's source contract: an image must already
-belong to the configured Transloadit Storage workspace.
+## Quickstart
-This workspace remains private at version `0.0.0` while the API and production dogfood soak. Do not
-depend on it from npm yet.
+Next.js 16.3.3+ App Router, React 19, Node.js runtime.
+**Unpublished dogfood:** ask a maintainer for matching img/utils/node/types tarballs.
+After release: `npm install @transloadit/img && npm install --save-dev @transloadit/node`.
+pnpm: `pnpm add @transloadit/img && pnpm add -D @transloadit/node`;
+Yarn: `yarn add @transloadit/img && yarn add -D @transloadit/node`.
-## Next.js
+Run beside `package.json`. Start with `auth login` even without an account: choose Sign up in the
+browser it opens, create a free workspace, and approve the CLI. For `./hero.jpg`, use any JPEG you have.
-The server entry point targets the Next.js 16 App Router with `cacheComponents: true` in
-`next.config.ts`.
+Prefer `--hashed` for images you will replace: it generates an immutable filename such as
+`website/hero.fce9d56a.jpg`. The command below includes it; use the printed JSX path, with no `--overwrite`.
-Create one server-only application module. The factory does not read environment variables:
-
-```tsx
-import { createTransloaditImage } from '@transloadit/img/next/server'
-
-const authKey = process.env.TRANSLOADIT_KEY
-const authSecret = process.env.TRANSLOADIT_SECRET
-const workspace = process.env.TRANSLOADIT_WORKSPACE
+```bash
+npx transloadit auth login
+npx transloadit storage store ./hero.jpg website/hero.jpg --public --hashed
+```
-if (!authKey || !authSecret || !workspace) {
- throw new Error('Transloadit image credentials are required')
-}
+`--public` publishes the directory recursively, including future uploads. In `next.config.ts`:
-export const { Image } = createTransloaditImage({
- authKey,
- authSecret,
- storage: { allowedPathPrefixes: ['website/'] },
- workspace,
-})
+```ts
+import type { NextConfig } from 'next'
+import { withTransloaditImages } from '@transloadit/img/next/config'
+const nextConfig: NextConfig = { /* your existing Next config */ }
+export default withTransloaditImages(nextConfig)
```
-The Auth Secret stays in the server module and never enters rendered markup or a client bundle.
-Signed browser URLs contain the public Auth Key identifier, as required by Smart CDN verification.
-
-Use a relative Storage object path as `src` and provide the source's intrinsic dimensions:
+Render in `app/page.tsx` or any Server Component. If your app has `src/`, prefix the source paths:
+Use the path printed by your upload as `src`; the hash below is only an example.
```tsx
-import { Image } from '../lib/transloaditImage.tsx'
-
+import { StorageImage } from '@transloadit/img/next'
export default function Page() {
- return (
-
- )
+ return
}
```
-`storage.allowedPathPrefixes` is a hard workspace boundary, not object authorization. Prefixes must
-be relative directories ending in `/`. The default is deny-all; `['']` deliberately allows the
-workspace root. Paths with dot segments, backslashes, empty segments, control characters,
-non-normalized Unicode, or more than 1024 UTF-8 bytes are rejected before signing.
+Run `npm run dev` and open `/`. Commit `transloadit.images.json` and `transloadit-images.d.ts`, then deploy.
+Public images need no app secrets. The required plugin bundles the catalog, with no runtime lookup.
-### Direct delivery
+## Responsive
-Direct delivery is the default and fits image-heavy views that already authorize their data while
-rendering. The component calls Next.js `connection()` before creating short-lived signed URLs. A
-built-in Suspense boundary lets a Cache Components page prerender a shell, but the signed image
-itself is request-rendered and must not be stored in a shared full-page cache.
-`suspenseFallback` customizes that shell.
+Catalog paths autocomplete. `width` sets a responsive maximum; `preload` makes a hero eager,
+preloads its responsive source and sets high fetch priority. Other images load lazily.
+Add `placeholder="blur"` for an inline preview from the receipt's optional `thumbhash`.
+[Layouts, art direction and the temporary priority alias](./docs/reference.md#responsive).
-The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the
-platform default. A candidate first requested after its signature expires can fail on an unusually
-long-lived page; choose an appropriate bounded `expiresInMs`, eagerly load a measured critical
-image, or use authorized redirect delivery.
+## Private
-### Authorized redirects
-
-Redirect delivery keeps markup stable and rechecks application access when the browser loads an
-image:
-
-```tsx
-import { createTransloaditImage } from '@transloadit/img/next/server'
-
-export const { Image, storageRoute } = createTransloaditImage({
- authKey,
- authSecret,
- storage: {
- allowedPathPrefixes: ['documents/'],
- delivery: {
- authorize: async ({ path, request }) => {
- const user = await authenticate(request)
- return user !== null && (await canReadStorageObject(user, path))
- },
- // Match next.config.ts when the application uses basePath.
- basePath: '/app',
- route: '/api/private-images',
- },
- },
- workspace,
-})
-```
-
-Export the handler from that exact App Router path:
+Wire `getSession` to your application's session and per-object permissions; it is not an SDK helper:
```ts
-export { storageRoute as GET } from '../../../lib/transloaditImage.tsx'
-```
-
-The component emits same-origin URLs containing an authenticated-encrypted capability for one
-exact Storage path and transformation. Filenames and credentials stay out of prerendered HTML.
-The handler rejects changed, duplicate, unknown, oversized, or malformed capabilities before
-calling application authorization. `authorize` must return the boolean `true` for the current
-request.
-
-After authorization, the handler returns a private, non-cacheable `307` to a fresh signed Smart CDN
-URL. Image bytes still bypass Next.js. Rotating the Transloadit secret invalidates existing
-capabilities, so redeploy cached static markup at the same time.
-
-| Property | Direct, the default | Authorized redirect |
-| --- | --- | --- |
-| Next.js work per loaded image | None | One authorization + redirect |
-| Image bytes through Next.js | Never | Never |
-| Shared/static image markup | No | Yes |
-| Request-time revocation | No | Yes |
-| Long-lived lazy pages | Signature can expire | Fresh CDN signature per load |
-| Typical fit | Large authorized galleries | Strict ACLs and revocation |
-
-## Responsive policy
-
-Storage previews use signed-only `builtin/storage-preview@0.0.1`. AVIF quality 45 and WebP quality
-75 are emitted in browser preference order, with a JPEG quality 75 fallback. Explicit formats keep
-CDN objects independent from an unkeyed `Accept` header.
-
-The default candidate ladder is 320, 640, 960, 1280, 1920, 2560, and 3840 pixels, capped at the
-declared intrinsic width and backend-safe height. The exact intrinsic width is included between
-steps. `widths` is an advanced per-image override. `sizes` is optional because that is valid HTML,
-but strongly recommended whenever an image is not effectively `100vw`.
-
-```tsx
-
+// transloadit.authorize.ts, beside next.config.ts
+import type { AuthorizeTransloaditStorageImage } from '@transloadit/img/next/server'
+import { getSession } from './lib/authorization'
+export const authorize: AuthorizeTransloaditStorageImage = async ({ path, request }) =>
+ (await getSession(request))?.canRead(path) === true
```
-- Images are lazy and asynchronously decoded by default.
-- `preload` implies eager loading. Combine it with `fetchPriority="high"` only for a measured LCP
- image. Explicitly lazy preloads are rejected.
-- `objectFit` is forwarded for deliberate crop or containment behavior.
-- `deferUntilHydrated` avoids WebKit parser-to-hydration replay for non-critical images. It cannot be
- eager or preloaded and is not a secrecy mechanism.
-- `fallbackQuality` changes the signed JPEG fallback quality.
-
-Private signature lifetimes default to at least one hour in stable five-minute rotation windows.
-Their sum cannot exceed 48 hours:
-
-```tsx
-storage: {
- allowedPathPrefixes: ['documents/'],
- expiresInMs: 2 * 60 * 60 * 1000,
- rotationIntervalMs: 5 * 60 * 1000,
-}
+```ts
+// app/api/storage-images/route.ts (prefix with src/ if needed)
+export { GET, HEAD } from '@transloadit/img/next/route'
```
-## Template override
+Console → Credentials → New Auth Key → “Private image delivery”: Smart CDN on, `smart_cdn:sign`
+(`assemblies:write` is also accepted, but grants broader Assembly access). Use this application key,
+not the disposable login key that `auth logout` revokes, in `.env.local` and your host's server-only build/runtime env:
-A compatible workspace Template can replace the Built-in in trusted factory configuration:
-
-```tsx
-export const { Image } = createTransloaditImage({
- authKey,
- authSecret,
- storage: { allowedPathPrefixes: ['website/'] },
- template: 'my-storage-preview',
- workspace,
-})
+```dotenv
+TRANSLOADIT_SMART_CDN_KEY=…
+TRANSLOADIT_SMART_CDN_SECRET=…
```
-Template selection is unavailable on individual images because the factory owns the signing
-boundary. A replacement must accept the same trusted fields as the Storage preview Built-in.
+Restart `next dev` after adding the authorizer. [Private setup and authorization contract](./docs/reference.md#private).
-## Framework-neutral API
+## When it breaks
-`@transloadit/img` exports `createTransloaditImageModel` and serializable model types.
-`@transloadit/img/next` renders an already-resolved model. These lower-level entry points let other
-framework adapters inject a server-side URL resolver while credential and authorization policy stay
-outside the renderer.
+Opt into `errorFallback` to show a delivery failure instead of a broken image. Development adds
+the HEAD result; see the terminal. Non-production login endpoints carry into the catalog.
+[`baseUrl` and `urlParams`, diagnostics and recovery](./docs/reference.md#when-it-breaks).
+Lost metadata? First restore the committed catalog; see [recovery options and API availability](./docs/reference.md#recovery-requires-the-storage-read-api-not-yet-enabled-in-production).
-## Verification
+## Reference
-```console
-corepack yarn workspace @transloadit/img check
-corepack yarn test:img:fixture
-```
+[Store an image from your application server](https://github.com/transloadit/node-sdk/blob/main/packages/node/README.md#store-an-image).
-The fixture packs the published artifacts, installs them into a clean Next.js 16 App Router app,
-builds partially prerendered and dynamic routes, starts the production server, probes route
-authorization and capability tampering, checks for secret leakage, and reports direct-versus-
-redirect HTML size and route work for 1, 20, and 100 images. Size measurements are deterministic;
-wall-clock measurements are diagnostic and do not create flaky CI thresholds.
+[Full reference](./docs/reference.md) · [Cache tags do not pin old bytes](./docs/reference.md#cache-and-markup-cost) · [Maintainer dogfood setup](https://github.com/transloadit/node-sdk/blob/img-onboard/docs/img-dogfood.md).
diff --git a/packages/img/docs/reference.md b/packages/img/docs/reference.md
new file mode 100644
index 00000000..7389ef16
--- /dev/null
+++ b/packages/img/docs/reference.md
@@ -0,0 +1,807 @@
+# Storage images reference
+
+Start with the [Quickstart](../README.md). This reference covers policy, advanced layouts and operations.
+
+## Next.js plugin and catalog convention
+
+`StorageImage` from `@transloadit/img/next` is an App Router Server Component. Wrap the existing
+Next config with `withTransloaditImages(nextConfig)` from `@transloadit/img/next/config`.
+The plugin binds `transloadit.images.json` and optional `transloadit.authorize.ts` from the app
+root using build-time aliases for Turbopack and webpack, and adds narrow output tracing includes.
+It preserves existing aliases, tracing rules and webpack hooks. Restart dev after first adding
+the authorizer; ordinary catalog edits participate in the bundler's dependency graph.
+The wrapper returns Next's phase-aware config function; make it the outer wrapper when composing
+with plugins that accept only config objects. Generation runs in dev/build, never at `next start`:
+the compiled app does not need the source catalog or generation cache to remain on disk.
+
+This first cut requires the plugin. There is no cwd-based runtime fallback: bundlers and deployment
+hosts differ in which files they trace and where they start a process. The packed fixture verifies
+local `next build` + `next start`, with Cache Components enabled and omitted. Hosted Vercel,
+other serverless adapters and Edge are not claimed as verified; Edge is unsupported.
+
+```ts
+import { withTransloaditImages } from '@transloadit/img/next/config'
+
+export default withTransloaditImages({}, {
+ catalog: 'assets/transloadit.images.json',
+ // Set root: import.meta.dirname when starting Next from a monorepo parent directory.
+ delivery: {
+ baseUrl: 'https://my-storage-api.example/file/{workspace}',
+ urlParams: { cdn: 'required' },
+ },
+})
+```
+
+Catalog overrides must remain inside the app root. The plugin's generated options under
+`node_modules/.cache/transloadit-images/` contain only nonsecret transport/basePath settings, never a
+second catalog or an application key. Its build-time delivery override wins over catalog delivery.
+Rebuild after changing transport, private authorization or deployment keys for prerendered pages.
+
+`storage store` creates the catalog on first use and updates it after each successful upload.
+Run beside `package.json`, or select a catalog explicitly with `--receipts`. The catalog contains
+workspace, server-declared public prefixes and image receipts. A non-production login also writes:
+
+```json
+{
+ "delivery": {
+ "baseUrl": "http://127.0.0.1:3020/file/{workspace}",
+ "urlParams": { "cdn": "required" }
+ }
+}
+```
+
+This is a field within the catalog, not a standalone catalog. Production logins omit it.
+Later writes preserve an existing delivery choice; remove that block deliberately to return to
+production Smart CDN. Neither the catalog nor declarations contain login credentials.
+
+### Generated types and optional scaffolding
+
+CLI catalog writes also derive `transloadit-images.d.ts` beside the catalog. Commit both files.
+Its augmentation of `RegisteredStorageImages` in `@transloadit/img/next` gives `src` exact path
+completion and retains each source's width/height. Next's stock TypeScript include discovers it;
+include the declaration explicitly if your app uses restrictive includes. Without it, `src` is
+`string` and runtime geometry still comes from the JSON. The declaration is metadata, never a
+second runtime source. Recovery regenerates it without another upload.
+
+Use one conventional catalog per app. For several catalogs, keep their explicit
+`createStorageImages(catalog)` factories and inferred JSON keys instead of combining generated
+global declarations. Factories do not require the plugin. They also understand catalog delivery;
+explicit top-level `baseUrl`/`urlParams` override that block.
+
+`image init website/ --example` remains an optional page generator using the same package import
+as the README, without `lib/storageImage.ts` or a second factory. It uses an existing
+catalog without login or publication, preserving its workspace and delivery. A saved development
+login cannot redirect that existing catalog; only an explicit `--endpoint` changes its transport.
+Credentialed init (publication or `--write-env`) requires matching login/delivery origins; otherwise
+it refuses before any write. Select matching credentials or a separate catalog, or deliberately
+switch delivery with `--endpoint`. Custom CDN overrides can stay in the Next config plugin instead.
+`image init uploads/ --private` creates only the conventional
+authorizer and route below; add `--example` for a page too. The older `image init --public` is an
+explicit publication plus example shortcut, not a prerequisite. No existing source file is overwritten.
+Package-import scaffolding requires a catalog inside the Next.js app; external catalogs are refused
+before writing files or publishing. Use an explicit `createStorageImages` factory for shared catalogs
+outside the app instead.
+
+## Responsive
+
+`preload` is this component's hero macro: eager loading, a responsive preload and high fetch priority.
+Unlike Next.js 16's preload flag (which adds a preload link), it intentionally sets all three.
+Next deprecates priority in favor of preload; our `priority` alias remains for one release and
+warns only in development. Do not combine either with lazy loading. Explicit eager `sizes="auto, …"`
+uses the fallback lengths without `auto`, with a development warning; bare `auto` falls back to 100vw.
+Other images default to native lazy loading. Props are serializable native attributes, not callbacks or refs.
+
+`placeholder="blur"` uses the receipt's optional base64 `thumbhash`. `storage store` and
+`client.storeImage()` generate it from the checksum read using pinned [ThumbHash](https://github.com/evanw/thumbhash)
+and Sharp, EXIF-oriented and at most 100×100 pixels. Encoding is best-effort: originals over
+32 MiB, over 40 million pixels, unsupported formats or a two-second decoder timeout omit it.
+Origin-side byte changes also omit the hash, since the local preview would no longer match.
+The Server Component decodes the hash; the ThumbHash decoder never enters the client bundle.
+Sharp is an optional SDK dependency; an unavailable local decoder omits this metadata without
+blocking the Storage write. Storage writes also record `hasAlpha: true` only when the original has an alpha channel, even if
+all its pixels happen to be opaque. For those images blur is a no-op with the development-only
+note "transparent image: no blur placeholder". An alpha-encoded hash also suppresses blur when
+the receipt flag is missing. For images without alpha, the background remains in place, hidden
+under the loaded opaque image: no client-side load handler is needed or shipped.
+Blur requires a box-filling image: the default constrained layout, or `object-fit: fill` / `cover`.
+Letterboxed `contain`, `none` and `scale-down` images omit it with a development note, since the
+approximate ThumbHash ratio could otherwise leave a permanent blurred band beside the image.
+Each blur adds up to about 6 KB of inline PNG/base64 markup before HTML compression; opt in only
+where the loading preview is worth that extra HTML. Your CSP must allow `img-src data:` (alongside
+your normal image sources) for the placeholder to display.
+Without a usable hash, the prop is a no-op with a development-only note. Request-authorized
+private redirects also omit it: embedding blurred private pixels would expose them before the
+image request's authorization check. Direct delivery is only for already-authorized page data.
+Receipts sync performs no original download and cannot create a missing ThumbHash.
+ThumbHashes contain a recognizable preview, not just a checksum. Keep catalogs for private images
+in private source control, or remove their `thumbhash` fields before sharing the catalog publicly.
+
+The `constrained` and `fixed` layout names follow Astro; `fill` follows Next.js.
+
+The pinned public Built-in caps both output dimensions at 4096 pixels, including crops and JPEG
+fallbacks; the SDK scales its candidate ladder accordingly. Explicit public quality above 85 is
+rejected before rendering. Private previews retain their 8000-pixel / quality-100 limits.
+
+`width={960}` on a catalog path or receipt derives proportional responsive CSS, the
+`auto, (min-width: 960px) 960px, 100vw` sizes expression for lazy images and a ladder capped at
+1920px and the source. Eager/preloaded images omit `auto`. Explicit `sizes` remains your override.
+
+```tsx
+
+```
+
+Fixed layout keeps intrinsic dimensions in `src`; `width` and `height` describe the display box.
+It derives `sizes="48px"`, 48/96px candidates and a 48px JPEG fallback. `fit="cover"` requests a
+`fillcrop` at the box ratio, so a square avatar does not download an uncropped original.
+The default `fit="contain"` keeps the source proportions with CSS letterboxing.
+
+```tsx
+
+```
+
+Fill with `aspectRatio` emits a positioned container with that ratio and crops to match it.
+Native `style` and `className` apply to the image, not that container. Constrain the parent to set
+the frame's maximum width; setting `maxWidth` only on the image leaves the frame full-width.
+Use `frame={false}` when your application already owns the box, matching its CSS to the crop.
+Fill without a ratio requires an already-sized, positioned parent; cover always needs the ratio.
+All layout modes preserve explicit `sizes`, `widths`, `style` and `objectFit` overrides. Source
+and backend limits still apply. `widths` overrides even the constrained mode's default 2× cap.
+`layout="none"` retains presentation-only width/height; its encoding strategy stays `pad`.
+
+For different mobile and desktop crops, pass width breakpoints in priority order and a default:
+
+```tsx
+
+
+
+```
+
+The same map sizes the emitted container; no duplicate responsive CSS is needed. Each breakpoint gets
+real `fillcrop` candidates and its own JPEG fallback; preloads select only the matching crop.
+Use up to eight `(min-width: …)` or `(max-width: …)` conditions with px, em or rem. No oversized
+viewport-width arithmetic is needed. Receipts always supply intrinsic signing geometry;
+separate `width`/`height` props describe presentation, even without fixed layout. One presentation
+dimension derives the other proportionally.
+
+## Private
+
+Keep private uploads in a never-published directory such as `uploads/`. Removing a JavaScript
+public declaration does not revoke server policy or recall cached bytes. Create the separate
+application key described in [Login and credentials](#login-and-credentials), not the CLI login key.
+Set that pair in both the host's server-only build and runtime environments, never as `NEXT_PUBLIC_`.
+
+```ts
+// transloadit.authorize.ts, beside next.config.ts
+import type { AuthorizeTransloaditStorageImage } from '@transloadit/img/next/server'
+import { authenticate, canReadStorageObject } from './lib/authorization'
+
+export const authorize: AuthorizeTransloaditStorageImage = async ({ path, request }) => {
+ const user = await authenticate(request)
+ return user !== null && (await canReadStorageObject(user, path))
+}
+```
+
+`request` is a standard Web `Request`; read the browser's native cookie through your session library.
+Export in `app/api/storage-images/route.ts` (prefix source paths with `src/` if your app uses it):
+
+```ts
+export { GET, HEAD } from '@transloadit/img/next/route'
+```
+
+The default route is `/api/storage-images`; denied requests return `404`. Each uncached private
+image load invokes the handler once. Conventional private redirects use private, no-store responses.
+Downstream CDN grants have 30–60 minutes remaining by default and are usable
+until expiry, independently of redirect caching. Image bytes always bypass the application.
+
+`image init uploads/ --private` scaffolds those two files with fail-closed authorization, including
+in an existing public project. It never overwrites application code. The optional `--write-env` copies the saved login key
+for local testing only; replace it with the separate application key before deployment.
+Public-only rendering never reads or validates signing credentials and needs no application env.
+
+For custom routing or caching, keep the explicit factory escape hatch:
+
+```ts
+import { createStorageImages } from '@transloadit/img/next/server'
+import catalog from '../transloadit.images.json'
+import { authorize } from '../transloadit.authorize'
+
+export const { StorageImage, storageRoute } = createStorageImages({ ...catalog, authorize, cacheMaxAge: '1m' })
+```
+
+Its route exports `storageRoute as GET, storageRoute as HEAD` from the application factory.
+This optional minute of redirect caching delays reauthorization; omit it for private, no-store.
+
+## Mixed public and private images
+
+Spread the committed catalog alongside `authorize` to share one factory. Published paths always emit
+direct unsigned URLs, with zero application image requests; private paths still emit capabilities
+and use the authorizer. Public prefixes are also allowed prefixes when no explicit `allowedPathPrefixes` is supplied,
+including for an empty catalog. An explicit allowed policy still bounds public prefixes; the workspace
+root cannot be declared public. CLI commands maintain `public` in `transloadit.images.json` after
+updating server policy; do not edit that field manually:
+
+```bash
+npx transloadit storage publish website/
+npx transloadit storage publish website/ --dry-run
+npx transloadit storage publications
+npx transloadit storage unpublish website/
+```
+
+Publishing is idempotent and requires `dam:write`. `storage store ./hero.jpg website/hero.jpg --public`
+declares the destination directory after checkpointing the upload. It prints the recursive
+current-and-future publication boundary before changing it. If publication fails, the receipt
+remains saved: retry with `storage publish website/`, not another upload. A root object cannot be
+published with --public. Plain `storage store` never changes access policy.
+`--dry-run` only lists current matching objects through the S3 read API; it never publishes or
+changes the local catalog. Future objects under that prefix would also be public after publication.
+Unpublishing stops uncached origin access. Cached or downloaded bytes cannot be recalled.
+
+## When it breaks
+
+In development only, the server performs one HEAD per unique path/Template per factory, with a
+five-second timeout. The probe runs in the background and never holds up the image or redirect.
+An opted-in development failure fallback shares that same result (origin/path only, no query) and
+points at the terminal; it issues no additional HEAD. Production fallback output is unchanged.
+Concurrent/repeated renders share that probe. Redirects probe only after
+application authorization; disallowed prefixes fail before any request. Production performs no
+diagnostic requests. Editing the factory configuration recreates its probes through Next.js Fast
+Refresh; repeated requests to the unchanged factory do not retry a failed check automatically.
+
+Denied redirect routes also emit one development-only hint per reason (and verified path for authorization): route/basePath mismatch,
+invalid or stale capability (secret/Template changes), disallowed prefix, or failed authorization.
+Malformed capabilities never reveal a requested path. After decoding a valid capability, denied
+authorization names the catalog path and offers `storage publish` only if that image should be public;
+it never changes policy. URLs, signatures and secrets are not logged. When the conventional catalog's
+`public` policy changes during development, one server notice lists paths that now require the
+private route and authorization. Unknown catalog keys name the path, suggest a close spelling, and
+show a safely quoted `storage store` command. Set `basePath` explicitly in the factory
+if your Next.js app uses one; no internal Next environment variable is consulted.
+Private direct delivery logs once per factory that it makes the route dynamic; public direct does not.
+
+An unsigned public HEAD with `Transloadit-Error: NO_SIGNATURE_FIELD` (HTTP 400) gets a
+`transloadit storage publish` hint. Other HTTP 400 responses receive generic endpoint/Template
+advice; 404 points to the workspace, path or Template. Older API versions without the header get
+the generic hint, not an inferred publication diagnosis. A 200 image response with
+`immutable` confirms the public delivery/cache contract.
+If that path should be private, remove its stale public declaration from the catalog or factory
+and configure private delivery; an authorization callback cannot gate a path still declared public.
+Recovery commands target the default catalog unless you add `--receipts ` for your
+custom catalog. With an explicit factory, update its images/public configuration as well.
+The probe reads the `Transloadit-Error` code before choosing advice. `INSUFFICIENT_AUTH_SCOPE`
+calls for `smart_cdn:sign`: edit the application key in Console → Credentials,
+with Smart CDN enabled (`assemblies:write` is also accepted, but grants broader Assembly access). Other safe error-code
+labels are included in the HEAD result. Only a 403 without a specific code leaves Smart CDN
+enablement, workspace, secret, expiry and clock ambiguous. No response bodies, raw errors,
+signed query strings or secrets are logged.
+The probe can trigger one cold transformation in development; it does not weaken authorization.
+
+```tsx
+Image unavailable
}
+/>
+```
+
+This optional small client boundary keeps the exact server-rendered picture and replaces it only
+after a failed native image load, including one completed before hydration. It adds no wrapper
+element or retry loop. A changed source remounts the boundary. For a same-page sign-in followed
+by `router.refresh()`, the **experimental** `retryKey` option accepts a non-secret session identity, for example
+`retryKey={user?.id ?? 'anonymous'}`. The refreshed Server Component then resets a failed image
+even though its redirect URL has not changed. A deliberate retry counter also works. Refreshing
+alone does not reset a stable failed boundary, and unchanged keys never cause retry loops.
+With no JavaScript, native image
+failure behavior remains. A cross-origin browser error cannot identify the HTTP failure reason.
+This is separate from `suspenseFallback`, which handles pending server signing, and from JPEG
+format fallback, which does not recover failed AVIF/WebP requests.
+
+
+### Login and credentials
+
+`auth login` creates a short-lived device authorization, prints its code and verification URL,
+opens your browser on macOS/Linux/Windows and polls until you approve the workspace. Open the
+printed URL manually if opening fails. Windows uses `cmd /c start ""` with a safely quoted URL and no
+AutoRun/delayed expansion. `--no-browser` only skips the
+browser launch. Ctrl-C cancels polling without saving anything. Secrets never pass through the
+browser URL or a localhost callback. A browser denial stops polling immediately and saves nothing.
+The approval page lets you sign up before choosing a workspace;
+the code remains valid for 15 minutes while you verify your email and finish signup.
+
+The approved **Auth Key** appears under the Console's
+**[Credentials](https://transloadit.com/c//template-credentials/)** sidebar item and supports
+Assemblies/Storage writes and Smart CDN. Existing keys used for private rendering also need
+Smart CDN enabled and the `smart_cdn:sign` scope (`assemblies:write` is also accepted).
+
+For private deployments, create a **separate application key** in Console → Credentials → New Auth Key
+with Smart CDN on and the `smart_cdn:sign` scope (`assemblies:write` is also accepted).
+The signing-only scope permits URL transforms, not standalone Assembly or Storage writes.
+Set `TRANSLOADIT_SMART_CDN_KEY` and `TRANSLOADIT_SMART_CDN_SECRET` in the host's
+server-only build and runtime environment, using the same pair for the page and route handler.
+If you also grant Assembly access and use that combined key with `new Transloadit()` from `@transloadit/node`, pass
+`signatureAlgorithm: 'sha256'`: new Console-created combined keys use SHA-256, while the SDK keeps
+its SHA-384 default for existing keys. The image component already signs Smart CDN URLs correctly.
+`TRANSLOADIT_SMART_CDN_KEY/SECRET` override the pair, not individual missing fields. Keeping the
+application key separate prevents a developer's logout from breaking deployed images: `auth logout`
+revokes the browser-login key. Never deploy that disposable login identity as the application's key.
+
+Login saves `TRANSLOADIT_WORKSPACE`, `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET` in
+`~/.transloadit/credentials` with owner-only permissions. A shell `TRANSLOADIT_CREDENTIALS_FILE`
+override is supported; project dotenv cannot redirect newly authorized credentials.
+Existing credentials are preserved: login prints their file path, saved workspace/description and
+file modification date in UTC. Set `TRANSLOADIT_CREDENTIALS_FILE` to another file for a separate
+login, or deliberately use `--replace`; app env files and symlinks are refused.
+`auth status` prints the saved workspace and key description without secrets. `auth logout`
+revokes a browser-login key before removing the credentials file, ignoring stale shell/project
+keys. Applications using that same key stop working too. Imported (`--stdin`) and legacy keys
+are only forgotten locally; use `auth logout --revoke` to explicitly revoke those shared keys.
+If revocation fails, the file remains so you can retry. `DELETE /auth_keys/self` identifies
+the signing key on the server; no stored key ID or broad key-management scope is needed.
+Revocation propagates through API caches asynchronously; logout is not an instantaneous global
+cutoff. `--no-revoke` is rejected without changing the key or credentials file.
+After saving, login makes one bounded signed `GET /storage/public_prefixes` to verify `dam:write`
+and catalog access without publishing anything. Failure preserves the login and prints a Console
+link plus a retry command. Success does not prove worker/object-store upload availability.
+`auth login --stdin` retains automation with dotenv input (workspace optional, but needed by init),
+verifying one signed Template read. Never pass secrets as CLI arguments.
+The login also saves its API signing algorithm. For combined keys this is SHA-256; the CLI uses
+it for subsequent API requests. With `--stdin`, include `TRANSLOADIT_SIGNATURE_ALGORITHM=sha256`
+for such a key. Existing credentials without this value retain the SDK's SHA-384 default.
+Unrestricted keys (`signature_algo: null`) also retain that default for API requests.
+
+`image init` is optional: `--example` uses an existing catalog without credentials; publication or
+initializing an empty project prefers the saved login, keeping key, workspace and endpoint together.
+The catalog carries `{ workspace, public, images }` and optional non-production `delivery`.
+`TRANSLOADIT_WORKSPACE` overrides the catalog workspace when explicitly set in the app's environment.
+Remove a stale override if image URLs point at another workspace; the factory does not read the CLI's
+saved credentials file. Private `--write-env` creates
+an owner-only `.env.local` containing only key and secret, never overwriting it. Omit that flag to
+leave env files untouched. All keys are **server-only**, never `NEXT_PUBLIC_`.
+Private initialization preserves already-published directories; it does not unpublish them.
+The generated example selects a receipt in the initialized directory, or shows the empty state.
+Public-only rendering reads workspace and policy from the catalog, not signing credentials. Private capability
+prerenders need a build-time secret; request-only direct rendering can defer it to runtime.
+Supply the same private credentials to the deployed route handler.
+
+CLI lookup is shell environment, current-directory `.env`, then the credentials file.
+Ordinary commands retain this order. Storage commands print the selected credential source only
+when a shell/project override wins, including mixed credentials and any declared workspace.
+A declared env workspace is not proof of key ownership. Storage commands verify it through one
+read for env/legacy keys or use the workspace verified during device login. A mismatch stops the
+operation: `Project uses ; the selected credentials belong to . Nothing uploaded.`
+`--workspace` explicitly selects another workspace but never mixes its records into the existing
+catalog; use `--receipts` with a separate file. Login/init do not overwrite shell/project settings.
+Login uses production unless `--endpoint` selects an explicit trusted API origin; this binding is
+saved alongside the credential. Ordinary commands honor `TRANSLOADIT_ENDPOINT` under the same
+lookup rules. Rendering never loads CLI credential files. The Assembly client is an upload-side
+dependency, not part of rendering or the browser.
+
+`auth login --endpoint ` persists that endpoint in the saved login. On first catalog creation,
+store records `delivery.baseUrl: '/file/{workspace}'` and `urlParams: { cdn: 'required' }`
+for non-production. Subsequent writes preserve an existing delivery block. Remove it, and any
+explicit plugin/factory overrides, to switch to production Smart CDN. Public rendering stays
+secretless and production derives the CDN host from the catalog workspace.
+For a separate login, set `TRANSLOADIT_CREDENTIALS_FILE` in your shell before logging in.
+Console → Credentials contains the key; follow its real workspace link printed by the CLI.
+
+Init detects `app/` or `src/app/` and checks existing files before publishing. If a later local
+write fails after publication, it reports that the prefix remains public. Do not unpublish shared
+directories merely to retry a local scaffold. For manual setup, import the catalog into
+`createStorageImages(catalog)`; `src/lib` imports the root catalog from
+`../../transloadit.images.json`. See [local dogfood](https://github.com/transloadit/node-sdk/blob/img-onboard/docs/img-dogfood.md)
+for trusted devdock endpoint overrides and the required CDN acknowledgment.
+
+## Delivery overrides
+
+CLI `--endpoint` (saved by login) and `TRANSLOADIT_ENDPOINT` select the Assembly/Storage API.
+A new non-production catalog records that origin in its delivery block; existing blocks are
+preserved. The plugin accepts an explicit delivery override. The equivalent factory escape hatch is:
+
+```ts
+import { createStorageImages } from '@transloadit/img/next/server'
+import catalog from '../transloadit.images.json'
+
+export const { StorageImage } = createStorageImages({
+ ...catalog,
+ baseUrl: 'https://api2-devdock.transloadit.dev/file/{workspace}',
+ urlParams: { cdn: 'required' },
+})
+```
+
+This example is for a trusted local devdock, not production configuration. `baseUrl` is the
+delivery base before the Template and image path, with an optional `{workspace}` placeholder;
+it must be an absolute HTTP(S) URL without credentials, query string or fragment. Direct API2
+delivery requires the explicit `cdn: 'required'` acknowledgment. `urlParams` supplies transport
+parameters; it cannot override image geometry, format, background, version or signing fields.
+Never derive either option from browser input: a private delivery origin receives signed URLs.
+Ordinary production delivery needs neither override and uses the workspace's Smart CDN hostname.
+
+## Redirect lifetime and caching
+
+Redirect capabilities hide filenames and bind one path and transformation. Authorization must
+return exactly `true`. The handler responds with a fresh signed CDN URL in a `307`; no image bytes
+pass through the app. By default each candidate load makes one app function invocation for
+authorization and redirect — normally one per image per page view, more on candidate changes.
+The matching responsive preload is reused by the image, not a second intended redirect.
+Redact capabilities and signed CDN query strings from logs. A private capability still requires
+application authorization; a downstream signed CDN URL is usable until its own expiry.
+The capability has no independent expiry: current prefix and authorization policy is checked
+again at the handler. Key/route/custom-Template or capability-contract changes can invalidate it.
+Only cached redirects and already-issued CDN grants delay revocation.
+
+Default `Cache-Control: private, no-store` rechecks every redirect request. To trade faster repeat
+loads for delayed reauthorization, opt in with `cacheMaxAge: '30s'`. The `307` uses
+`private, max-age=30` (HTTP seconds), capped at the rotation interval and signed lifetime. Errors
+remain `no-store`. Cached redirects may grant access without a new app check until that age elapses.
+CDN URLs already issued remain usable until their own expiry; downloaded bytes cannot be recalled.
+
+After a directory is published, old private capabilities can redirect to its unsigned public URL.
+These compatibility redirects share-cache for at most one minute: their request URL has no receipt
+hash, so a longer cache could retain an old cache-tagged target after an overwrite and catalog refresh.
+New public markup uses direct cache-tagged CDN URLs and does not take this compatibility route.
+
+### Cache and markup cost
+
+Production Smart CDN uses Bunny, configured on `*.tlcdn.com`: hostname and the whole query string
+form the cache key. This is our pull-zone configuration, not universal Bunny behavior.
+Format-specific URLs avoid unkeyed Accept negotiation. A representative constrained
+hero has 11 image candidates (five AVIF, five WebP, one JPEG), plus five preload candidates.
+The pinned Built-ins omit default JPEG format, quality 75, pad resizing and white background;
+transparent formats retain their explicit background. Dimensions stay explicit. Custom Templates
+keep all fields because their defaults are unknown; `cdn` is sent only when delivery configuration
+sets it. This deliberately changes cache keys during unpublished dogfood. Markup overhead is not
+transferred image bytes; compression and full-page RSC data vary. Private expiry/signature rotation
+creates new cache entries (30 minutes by default).
+Public URLs have no signature or expiry. They are cache-busted, not immutable origin identities:
+an old uncached URL can fetch new bytes after a path overwrite. Prefer immutable filenames:
+`storage store ./hero.jpg website/ --hashed` inserts the first eight hex digits of the input MD5
+before the extension, for example `website/hero.fce9d56a.jpg`. The catalog key, generated types and
+printed JSX use that name; the receipt's `source` keeps the original local filename for humans.
+The same bytes at the same destination are a no-op when the same-workspace catalog has a verified
+receipt with matching full MD5, size and API origin. Hashed receipts record `apiOrigin` so a dev
+workspace cannot stand in for production just because their slugs match. A missing or different
+origin stops the command; use a separate `--receipts` catalog for that environment.
+Commit the catalog: without that evidence the CLI cannot
+prove a remote conflict is the same object. Restore the receipt or choose another basename; a
+short-hash collision is never overwritten. Changed bytes get a new name, so `--overwrite` is not
+needed and cannot be combined with `--hashed`. Do not modify the input while uploading.
+The `v` tag is then belt-and-braces; hashed naming does not change the origin's versioning contract.
+`v` is a cache-busting tag derived from the receipt hash; the origin does not verify it, so a cold
+request after an overwrite can return the replacement. With a receipt MD5 it uses the first 16 hex digits and
+responses use `public, max-age=31536000, s-maxage=31536000, immutable`. Changed bytes plus a refreshed
+catalog change the cache key. Without an MD5, no cache tag is invented: the public Built-in uses its
+ordinary three-day browser/one-day shared cache policy. Production Bunny cache hits/cost are a
+separate deployment check, not something the local browser fixture establishes.
+
+### Direct delivery for request-authorized galleries
+
+Select `delivery: 'direct'` explicitly for request-rendered private delivery. It can reduce
+application requests for galleries whose page data is already authorized:
+
+```ts
+export const { StorageImage } = createStorageImages({
+ allowedPathPrefixes: ['website/'],
+ delivery: 'direct',
+})
+```
+
+It avoids per-image application requests. Authorize the
+page's image data before rendering. `connection()` defers signing to the request, with an inert,
+source-free Suspense shell for partial prerendering. Do not cache the signed markup in a shared
+full-page cache. A lazy candidate requested after expiry can fail; direct URLs are bearer grants
+until expiry. Prefer redirects for long-lived pages. `suspenseFallback` replaces only the pending
+server shell, not browser image failures.
+
+Without a catalog, prefixes or `allowWorkspaceRoot: true` are required. `[]` deliberately denies all.
+Directory prefixes end in `/`; ambiguous paths are rejected. Prefixes bound signing but are not a
+replacement for per-user object authorization. Rotate the secret together with cached markup:
+existing redirect capabilities become invalid.
+
+### Template migrations and revocation
+
+Capabilities bind the payload-contract version, workspace and route/basePath, not the default
+Built-in version. An SDK upgrade can pin a new compatible `storage-preview` without breaking old
+private markup: the new handler signs with its current Built-in and rechecks current authorization.
+There are no `previousTemplates` options or time-window chores for consumers. This unpublished
+factory consolidation requires a one-time consumer update/rebuild; it is not a compatibility
+promise for earlier experimental exports.
+
+An explicitly configured `template` is bound to the capability. Coordinate custom Template changes
+with a cached-markup rebuild. Payload-contract changes also require a capability-version bump and
+rebuild; ordinary Built-in updates do not. Rotating the signing secret invalidates existing
+capabilities. Already-issued or cached CDN grants remain usable until their own expiry.
+Public pinned Built-ins must remain served while their permanent URLs are in circulation; coordinate
+backend migrations before retiring a version. The SDK never retires server Templates.
+Opaque capabilities use server-side AES-GCM-SIV from `@noble/ciphers` for deterministic safe sealing.
+
+One factory owns both modes. Omitting `public`, `authorize` and `delivery: 'direct'` throws;
+a catalog or prefix is not an authorization decision. `authorize` adds `storageRoute` to the result.
+
+
+### Format, width and lifetime policy
+
+Private delivery pins `builtin/storage-preview@0.0.2`; public delivery pins
+`builtin/public-preview@0.0.1`, which wraps `builtin/storage-preview@0.0.2`: the public URL pins
+its transformation pipeline version too. AVIF quality 45 and WebP quality
+75 precede a JPEG quality 75 fallback. Formats use separate URLs, not unkeyed Accept negotiation.
+Candidate widths follow 320, 640, 960, 1280, 1920, 2560, 3840 plus intrinsic width, bounded by the
+source and backend dimensions. `widths` overrides the ladder; the JPEG fallback is no larger than
+its largest candidate. For a 48px avatar, `widths={[48, 96]}` also caps JPEG at 96px.
+
+Explicit `sizes` describes CSS layout; it does not set that layout. Without a derived or explicit
+size, lazy images default to `sizes="auto, 100vw"` (automatic CSS-box sizing where supported,
+viewport fallback otherwise); eager/preloaded images retain `100vw`. Auto sizing is lazy-only:
+Chrome 126+ and [Firefox 150+](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/150)
+support it; Safari does not yet. Browsers without support use the listed fallback. Keep explicit fallback lengths.
+`objectFit` controls CSS, while the default `r: 'pad'` preserves source
+proportions in encoded candidates. AVIF/WebP/PNG candidates use `bg: '#00000000'` to preserve
+transparency through both preview and encoding; JPEG uses an opaque background, white by default.
+`fallbackBackground="#224466"` changes only the JPEG background (six RGB hex digits, or eight RGBA
+digits ending in `ff`). Named colors and transparent JPEG backgrounds are rejected before signing.
+Pass raw hex colors: URL signing encodes `#` as `%23`. `bg` cannot be overridden through global
+`urlParams`. A custom Template must support the same background field contract.
+`formats` sets per-format quality; `fallbackQuality` sets JPEG quality.
+`template` overrides only private previews; `publicTemplate` independently overrides public
+delivery and must accept unsigned requests with the same fields. A private Built-in cannot serve
+as a public override, even when its input directory is published.
+
+`lifetime` is a **private-grant maximum**, in milliseconds or a duration such as `'1h'`.
+It defaults to one hour and cannot exceed 48 hours, including in mixed factories. Public URLs
+ignore lifetime and rotation and never need an expiry-driven rebuild.
+Private rotation defaults to half the lifetime, capped at one hour. The default grant therefore
+has 30–60 minutes remaining, never 60–120. `rotationInterval` cannot exceed half the private
+lifetime, preserving a delivery margin; smaller buckets reduce variation but fragment the cache.
+
+`cacheMaxAge` and `rotationInterval` accept milliseconds or the same strings as `lifetime`, for
+example `'1m'` and `'30m'`. `cacheMaxAgeMs` and `rotationIntervalMs` are deprecated numeric aliases;
+do not supply both spellings. In development, an image without explicit `sizes` can warn when
+its decoded, density-corrected width exceeds twice its rendered CSS width. Transient 0/1px boxes
+are ignored; cached/HiDPI resolution alone does not trigger the hint. Production does no size diagnostics.
+
+Without `errorFallback`, HTTP failure uses native broken-image/alt behavior. JPEG is a format
+fallback, not HTTP-error recovery.
+
+### Experimental browser recovery controls
+
+`retryKey` is experimental, retained for the native-cookie sign-in recovery browser fixture.
+Prefer the native behavior or `errorFallback` alone unless a
+same-page sign-in/refresh needs an explicit retry identity. See [When it breaks](#when-it-breaks).
+
+### Receipt integrity and recovery
+
+Older deployments may watermark Community-plan uploads. The CLI reports changed bytes and saves
+metadata for the actual stored image; it does not suggest overwriting that completed upload.
+
+The store command wraps `client.storeImage()`, waits for completion and validates `asset_id`,
+the exact destination, stored byte count/MD5 and positive EXIF-oriented display dimensions.
+The receipt lands in `results[':original']`, not `results.stored`. Older deployments can apply
+Community-plan watermarks before Storage runs; newer API2 deployments preserve stored originals.
+The receipt describes the stored bytes, not an assumption that they equal the local file.
+The CLI warns about a changed size or checksum and saves that authoritative receipt normally.
+`--log-level debug` adds the Assembly ID, bounded receipt summary and input comparison, never
+raw Assembly responses or credentials. SDK callers can observe `(receipt, input, assemblyId)`
+through the optional `onReceipt` callback. Observers are not awaited; synchronous exceptions and
+asynchronous rejections do not discard completed writes.
+Rendering requires no metadata lookup.
+
+`storage store ./images/*.jpg website/` accepts shell-expanded files and a directory destination.
+Each successful upload is checkpointed before the next; a later failure preserves earlier receipts.
+Duplicate destination basenames are refused before uploading unless `--hashed` distinguishes them
+by content (identical bytes reuse the first receipt, even during an explicit workspace override).
+The printed snippet uses a
+filename-derived alt; replace it with an accurate description, or an empty alt for a decorative image.
+
+The CLI atomically appends to the catalog's `images` object keyed by Storage path, preserving earlier receipts
+on failure. Parent directories must exist. A sibling lock prevents concurrent writers from losing
+each other's records. Ctrl-C cancels active uploads and S3 reads, releases the lock, and checkpoints
+any receipt that already returned before stopping. An accepted Assembly may still finish remotely:
+check Storage or sync receipts before retrying a write. A forced exit or crash can leave a lock;
+remove it only after confirming the writer has stopped.
+New catalogs use ordinary file permissions derived from your umask; existing modes are preserved.
+The credentials file remains private (`0600`).
+Receipt validation occurs after the Storage write, not as a rollback. Do not re-upload or use
+`--overwrite` to fix missing metadata. Existing paths conflict by default.
+`storage store --overwrite` explicitly replaces an occupied path; it is never the default. Prefer
+[hashed immutable filenames](#cache-and-markup-cost) because delivery resolves paths, not receipt
+hashes, and cached bytes can outlive an overwrite. On older deployments that transform uploaded
+bytes, the CLI still saves the authoritative receipt; a differing checksum makes a hashed replay
+fail without uploading or replacing anything. Restoring that same transformed receipt cannot fix
+the mismatch. Choose a fresh destination basename; hashed replay requires an origin that preserves
+the uploaded bytes. The same refusal protects against a short-hash collision.
+
+### Recovery (requires the Storage read API, not yet enabled in production)
+
+If no usable receipt comes back, inspect with `storage ls` and recover with `storage receipts sync`,
+using the same catalog. Until this API is enabled, restore the committed catalog or use the
+[Assembly receipt recovery API](#images-uploaded-by-your-users) with trusted upload metadata.
+
+`storage ls` and `storage receipts sync` require the S3 read API, currently off in production until
+`API2_STORAGE_S3_ENABLED` is deployed. HTTP 403 cannot distinguish a disabled API from denied access;
+check the endpoint and key scope before retrying.
+
+`storage ls website/` lists the current workspace using its Auth Key with `read` or `dam:write` scope and the existing
+S3-compatible read API, without an Assembly. `--workspace` overrides automatic workspace discovery.
+S3 allows 30 seconds to receive headers and at most two attempts per request. A 60-second deadline
+also covers retries and response-body reads; failed syncs release the catalog lock and leave the
+existing catalog intact.
+It uses the endpoint saved with those key credentials; `--endpoint` is an explicit trusted override
+and accepts the API origin, not a bucket URL. The rendering factory's `baseUrl` is unrelated.
+The Storage S3 API must be enabled separately: successful Assembly-based storage or image delivery
+does not imply that listing is enabled. A disabled S3 API returns HTTP 403, even with valid credentials.
+
+Recover or refresh a rendering catalog without re-uploading or downloading originals:
+
+```console
+npx transloadit storage receipts sync website/
+```
+
+This uses paginated List + HEAD with the same `read` or `dam:write` credentials, `--workspace` and
+`--endpoint` options as `storage ls`. Sync also reads `GET /storage/public_prefixes` with `dam:write`
+scope and commits server-declared public policy and receipts atomically. If policy cannot be read,
+recovery fails without changing the existing file. Folder names never imply public access.
+An empty server policy is recovered as `public: []`, not silently republished. For intentionally
+public images run `storage publish` on the intended directory; otherwise configure `authorize`
+for private delivery. The CLI and factory explain this missing delivery choice.
+HEAD's `x-amz-meta-dam-width` and `x-amz-meta-dam-height`
+rebuild `{ path, width, height }`, which can be passed directly as `StorageImage`'s `src`.
+`md5hash` is included only for compatible single-part ETags; multipart, opaque and SSE-KMS/SSE-C
+ETags are not treated as MD5. See [S3's ETag contract](https://docs.aws.amazon.com/AmazonS3/latest/API/API_Object.html).
+HEAD does not expose `asset_id`: sync recovers rendering metadata, not a verified upload receipt.
+Sync preserves an existing `asset_id`, `size`, `source`, `apiOrigin`, `thumbhash` and `hasAlpha` only when the HEAD MD5 matches
+the saved hash. A fresh sync has no original bytes and cannot reconstruct ThumbHash or alpha metadata.
+It cannot generate a ThumbHash from List + HEAD; fresh recovered receipts leave that field absent.
+Otherwise it replaces that entry with rendering metadata, so stale upload evidence is not retained.
+
+Sync adds or refreshes matching paths and never prunes unmatched entries. Any missing/invalid
+dimensions, failed HEAD or incomplete listing leaves the existing file intact; a failed atomic
+replacement retains the complete temporary catalog for recovery. Choose an image-only prefix;
+older objects without dimensions need a catalog backfill. Storage records EXIF-oriented display
+dimensions for new image uploads, so sync matches `storeImage` receipts for rotated photos too.
+Commit `transloadit.images.json` before building so rendering needs no runtime metadata lookup; it can now
+be regenerated from Storage rather than being the only copy of rendering metadata.
+
+### Images uploaded by your users
+
+The CLI is for repository/content seeding. In an application, use Uppy with its Transloadit plugin
+or your existing Assembly upload flow, with a server-owned `/transloadit/store` step:
+
+```json
+{
+ "steps": {
+ "stored": {
+ "robot": "/transloadit/store",
+ "use": ":original",
+ "path": "uploads/server-generated-upload-id/${file.url_name}",
+ "conflict_strategy": "error"
+ }
+ }
+}
+```
+
+The application server authenticates the uploader, chooses the destination prefix/upload ID,
+and signs the Assembly parameters or a trusted Template. Transloadit interpolates the literal
+`${file.url_name}`. Do not put the Assembly secret in Uppy/browser code or let a client choose
+another user's destination/steps. Enable `uploads/` in the rendering factory's allowed prefixes
+only alongside an exact per-object ownership check in `authorize`.
+
+Treat a notification as a wake-up signal. Correlate its Assembly ID with the upload your server
+authorized, then use the write-side SDK client to fetch and verify the receipt:
+
+```ts
+const receipt = await client.getStoredImageReceipt({
+ assemblyId: upload.assemblyId,
+ expected: { path: upload.path, size: upload.size, md5hash: upload.md5hash },
+})
+await saveImage({ ...receipt, ownerId: upload.ownerId })
+```
+
+Here `upload` is your trusted, server-side upload record, not an unchecked request body. The flow
+is Uppy → store step → notification → `getStoredImageReceipt` → persist. The helper validates
+one original, its exact path/asset_id and EXIF-oriented dimensions. Unlike the result of a write
+initiated by `storeImage`, recovery from a separate Assembly ID also requires a size/MD5 match
+with your trusted upload record. Community-plan watermarks can break that byte-identity check.
+For transformed or multiple inputs, correlate and validate the appropriate annotated result step
+yourself; do not replace trusted expectations with unchecked notification fields.
+Failed Assemblies retain their `ApiError` code, such as `TRANSLOADIT_STORE_CONFLICT`. An unfinished
+Assembly raises `InconsistentResponseError` naming its current status; retry recovery after it
+finishes. A completed Assembly with mismatched receipt data remains an integrity error.
+
+The same call recovers a local receipts-file failure: get the completed Assembly ID from the CLI
+error or Console, supply the original file's trusted path/size/MD5, and append the returned receipt
+to your saved app data. Do not rerun the write merely to recover metadata. Persist the complete
+receipt with your owner/project ID; never persist a browser-supplied receipt without verification.
+
+Dynamic receipts need an explicit factory: the Quickstart's package component infers its allowed
+directories from the catalog and will reject `uploads/` when only `website/` was seeded. Keep that
+public catalog unchanged. This separate private factory does not need the CLI catalog or a rebuild for each upload.
+Use your workspace slug in place of `your-workspace` and the server-only application signing key
+from [Private setup](#private); an uploads-only app needs no `withTransloaditImages` plugin.
+The examples use `app/`; when using `src/app/`, put the factory in `src/app/` and helpers in `src/lib/`.
+
+```ts
+// app/upload-images.ts
+import { createStorageImages } from '@transloadit/img/next/server'
+import { getSession } from '../lib/authorization'
+
+export const { StorageImage, storageRoute } = createStorageImages({
+ workspace: 'your-workspace',
+ allowedPathPrefixes: ['uploads/'],
+ route: '/api/upload-images',
+ authorize: async ({ path, request }) =>
+ (await getSession(request))?.canRead(path) === true,
+})
+```
+
+```ts
+// app/api/upload-images/route.ts
+export { storageRoute as GET, storageRoute as HEAD } from '../../upload-images'
+```
+
+`getSession`, `canRead` and `getAuthorizedImage` below are your application's helpers, not SDK helpers.
+`canRead(path)` must check the current user's permission for that exact stored object, not merely
+whether they are logged in or the path starts with `uploads/`. Keep `uploads/` private; do not publish
+it or allow the workspace root. This route has its own path so it can coexist with the Quickstart route.
+
+Read the saved receipt in an authorized Server Component and import this factory's component, not
+the catalog-bound package component. `getAuthorizedImage(id)` must authenticate the viewer, check
+ownership and return the validated database receipt (or stop with a not-found/denied response):
+
+```tsx
+// app/uploads/[id]/page.tsx
+import type { ReactNode } from 'react'
+import { getAuthorizedImage } from '../../../lib/images'
+import { StorageImage } from '../../upload-images'
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+export default async function Page({ params }: PageProps): Promise {
+ const { id } = await params
+ const savedImage = await getAuthorizedImage(id)
+ return
+}
+```
+
+`savedImage` is the application's validated database record; owner and asset IDs are never forwarded.
+A public receipt's `v` is a cache-busting tag derived from the receipt hash; the origin does not
+verify it, so a cold request after an overwrite can return the replacement. Private signing omits
+the tag. Prefer immutable filenames; see [upload/overwrite guidance](#receipt-integrity-and-recovery).
+The browser never needs the Assembly secret, Smart CDN secret, or a render-time metadata lookup.
+
+### Credentials and framework adapters
+
+`createStorageImages({ authKey, authSecret, workspace, images, public: ['website/'] })` supports
+secret managers and multiple workspaces with the same flat options. The env factory snapshots only
+the workspace and private signing pair on first use; it loads no files and uses the login key names
+unless the complete Smart CDN override pair is supplied. The factory accepts a trusted compatible `template`,
+`baseUrl` and transport `urlParams`. Never derive these signing policies from request input.
+
+`@transloadit/img` exposes `createTransloaditImageModel` and serializable model types for other
+framework adapters. `@transloadit/img/next` renders a resolved model without owning credentials.
+
+“Native props” means serializable image attributes such as `alt`, `className`, `aria-*`, `data-*`,
+`decoding` and `referrerPolicy`. Event callbacks and refs do not cross this Server Component
+boundary. `src` and candidate URLs belong to the configured catalog and signing policy.
diff --git a/packages/img/package.json b/packages/img/package.json
index 8d2b31a7..345360c4 100644
--- a/packages/img/package.json
+++ b/packages/img/package.json
@@ -12,6 +12,7 @@
},
"files": [
"dist",
+ "docs",
"README.md"
],
"main": "./dist/index.js",
@@ -23,8 +24,29 @@
},
"./next": {
"types": "./dist/next/index.d.ts",
+ "react-server": "./dist/next/react-server.js",
"default": "./dist/next/index.js"
},
+ "./next/authorize": {
+ "types": "./dist/next/authorize.d.ts",
+ "default": "./dist/next/authorize.js"
+ },
+ "./next/catalog": {
+ "types": "./dist/next/catalog.d.ts",
+ "default": "./dist/next/catalog.js"
+ },
+ "./next/config": {
+ "types": "./dist/next/config.d.ts",
+ "default": "./dist/next/config.js"
+ },
+ "./next/options": {
+ "types": "./dist/next/options.d.ts",
+ "default": "./dist/next/options.js"
+ },
+ "./next/route": {
+ "types": "./dist/next/route.d.ts",
+ "default": "./dist/next/route.js"
+ },
"./next/server": {
"types": "./dist/next/server.d.ts",
"default": "./dist/next/server.js"
@@ -41,10 +63,11 @@
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@transloadit/utils": "workspace:^",
- "server-only": "^0.0.1"
+ "server-only": "^0.0.1",
+ "thumbhash": "0.1.1"
},
"peerDependencies": {
- "next": ">=16.0.0 <17.0.0",
+ "next": ">=16.3.3 <17.0.0",
"react": ">=19.0.0 <20.0.0",
"react-dom": ">=19.0.0 <20.0.0"
},
@@ -63,7 +86,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0",
- "next": "16.3.0",
+ "next": "16.3.4",
"react": "^19.2.6",
"react-dom": "^19.2.6"
}
diff --git a/packages/img/src/imageBackground.ts b/packages/img/src/imageBackground.ts
new file mode 100644
index 00000000..137eaf40
--- /dev/null
+++ b/packages/img/src/imageBackground.ts
@@ -0,0 +1,7 @@
+/** Fully transparent background supported by the Storage preview pipeline. */
+export const transparentImageBackground = '#00000000'
+
+/** JPEG requires an opaque six-digit RGB color or eight-digit RGBA color ending in ff. */
+export function isOpaqueImageBackground(value: unknown): value is string {
+ return typeof value === 'string' && value.trim() === value && /^#[0-9a-f]{6}(?:ff)?$/i.test(value)
+}
diff --git a/packages/img/src/imageSource.ts b/packages/img/src/imageSource.ts
new file mode 100644
index 00000000..bf52208a
--- /dev/null
+++ b/packages/img/src/imageSource.ts
@@ -0,0 +1,80 @@
+import { validateStoragePath } from '@transloadit/utils'
+
+/** Saved source geometry; structurally compatible with a verified SDK Storage receipt. */
+export interface TransloaditImageSource {
+ readonly path: string
+ readonly width: number
+ readonly height: number
+ /** Original-byte MD5 from a verified receipt or compatible Storage HEAD ETag. */
+ readonly md5hash?: string
+ /** Optional base64 ThumbHash, generated from the original bytes by storage store. */
+ readonly thumbhash?: string
+ /** An original alpha channel disables persistent blur backgrounds, without a client load handler. */
+ readonly hasAlpha?: boolean
+}
+
+/** A path needs separate dimensions; a receipt owns its dimensions. */
+export type TransloaditImageSourceProps =
+ | { src: string; width: number; height: number }
+ | { src: TransloaditImageSource; width?: never; height?: never }
+
+function validateDimension(value: unknown, name: string): asserts value is number {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
+ throw new RangeError(`${name} must be a positive safe integer`)
+ }
+}
+
+/** Copies and validates source geometry before attribute getters, suspension or signing. */
+export function snapshotImageSource(props: {
+ src: unknown
+ width?: unknown
+ height?: unknown
+}): TransloaditImageSource {
+ const src = props.src
+ let path: unknown
+ let width: unknown
+ let height: unknown
+ let md5hash: unknown
+ let thumbhash: unknown
+ let hasAlpha: unknown
+ if (typeof src === 'string') {
+ path = src
+ width = props.width
+ height = props.height
+ } else {
+ if (
+ typeof src !== 'object' ||
+ src === null ||
+ Array.isArray(src) ||
+ !('path' in src) ||
+ !('width' in src) ||
+ !('height' in src) ||
+ props.width !== undefined ||
+ props.height !== undefined
+ ) {
+ throw new TypeError(
+ 'Storage image src must be one relative object path or a receipt without separate dimensions',
+ )
+ }
+ path = src.path
+ width = src.width
+ height = src.height
+ md5hash = 'md5hash' in src ? src.md5hash : undefined
+ thumbhash = 'thumbhash' in src ? src.thumbhash : undefined
+ hasAlpha = 'hasAlpha' in src ? src.hasAlpha : undefined
+ }
+ if (typeof path !== 'string') throw new TypeError('Storage image receipt path must be a string')
+ validateStoragePath(path)
+ validateDimension(width, 'width')
+ validateDimension(height, 'height')
+ if (md5hash !== undefined && (typeof md5hash !== 'string' || !/^[a-f0-9]{32}$/i.test(md5hash)))
+ throw new TypeError('Storage image md5hash must be a 32-digit hexadecimal checksum')
+ return {
+ path,
+ width,
+ height,
+ ...(typeof md5hash === 'string' ? { md5hash: md5hash.toLowerCase() } : {}),
+ ...(typeof thumbhash === 'string' ? { thumbhash } : {}),
+ ...(hasAlpha === true ? { hasAlpha: true } : {}),
+ }
+}
diff --git a/packages/img/src/index.ts b/packages/img/src/index.ts
index 86d78ad7..9207ef53 100644
--- a/packages/img/src/index.ts
+++ b/packages/img/src/index.ts
@@ -1,17 +1,24 @@
import type { SignSmartCdnImageRequest, SmartCdnImageFormat } from '@transloadit/utils'
+import type { TransloaditImageSourceProps } from './imageSource.ts'
+
import {
resolveSmartCdnImageFormats,
resolveSmartCdnImageWidths,
smartCdnImageMaxDimension,
} from '@transloadit/utils'
-import { validateStoragePath } from './storagePath.ts'
+import { isOpaqueImageBackground, transparentImageBackground } from './imageBackground.ts'
+import { snapshotImageSource } from './imageSource.ts'
export type { SignSmartCdnImageRequest, SmartCdnImageSignRequest } from '@transloadit/utils'
+export type { TransloaditImageSource } from './imageSource.ts'
+
/** Signed Built-in used by default for Transloadit Storage previews. */
-export const transloaditStoragePreviewTemplate = 'builtin/storage-preview@0.0.1'
+export const transloaditStoragePreviewTemplate = 'builtin/storage-preview@0.0.2'
+/** Unsigned Built-in, served only under a server-declared public Storage prefix. */
+export const transloaditPublicStoragePreviewTemplate = 'builtin/public-preview@0.0.1'
const defaultFallbackQuality = 75
const defaultResponsiveImageWidths: readonly number[] = [320, 640, 960, 1280, 1920, 2560, 3840]
const minimumMillisecondTimestamp = 1_000_000_000_000
@@ -40,30 +47,37 @@ export interface TransloaditImageSourceSet {
/** Serializable data consumed by framework renderers. */
export interface TransloaditImageModel {
+ /** Ordered viewport-specific crops; each includes its own JPEG fallback. */
+ artDirection?: readonly { media: string; model: TransloaditImageModel }[]
/** Fixed URL expiry. Omitted when an adapter resolves fresh URLs after browser authorization. */
expiresAt?: number
fallbackUrl: string
sources: readonly TransloaditImageSourceSet[]
}
-/** Framework-neutral options for a responsive Transloadit Storage preview. */
-export interface TransloaditImageModelOptions {
- expiresAt: number
+interface TransloaditImageModelConfiguration {
+ /** Optional output width/height ratio; requests a server-side fillcrop instead of padding. */
+ cropAspectRatio?: number
+ expiresAt: Expiry
+ /** Opaque JPEG background as #rrggbb or #rrggbbff. Defaults to white. */
+ fallbackBackground?: string
+ /** Optional JPEG width, capped by the resolved candidate ladder. */
+ fallbackWidth?: number
/** Encoding quality for the signed JPEG fallback. Defaults to 75. */
fallbackQuality?: number
formats?: StoragePreviewFormats
- /** Storage preview aspect-ratio numerator. */
- height: number
- /** Relative object path inside the configured Transloadit Storage workspace. */
- src: string
- /** Trusted compatible signed Template. Defaults to `builtin/storage-preview@0.0.1`. */
+ /** Maximum candidate width, additionally bounded by the source and backend dimensions. */
+ maximumWidth?: number
+ /** Trusted compatible signed Template. Defaults to `builtin/storage-preview@0.0.2`. */
template?: string
- /** Storage preview aspect-ratio denominator and conservative JPEG fallback width. */
- width: number
/** Requested intrinsic candidate widths. Defaults to a conservative ladder up to the source. */
widths?: readonly number[]
}
+/** Framework-neutral options for a responsive Transloadit Storage preview. */
+export type TransloaditImageModelOptions =
+ TransloaditImageModelConfiguration & TransloaditImageSourceProps
+
function validateDimension(value: number, name: string): void {
if (!Number.isInteger(value) || value < 1 || value > smartCdnImageMaxDimension) {
throw new RangeError(`${name} must be an integer from 1 through ${smartCdnImageMaxDimension}`)
@@ -82,9 +96,9 @@ function validateTemplate(template: string): void {
}
}
-function validateQuality(quality: number, name: string): void {
- if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
- throw new RangeError(`${name} must be an integer from 1 through 100`)
+function validateQuality(quality: number, name: string, maximum = 100): void {
+ if (!Number.isInteger(quality) || quality < 1 || quality > maximum) {
+ throw new RangeError(`${name} must be an integer from 1 through ${maximum}`)
}
}
@@ -103,52 +117,87 @@ function getResponsiveImageWidths(
}
/** Creates one signed, serializable responsive preview of a Transloadit Storage object. */
-export function createTransloaditImageModel(
- options: TransloaditImageModelOptions,
- sign: SignSmartCdnImageRequest,
+export function createTransloaditImageModel(
+ options: TransloaditImageModelOptions,
+ sign: SignSmartCdnImageRequest,
): TransloaditImageModel {
+ const { path: src, width, height } = snapshotImageSource(options)
const expiresAt = options.expiresAt
+ const cropAspectRatio = options.cropAspectRatio
+ const requestedMaximumWidth = options.maximumWidth
+ const requestedFallbackWidth = options.fallbackWidth
+ const fallbackBackground = options.fallbackBackground ?? '#ffffff'
const fallbackQuality = options.fallbackQuality ?? defaultFallbackQuality
const formats = options.formats === undefined ? undefined : { ...options.formats }
- const height = options.height
- const src = options.src
const template = options.template ?? transloaditStoragePreviewTemplate
- const width = options.width
+ // API2's exact public Built-in narrows the private preview's dimensions and quality.
+ // Customer templates and future Built-in versions retain their existing contract.
+ const maxDimension =
+ template === transloaditPublicStoragePreviewTemplate ? 4096 : smartCdnImageMaxDimension
+ const maxQuality = template === transloaditPublicStoragePreviewTemplate ? 85 : 100
const widthsSnapshot = Array.isArray(options.widths) ? [...options.widths] : options.widths
- validatePositiveSafeInteger(expiresAt, 'expiresAt')
- if (expiresAt < minimumMillisecondTimestamp) {
- throw new RangeError('expiresAt must be a millisecond timestamp')
+ if (expiresAt !== undefined) {
+ validatePositiveSafeInteger(expiresAt, 'expiresAt')
+ if (expiresAt < minimumMillisecondTimestamp)
+ throw new RangeError('expiresAt must be a millisecond timestamp')
}
if (typeof sign !== 'function') throw new TypeError('sign must be a function')
- validatePositiveSafeInteger(width, 'width')
- validatePositiveSafeInteger(height, 'height')
- validateQuality(fallbackQuality, 'fallbackQuality')
- validateStoragePath(src)
+ validateQuality(fallbackQuality, 'fallbackQuality', maxQuality)
+ const resolvedFormats = resolveSmartCdnImageFormats(formats)
+ for (const { quality } of resolvedFormats) validateQuality(quality, 'quality', maxQuality)
validateTemplate(template)
-
- const heightLimitedWidth = Number(
- (BigInt(smartCdnImageMaxDimension) * BigInt(width)) / BigInt(height),
- )
+ if (!isOpaqueImageBackground(fallbackBackground)) {
+ throw new TypeError('fallbackBackground must be an opaque #rrggbb or #rrggbbff color')
+ }
+ if (
+ cropAspectRatio !== undefined &&
+ (!Number.isFinite(cropAspectRatio) || cropAspectRatio <= 0)
+ ) {
+ throw new RangeError('cropAspectRatio must be a positive finite number')
+ }
+ if (requestedMaximumWidth !== undefined)
+ validatePositiveSafeInteger(requestedMaximumWidth, 'maximumWidth')
+ if (requestedFallbackWidth !== undefined)
+ validatePositiveSafeInteger(requestedFallbackWidth, 'fallbackWidth')
+
+ const ratioWidth = cropAspectRatio ?? width
+ const ratioHeight = cropAspectRatio === undefined ? height : 1
+ const heightLimitedWidth =
+ cropAspectRatio === undefined
+ ? Number((BigInt(maxDimension) * BigInt(width)) / BigInt(height))
+ : Math.floor(maxDimension * cropAspectRatio)
if (heightLimitedWidth < 1) {
throw new RangeError('display aspect ratio cannot fit within backend dimensions')
}
- const maximumWidth = Math.min(width, smartCdnImageMaxDimension, heightLimitedWidth)
+ const maximumWidth = Math.min(
+ width,
+ maxDimension,
+ heightLimitedWidth,
+ cropAspectRatio === undefined ? width : Math.floor(height * cropAspectRatio),
+ requestedMaximumWidth ?? width,
+ )
+ if (maximumWidth < 1) {
+ throw new RangeError(
+ 'source dimensions and cropAspectRatio must allow a crop at least one pixel wide',
+ )
+ }
const widths = resolveSmartCdnImageWidths(
getResponsiveImageWidths(widthsSnapshot, maximumWidth),
maximumWidth,
)
- const sources = resolveSmartCdnImageFormats(formats).map(({ format, quality }) => ({
+ const sources = resolvedFormats.map(({ format, quality }) => ({
candidates: widths.map((candidateWidth) => ({
url: sign({
expiresAt,
input: src,
template,
urlParams: {
+ bg: transparentImageBackground,
f: format,
- h: getStorageHeight(candidateWidth, width, height),
+ h: getStorageHeight(candidateWidth, ratioWidth, ratioHeight),
q: quality,
- r: 'pad',
+ r: cropAspectRatio === undefined ? 'pad' : 'fillcrop',
w: candidateWidth,
},
}),
@@ -156,16 +205,17 @@ export function createTransloaditImageModel(
})),
format,
}))
- const fallbackWidth = Math.min(width, maximumWidth)
+ const fallbackWidth = Math.min(requestedFallbackWidth ?? width, Math.max(...widths))
const fallbackUrl = sign({
expiresAt,
input: src,
template,
urlParams: {
+ bg: fallbackBackground,
f: 'jpg',
- h: getStorageHeight(fallbackWidth, width, height),
+ h: getStorageHeight(fallbackWidth, ratioWidth, ratioHeight),
q: fallbackQuality,
- r: 'pad',
+ r: cropAspectRatio === undefined ? 'pad' : 'fillcrop',
w: fallbackWidth,
},
})
diff --git a/packages/img/src/next/HydratedTransloaditPicture.tsx b/packages/img/src/next/HydratedTransloaditPicture.tsx
deleted file mode 100644
index a80f4737..00000000
--- a/packages/img/src/next/HydratedTransloaditPicture.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-
-import { useSyncExternalStore } from 'react'
-
-interface HydratedTransloaditPictureProps {
- children: ReactNode
- fallback: ReactNode
-}
-
-const subscribe = (): (() => void) => () => {}
-const getClientSnapshot = (): true => true
-const getServerSnapshot = (): false => false
-
-/** Mounts responsive source elements after hydration while retaining a no-script fallback. */
-export function HydratedTransloaditPicture({
- children,
- fallback,
-}: HydratedTransloaditPictureProps): ReactNode {
- const hydrated = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
-
- return hydrated ? children :
-}
diff --git a/packages/img/src/next/ImageSizeDiagnostics.tsx b/packages/img/src/next/ImageSizeDiagnostics.tsx
new file mode 100644
index 00000000..86cf78d3
--- /dev/null
+++ b/packages/img/src/next/ImageSizeDiagnostics.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+import type { ReactNode } from 'react'
+
+import { useEffect, useRef } from 'react'
+
+interface ImageSizeDiagnosticsProps {
+ children: ReactNode
+}
+
+/** Development-only native candidate inspection; the production renderer omits this boundary. */
+export function ImageSizeDiagnostics({ children }: ImageSizeDiagnosticsProps): ReactNode {
+ const ref = useRef(null)
+ useEffect(() => {
+ const container = ref.current
+ if (container === null) return
+ const warned = new WeakSet()
+ let frame = 0
+ let observedImage: HTMLImageElement | undefined
+ const observer = new ResizeObserver(schedule)
+ function schedule(): void {
+ cancelAnimationFrame(frame)
+ frame = requestAnimationFrame(inspect)
+ }
+ function inspect(): void {
+ const image = container?.querySelector('img')
+ if (!(image instanceof HTMLImageElement) || warned.has(image)) return
+ if (observedImage !== image) {
+ observer.disconnect()
+ observer.observe(image)
+ observedImage = image
+ }
+ if (!image.complete || image.naturalWidth === 0 || image.currentSrc === '') return
+ const { width: cssWidth, height } = image.getBoundingClientRect()
+ // Streamed/hydrating content can temporarily have a 1px box before its real layout.
+ if (cssWidth <= 1 || height <= 0) return
+ // naturalWidth is density-corrected CSS pixels. A larger cached/HiDPI candidate alone
+ // does not imply incorrect sizes when its intended display width matches the real box.
+ if (image.naturalWidth <= 2 * cssWidth) return
+ const sources = image.closest('picture')?.querySelectorAll('source') ?? []
+ // A JPEG fallback has no width descriptor; its decoded natural width is unscaled.
+ let width = image.naturalWidth
+ for (const source of sources) {
+ for (const match of source.srcset.matchAll(/(?:^|, )(\S+) (\d+)w/g)) {
+ if (new URL(match[1], image.baseURI).href !== image.currentSrc) continue
+ width = Number(match[2])
+ }
+ }
+ if (width <= 2 * cssWidth) return
+ warned.add(image)
+ console.warn(
+ `[StorageImage] The selected ${width}px candidate is more than twice its ${Math.round(cssWidth)}px rendered width. Set sizes to match the image’s CSS width.`,
+ )
+ }
+ schedule()
+ container.addEventListener('load', schedule, true)
+ window.addEventListener('resize', schedule)
+ return () => {
+ cancelAnimationFrame(frame)
+ observer.disconnect()
+ container.removeEventListener('load', schedule, true)
+ window.removeEventListener('resize', schedule)
+ }
+ }, [])
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/img/src/next/StorageImageErrorBoundary.tsx b/packages/img/src/next/StorageImageErrorBoundary.tsx
new file mode 100644
index 00000000..c4cc4f80
--- /dev/null
+++ b/packages/img/src/next/StorageImageErrorBoundary.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import type { ComponentProps, ReactNode } from 'react'
+
+import { Children, cloneElement, isValidElement, useEffect, useRef, useState } from 'react'
+
+interface StorageImageErrorBoundaryProps {
+ children: ReactNode
+ fallback: ReactNode
+}
+
+/** Retains the SSR picture; only an opted-in failed image switches to application-owned UI. */
+export function StorageImageErrorBoundary({
+ children,
+ fallback,
+}: StorageImageErrorBoundaryProps): ReactNode {
+ const picture = useRef(null)
+ const [failed, setFailed] = useState(false)
+ useEffect(() => {
+ const image = picture.current?.querySelector('img')
+ // The browser can finish (and fail) a native image request before hydration attaches events.
+ if (image?.complete && image.currentSrc !== '' && image.naturalWidth === 0) setFailed(true)
+ }, [])
+ if (failed) return fallback
+ // Flight can deliver children as a lazy reference, not a directly cloneable React element.
+ const elements = Children.toArray(children)
+ const element = elements[0]
+ if (
+ elements.length !== 1 ||
+ !isValidElement>(element) ||
+ element.type !== 'picture'
+ ) {
+ throw new Error('Storage image error fallback requires one picture')
+ }
+ return cloneElement(element, {
+ ref: picture,
+ onErrorCapture(event) {
+ if (event.target instanceof HTMLImageElement) setFailed(true)
+ },
+ })
+}
diff --git a/packages/img/src/next/authorize.ts b/packages/img/src/next/authorize.ts
new file mode 100644
index 00000000..1fd46a8c
--- /dev/null
+++ b/packages/img/src/next/authorize.ts
@@ -0,0 +1,4 @@
+import type { AuthorizeTransloaditStorageImage } from './server.tsx'
+
+// Only the plugin may replace this with application code; request data never selects a module.
+export const authorize: AuthorizeTransloaditStorageImage | undefined = undefined
diff --git a/packages/img/src/next/catalog.ts b/packages/img/src/next/catalog.ts
new file mode 100644
index 00000000..03379d74
--- /dev/null
+++ b/packages/img/src/next/catalog.ts
@@ -0,0 +1,18 @@
+import type { StorageImageCatalog } from './layout.ts'
+import type { StorageImagesConfiguration } from './server.tsx'
+
+/** Nonsecret transport overrides for a trusted Storage endpoint. */
+export type StorageImageDelivery = Pick
+
+/** The CLI's committed project identity, public policy and original image geometry. */
+export interface StorageProjectCatalog {
+ workspace: string
+ public: readonly string[]
+ images: StorageImageCatalog
+ delivery?: StorageImageDelivery
+}
+
+// The plugin replaces this module with the project's JSON. Keep import itself harmless so
+// explicitly configured factories and model-only renderers do not require the plugin.
+const catalog: StorageProjectCatalog | undefined = undefined
+export default catalog
diff --git a/packages/img/src/next/config.ts b/packages/img/src/next/config.ts
new file mode 100644
index 00000000..01cfa979
--- /dev/null
+++ b/packages/img/src/next/config.ts
@@ -0,0 +1,110 @@
+import type { NextConfig } from 'next'
+
+import type { StorageImageDelivery } from './catalog.ts'
+
+import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
+import { isAbsolute, relative, resolve } from 'node:path'
+
+import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_SERVER } from 'next/constants.js'
+
+/** Bind a single project catalog; use explicit factories for several independently typed catalogs. */
+export interface TransloaditImagesOptions {
+ catalog?: string
+ delivery?: StorageImageDelivery
+ /** The Next.js app directory, for commands started from a monorepo's parent directory. */
+ root?: string
+}
+
+/** Bundle the catalog and optional authorizer with both Next bundlers; no runtime cwd lookup. */
+export function withTransloaditImages(
+ nextConfig: NextConfig = {},
+ options: TransloaditImagesOptions = {},
+): (phase: string) => NextConfig {
+ // next start only serves compiled modules. Deployment may prune the generation cache and
+ // source catalog, or mount a read-only filesystem; neither is a runtime prerequisite.
+ return (phase) =>
+ phase === PHASE_PRODUCTION_SERVER ? nextConfig : buildConfiguration(nextConfig, options, phase)
+}
+
+function buildConfiguration(
+ nextConfig: NextConfig,
+ options: TransloaditImagesOptions,
+ phase: string,
+): NextConfig {
+ const root = resolve(options.root ?? process.cwd())
+ const catalog = resolve(root, options.catalog ?? 'transloadit.images.json')
+ function projectPath(file: string): string {
+ const path = relative(root, file).replaceAll('\\', '/')
+ if (path.startsWith('../') || isAbsolute(path))
+ throw new Error(
+ 'The Storage image catalog must be inside the Next.js app; use an explicit factory for shared catalogs outside it.',
+ )
+ return `./${path}`
+ }
+ const catalogPath = projectPath(catalog)
+ if (!statSync(catalog, { throwIfNoEntry: false })?.isFile())
+ throw new Error(
+ `Missing Storage image catalog ${catalogPath}. Run transloadit storage store ./hero.jpg website/hero.jpg first (add --public only for public images), or select an existing catalog in withTransloaditImages.`,
+ )
+ const authorize = resolve(root, 'transloadit.authorize.ts')
+ // Turbopack treats the build output directory as output, not an importable source tree.
+ const generated = resolve(root, 'node_modules/.cache/transloadit-images')
+ const configuration = resolve(generated, 'options.json')
+ const value = `${JSON.stringify({
+ ...(phase === PHASE_DEVELOPMENT_SERVER
+ ? { authorizePath: authorize, diagnosticsId: catalog }
+ : {}),
+ ...(nextConfig.basePath ? { basePath: nextConfig.basePath } : {}),
+ ...(options.delivery === undefined
+ ? {}
+ : {
+ delivery: {
+ baseUrl: options.delivery.baseUrl,
+ urlParams: options.delivery.urlParams,
+ },
+ }),
+ })}\n`
+ // Avoid needless invalidation in dev. This build-only JSON carries no catalog copy,
+ // authorization code or credentials.
+ if (
+ !statSync(configuration, { throwIfNoEntry: false })?.isFile() ||
+ readFileSync(configuration, 'utf8') !== value
+ ) {
+ mkdirSync(generated, { recursive: true })
+ writeFileSync(configuration, value)
+ }
+ const aliases: Record = {
+ '@transloadit/img/next/catalog': catalogPath,
+ '@transloadit/img/next/options': projectPath(configuration),
+ ...(statSync(authorize, { throwIfNoEntry: false })?.isFile()
+ ? { '@transloadit/img/next/authorize': projectPath(authorize) }
+ : {}),
+ }
+ return {
+ ...nextConfig,
+ turbopack: {
+ ...nextConfig.turbopack,
+ resolveAlias: { ...nextConfig.turbopack?.resolveAlias, ...aliases },
+ },
+ outputFileTracingIncludes: {
+ ...nextConfig.outputFileTracingIncludes,
+ '/*': [
+ ...new Set([
+ ...(nextConfig.outputFileTracingIncludes?.['/*'] ?? []),
+ ...Object.values(aliases),
+ ]),
+ ],
+ },
+ webpack(config, context) {
+ const configured = nextConfig.webpack?.(config, context) ?? config
+ configured.resolve ??= {}
+ configured.resolve.alias = {
+ ...configured.resolve.alias,
+ ...Object.fromEntries(
+ Object.entries(aliases).map(([name, path]) => [`${name}$`, resolve(root, path)]),
+ ),
+ }
+ return configured
+ },
+ }
+}
diff --git a/packages/img/src/next/diagnostics.ts b/packages/img/src/next/diagnostics.ts
new file mode 100644
index 00000000..8e2b1a04
--- /dev/null
+++ b/packages/img/src/next/diagnostics.ts
@@ -0,0 +1,105 @@
+import type { StorageProjectCatalog } from './catalog.ts'
+
+import { publishImageHint } from './pathHints.ts'
+
+/** Server-side development probe; logs the target origin/path, never queries or raw errors. */
+export type DiagnoseStorageImage = (
+ path: string,
+ url: string,
+ publicPrefix?: string,
+) => Promise
+
+const deliveryOverrideHint =
+ 'If you use a different API or CDN, set baseUrl/urlParams in the plugin delivery override or factory.'
+
+async function probe(path: string, url: string, publicPrefix?: string): Promise {
+ const target = new URL(url)
+ const safeUrl = `${target.origin}${target.pathname}`
+ try {
+ const response = await fetch(url, {
+ method: 'HEAD',
+ redirect: 'manual',
+ cache: 'no-store',
+ signal: AbortSignal.timeout(5000),
+ })
+ const header = response.headers.get('Transloadit-Error')
+ // The header is an error-code label, never an arbitrary upstream message or response body.
+ const code =
+ header !== null && header.length <= 64 && /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(header)
+ ? header
+ : undefined
+ const status = `HTTP ${response.status}${code === undefined ? '' : ` (${code})`}`
+ const summary = `HEAD ${safeUrl}: ${status}`
+ if (response.ok && response.headers.get('content-type')?.startsWith('image/')) {
+ if (
+ publicPrefix !== undefined &&
+ response.headers.get('cache-control')?.includes('immutable')
+ )
+ console.info(
+ `[StorageImage] Public delivery verified at ${safeUrl}: image response with immutable caching.`,
+ )
+ return summary
+ }
+ // A manual HEAD cannot establish whether the browser's redirect target is a valid image.
+ if ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has('location'))
+ return summary
+ const hints =
+ code === 'INSUFFICIENT_AUTH_SCOPE'
+ ? 'Grant smart_cdn:sign for image delivery. In Console → Credentials, edit the application key: enable Smart CDN and smart_cdn:sign; assemblies:write is also accepted, but grants broader Assembly access.'
+ : response.status === 404
+ ? 'Check the workspace slug, that the Storage path exists there, and the configured Template.'
+ : publicPrefix !== undefined && code === 'NO_SIGNATURE_FIELD'
+ ? `Storage path ${JSON.stringify(path)} may no longer be under a published public prefix. If already published, check its workspace and public Built-in. If it should be private, remove its public prefix from the catalog or factory and configure private delivery with application authorization. ${publishImageHint(path, publicPrefix)}`
+ : publicPrefix === undefined && (response.status === 401 || response.status === 403)
+ ? 'Enable Smart CDN on the Auth Key; check its workspace and the signature secret, expiry and server clock.'
+ : response.ok
+ ? 'Expected an image Content-Type. Check the configured Template and delivery endpoint.'
+ : `The delivery host did not serve this path as an image. Check the delivery endpoint and Template. ${deliveryOverrideHint}`
+ console.warn(`[StorageImage] Development HEAD ${safeUrl} returned ${status}. ${hints}`)
+ return summary
+ } catch {
+ // Error messages can include a credential-bearing URL. A HEAD failure does not establish
+ // whether the cause is credentials, networking, a cold transformation, or the CDN itself.
+ console.warn(
+ `[StorageImage] Could not reach Smart CDN at ${safeUrl} within five seconds. Check connectivity to this delivery host. ${deliveryOverrideHint}`,
+ )
+ return `HEAD ${safeUrl}: could not reach the delivery host within five seconds`
+ }
+}
+
+/** Deduplicates concurrent and repeated probes within one credentialed development integration. */
+export function createImageDiagnostics(template: string): DiagnoseStorageImage | undefined {
+ if (process.env.NODE_ENV !== 'development') return undefined
+ const requests = new Map>()
+ return (path, url, publicPrefix) => {
+ const key = JSON.stringify([path, template])
+ const previous = requests.get(key)
+ if (previous !== undefined) return previous
+ const result = probe(path, url, publicPrefix)
+ requests.set(key, result)
+ return result
+ }
+}
+
+declare global {
+ var __transloaditImagePublicPolicies: Map | undefined
+}
+
+/** Keeps only dev policy snapshots across HMR; each project's catalog has a distinct identity. */
+export function diagnosePublicPolicy(catalog: StorageProjectCatalog, id?: string): void {
+ if (process.env.NODE_ENV !== 'development' || id === undefined) return
+ globalThis.__transloaditImagePublicPolicies ??= new Map()
+ const policies = globalThis.__transloaditImagePublicPolicies
+ const previous = policies.get(id)
+ policies.set(id, [...catalog.public])
+ if (previous === undefined) return
+ const privatePaths = Object.keys(catalog.images).filter(
+ (path) =>
+ previous.some((prefix) => path.startsWith(prefix)) &&
+ !catalog.public.some((prefix) => path.startsWith(prefix)),
+ )
+ if (privatePaths.length === 0) return
+ console.info(
+ `[StorageImage] Catalog public prefixes changed. These paths now require the private image route and authorization: ${privatePaths.map((path) => JSON.stringify(path)).join(', ')}.`,
+ )
+}
diff --git a/packages/img/src/next/imageAttributes.ts b/packages/img/src/next/imageAttributes.ts
new file mode 100644
index 00000000..c2dbc133
--- /dev/null
+++ b/packages/img/src/next/imageAttributes.ts
@@ -0,0 +1,140 @@
+import type { DOMAttributes, ImgHTMLAttributes } from 'react'
+
+/** Native attributes that can cross the server-rendering boundary, without caller-owned URLs. */
+export interface ImageAttributes
+ extends Omit<
+ ImgHTMLAttributes,
+ | keyof DOMAttributes
+ | 'defaultChecked'
+ | 'defaultValue'
+ | 'inlist'
+ | 'loading'
+ | 'src'
+ | 'srcSet'
+ | 'suppressContentEditableWarning'
+ | 'suppressHydrationWarning'
+ | 'tw'
+ > {
+ // React types this RDFa attribute as any; only serializable values belong in this API.
+ inlist?: string
+ [attribute: `data-${string}`]: string | number | boolean | null | undefined
+}
+
+// Exhaustive against React's native img attributes. New React attributes require an explicit
+// decision here; arbitrary JS props must never leak factory configuration or override URLs.
+const nativeAttributes: Record<
+ Exclude,
+ true
+> = {
+ about: true,
+ accessKey: true,
+ alt: true,
+ autoCapitalize: true,
+ autoCorrect: true,
+ autoFocus: true,
+ autoSave: true,
+ className: true,
+ color: true,
+ content: true,
+ contentEditable: true,
+ contextMenu: true,
+ crossOrigin: true,
+ datatype: true,
+ decoding: true,
+ dir: true,
+ draggable: true,
+ enterKeyHint: true,
+ exportparts: true,
+ fetchPriority: true,
+ height: true,
+ hidden: true,
+ id: true,
+ inert: true,
+ inlist: true,
+ inputMode: true,
+ is: true,
+ itemID: true,
+ itemProp: true,
+ itemRef: true,
+ itemScope: true,
+ itemType: true,
+ lang: true,
+ nonce: true,
+ part: true,
+ popover: true,
+ popoverTarget: true,
+ popoverTargetAction: true,
+ prefix: true,
+ property: true,
+ radioGroup: true,
+ referrerPolicy: true,
+ rel: true,
+ resource: true,
+ results: true,
+ rev: true,
+ role: true,
+ security: true,
+ sizes: true,
+ slot: true,
+ spellCheck: true,
+ tabIndex: true,
+ title: true,
+ translate: true,
+ typeof: true,
+ unselectable: true,
+ useMap: true,
+ vocab: true,
+ width: true,
+}
+
+/** Snapshots only native, serializable attributes before suspension or rendering. */
+export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes & { alt: string } {
+ const alt = props.alt
+ if (typeof alt !== 'string') throw new TypeError('Image alt must be a string')
+ const style = props.style
+ if (style != null && (typeof style !== 'object' || Array.isArray(style))) {
+ throw new TypeError('Image style must be an object')
+ }
+ const attributes = Object.fromEntries(
+ Object.entries(props).filter(
+ ([name, value]) =>
+ (Object.hasOwn(nativeAttributes, name) || /^(?:aria|data)-[\w.-]+$/.test(name)) &&
+ (value === undefined ||
+ value === null ||
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'),
+ ),
+ )
+ return { ...attributes, alt, style: style == null ? undefined : { ...style } }
+}
+
+/** A preload is eager; explicitly lazy images must not issue preload requests. */
+export type ImageLoadingProps = {
+ /** @deprecated Use preload; this alias remains for one release. */
+ priority?: boolean
+} & (
+ | { loading?: 'eager'; preload?: boolean }
+ | { loading?: 'eager' | 'lazy'; preload?: false; priority?: false }
+)
+
+/** Retains runtime validation for JavaScript callers as well as the discriminated public type. */
+export function snapshotImageLoading({
+ loading,
+ priority,
+ preload,
+}: {
+ loading?: 'eager' | 'lazy'
+ priority?: boolean
+ preload?: boolean
+}): ImageLoadingProps {
+ if (priority !== undefined && process.env.NODE_ENV === 'development')
+ console.warn(
+ '[StorageImage] priority is deprecated; use preload. The alias will be removed after one release.',
+ )
+ if (preload || priority) {
+ if (loading === 'lazy') throw new Error('A preloaded Transloadit image cannot use lazy loading')
+ return { loading: 'eager', preload: true }
+ }
+ return { loading, preload }
+}
diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx
index bce31c63..a63a056a 100644
--- a/packages/img/src/next/index.tsx
+++ b/packages/img/src/next/index.tsx
@@ -5,42 +5,70 @@ import type {
TransloaditImageModel,
TransloaditImageSourceSet,
} from '../index.ts'
+import type { ImageAttributes, ImageLoadingProps } from './imageAttributes.ts'
+import type { StorageImageCatalog } from './layout.ts'
+import type { TransloaditRedirectImageProps } from './server.tsx'
+import { Fragment } from 'react'
import { preload as preloadResource } from 'react-dom'
-import { HydratedTransloaditPicture } from './HydratedTransloaditPicture.tsx'
+import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts'
+import { StorageImageErrorBoundary } from './StorageImageErrorBoundary.tsx'
+
+/** Augmented by the CLI-generated transloadit-images.d.ts; an absent file keeps string sources. */
+// biome-ignore lint/suspicious/noEmptyInterface: This is the intentional consumer module-augmentation hook.
+export interface RegisteredStorageImages {}
+
+/** The conventional catalog supplies intrinsic dimensions even without generated declarations. */
+export type StorageImageProps = TransloaditRedirectImageProps<
+ keyof RegisteredStorageImages extends never
+ ? StorageImageCatalog
+ : {
+ [Path in keyof RegisteredStorageImages]: RegisteredStorageImages[Path] extends StorageImageCatalog[string]
+ ? RegisteredStorageImages[Path]
+ : never
+ }
+>
+
+/** Available to App Router Server Components through the react-server export condition. */
+export function StorageImage(_props: StorageImageProps): ReactNode {
+ throw new Error(
+ 'StorageImage is a Server Component. Render it in an App Router page or server component; use TransloaditPicture for an already resolved model in client code.',
+ )
+}
-const transparentPixel =
- 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
const mimeTypes = {
avif: 'image/avif',
png: 'image/png',
webp: 'image/webp',
} satisfies Record
-/** Presentation options shared by the signed Server Component and model-only renderer. */
-export interface TransloaditImagePresentationProps {
+interface ImagePresentationProps extends Omit {
alt: string
- className?: string
- deferUntilHydrated?: boolean
- fetchPriority?: 'auto' | 'high' | 'low'
- height: number
- loading?: 'eager' | 'lazy'
- media?: string
- /** CSP-compatible placeholder used while `media` is unmatched. Defaults to an inline GIF. */
- mediaPlaceholderSrc?: string
+ /** Optional client-side image-load fallback. Does not replace the server-rendered picture. */
+ errorFallback?: ReactNode
+ /** @experimental Change after signing in to reset a failed image with stable URLs. */
+ retryKey?: string | number
/** Explicitly handles a display box whose aspect ratio differs from the source image. */
objectFit?: CSSProperties['objectFit']
- preload?: boolean
/** Expected rendered widths. Browsers otherwise assume `100vw` for width-based source sets. */
sizes?: string
- style?: CSSProperties
+}
+
+/** Layout and loading without assuming how the caller supplies source dimensions. */
+export type TransloaditImageLayoutProps = ImagePresentationProps & ImageLoadingProps
+
+/** Serializable native image attributes and layout shared by both Next.js renderers. */
+export type TransloaditImagePresentationProps = TransloaditImageLayoutProps & {
+ height: number
width: number
}
/** Props for rendering an already-signed framework-neutral image model. */
-export interface TransloaditPictureProps extends TransloaditImagePresentationProps {
+export type TransloaditPictureProps = TransloaditImagePresentationProps & {
model: TransloaditImageModel
+ /** Already decoded on the server; no ThumbHash decoder enters the client graph. */
+ blurDataURL?: string
}
function getSourceSet(candidates: readonly TransloaditImageCandidate[]): string {
@@ -54,6 +82,17 @@ function getMimeType(format: TransloaditImageSourceSet['format']): string {
return mimeTypes[format]
}
+function getImageRecoveryKey({ model, retryKey }: TransloaditPictureProps): string {
+ const identity = JSON.stringify([model.fallbackUrl, model.sources, model.artDirection, retryKey])
+ // FNV-1a is only a remount identity, never an authorization hash. Keep all candidate URLs out
+ // of the Flight key without requiring Node crypto or asynchronous rendering in this component.
+ let hash = 0xcbf29ce484222325n
+ for (const byte of new TextEncoder().encode(identity)) {
+ hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n)
+ }
+ return hash.toString(16).padStart(16, '0')
+}
+
function escapeSourceSetUrl(url: string): string {
const sourceSet = url
.replaceAll('\t', '%09')
@@ -75,8 +114,8 @@ function escapeSourceSetUrl(url: string): string {
function preloadImage(
source: TransloaditImageSourceSet,
- sizes: string | undefined,
- fetchPriority?: 'auto' | 'high' | 'low',
+ sizes: string,
+ { crossOrigin, fetchPriority, referrerPolicy }: ImageAttributes,
): void {
const firstCandidate = source.candidates[0]
if (firstCandidate === undefined) {
@@ -85,132 +124,164 @@ function preloadImage(
preloadResource(firstCandidate.url, {
as: 'image',
+ crossOrigin,
fetchPriority,
imageSizes: sizes,
imageSrcSet: getSourceSet(source.candidates),
+ referrerPolicy,
type: getMimeType(source.format),
})
}
-function OriginalImage({
- alt,
- className,
- fetchPriority,
- height,
- loading,
- objectFit,
- src,
- style,
- width,
-}: Pick<
- TransloaditImagePresentationProps,
- 'alt' | 'className' | 'fetchPriority' | 'height' | 'loading' | 'objectFit' | 'style' | 'width'
-> & {
- src?: string
-}): ReactNode {
- return (
- // biome-ignore lint/performance/noImgElement: This package is the image optimizer.
-
- )
-}
-
-/**
- * Renders browser-selected responsive candidates with one fallback. `media` keeps an unmatched
- * viewport inert; the caller controls whether its layout still reserves space in that viewport.
- * `deferUntilHydrated` avoids WebKit parser-to-hydration request replay.
- */
-export function TransloaditPicture({
- alt,
- className,
- deferUntilHydrated = false,
- fetchPriority,
- height,
- loading,
- media,
- mediaPlaceholderSrc,
- model,
- objectFit,
- preload = false,
- sizes,
- style,
- width,
-}: TransloaditPictureProps): ReactNode {
- if (deferUntilHydrated && (loading === 'eager' || preload)) {
- throw new Error('An eager or preloaded Transloadit image cannot be deferred until hydration')
- }
- if (preload && loading === 'lazy') {
- throw new Error('A preloaded Transloadit image cannot use lazy loading')
- }
- if (preload && media !== undefined) {
- // React 19's responsive-preload identity omits media and can silently collapse art direction.
- throw new Error('A media-gated Transloadit image cannot be preloaded')
- }
+/** Renders immediately discoverable, browser-selected candidates with a JPEG fallback. */
+export function TransloaditPicture(props: TransloaditPictureProps): ReactNode {
+ const { model, objectFit, sizes: explicitSizes } = props
+ const { loading, preload = false } = snapshotImageLoading(props)
const resolvedLoading = loading ?? (preload ? 'eager' : 'lazy')
+ let sizes = explicitSizes ?? (resolvedLoading === 'lazy' ? 'auto, 100vw' : '100vw')
+ let automaticSizes = /^auto(?:\s*,|\s*$)/i.test(sizes.trimStart())
+ if (automaticSizes && resolvedLoading !== 'lazy') {
+ if (process.env.NODE_ENV === 'development')
+ console.warn(
+ '[StorageImage] auto sizes require lazy loading; using the explicit fallback for this eager image.',
+ )
+ sizes =
+ sizes
+ .trimStart()
+ .replace(/^auto(?:\s*,\s*|\s*$)/i, '')
+ .trim() || '100vw'
+ automaticSizes = false
+ }
if (model.sources.length === 0) {
throw new Error('Cannot render a Transloadit image without a source')
}
+ const attributes = snapshotImageAttributes(props)
+ if (preload) attributes.fetchPriority = 'high'
+ let blurStyle: CSSProperties | undefined
+ if (props.blurDataURL !== undefined) {
+ if (
+ typeof props.blurDataURL !== 'string' ||
+ props.blurDataURL.length > 6000 ||
+ !/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(props.blurDataURL)
+ )
+ throw new TypeError('blurDataURL must be a bounded base64 PNG data URL')
+ const fit = objectFit ?? attributes.style?.objectFit ?? 'fill'
+ // ThumbHash only approximates the source ratio. A retained background must never extend
+ // into letterboxing beside loaded pixels; box-filling images cover it without client JS.
+ if (fit === 'cover' || fit === 'fill') {
+ blurStyle = {
+ backgroundImage: `url("${props.blurDataURL}")`,
+ backgroundPosition: attributes.style?.objectPosition ?? 'center',
+ backgroundRepeat: 'no-repeat',
+ backgroundSize: '100% 100%',
+ }
+ } else if (process.env.NODE_ENV === 'development') {
+ console.warn(
+ '[StorageImage] letterboxed image: no blur placeholder. Use the default constrained layout or fit="cover" for a box-filling image.',
+ )
+ }
+ }
+ const artDirection = model.artDirection ?? []
const original = (
- takes precedence over either fallback.
- src={media ? (mediaPlaceholderSrc ?? transparentPixel) : model.fallbackUrl}
- style={style}
- width={width}
+ // Without img srcset, only lazy auto sizing is valid here. Fallback lengths stay on source.
+ sizes={automaticSizes ? 'auto' : undefined}
+ src={model.fallbackUrl}
+ style={{
+ ...attributes.style,
+ ...(objectFit === undefined ? {} : { objectFit }),
+ ...blurStyle,
+ }}
/>
)
- const fallback = media ? (
-
-
- {original}
-
- ) : (
- original
- )
- if (preload) {
+ if (preload && artDirection.length === 0) {
const preferredSource = model.sources[0]
if (preferredSource === undefined) {
throw new Error('Cannot preload a Transloadit image without a source')
}
- preloadImage(preferredSource, sizes, fetchPriority)
+ preloadImage(preferredSource, sizes, attributes)
}
+ const preloads =
+ preload && artDirection.length > 0
+ ? [...artDirection, { media: undefined, model }].map((variant, index) => {
+ const preferred = variant.model.sources[0]
+ if (preferred === undefined) throw new Error('Cannot preload an empty art direction')
+ const prior = artDirection.slice(0, index).map((source) => source.media)
+ const unmatched = prior.length === 0 ? undefined : `not (${prior.join(' or ')})`
+ const condition =
+ variant.media === undefined
+ ? unmatched
+ : unmatched === undefined
+ ? variant.media
+ : `${variant.media} and (${unmatched})`
+ return (
+
+ )
+ })
+ : null
const picture = (
+ {artDirection.map((variant) => (
+
+ {variant.model.sources.map((source) => (
+
+ ))}
+
+
+ ))}
{model.sources.map((source) => (
))}
- {media ? : null}
{original}
)
- return deferUntilHydrated ? (
- {picture}
- ) : (
- picture
+ const resolved =
+ props.errorFallback === undefined ? (
+ picture
+ ) : (
+
+ {picture}
+
+ )
+ return (
+ <>
+ {preloads}
+ {resolved}
+ >
)
}
diff --git a/packages/img/src/next/layout.ts b/packages/img/src/next/layout.ts
new file mode 100644
index 00000000..f86d5f73
--- /dev/null
+++ b/packages/img/src/next/layout.ts
@@ -0,0 +1,202 @@
+import type { CSSProperties } from 'react'
+
+import type { TransloaditImageSource } from '../imageSource.ts'
+
+import { snapshotImageSource } from '../imageSource.ts'
+import { missingImageHint } from './pathHints.ts'
+
+/** Committed rendering receipts indexed by their exact Storage paths. */
+export type StorageImageCatalog = Readonly>
+
+type CatalogSource = TransloaditImageSource | Extract
+
+type PresentationSourceProps =
+ | (Catalog extends undefined ? { src: string; width: number; height: number } : never)
+ | { src: CatalogSource; width?: number; height?: number }
+
+/** Crop ratios selected by viewport width; default is required for all other viewports. */
+export type StorageImageAspectRatio =
+ | string
+ | number
+ | Readonly<{ default: string | number } & Record>
+
+/** Receipt sources are responsive by default; none leaves presentation sizing to the caller. */
+export type StorageImageLayoutProps =
+ | (PresentationSourceProps & {
+ layout?: 'constrained' | 'none'
+ fit?: never
+ aspectRatio?: never
+ frame?: never
+ })
+ | {
+ layout: 'fixed'
+ src: CatalogSource
+ width: number
+ height: number
+ fit?: 'contain' | 'cover'
+ aspectRatio?: never
+ frame?: never
+ }
+ | ({
+ layout: 'fill'
+ src: CatalogSource
+ width?: never
+ height?: never
+ /** Opt out when the app already owns the positioned box and its responsive ratios. */
+ frame?: false
+ } & (
+ | { fit: 'cover'; aspectRatio: StorageImageAspectRatio }
+ | { fit?: 'contain'; aspectRatio?: string | number }
+ ))
+
+interface ResolvedImageLayout {
+ source: TransloaditImageSource
+ width: number
+ height: number
+ cropAspectRatio?: number
+ artDirection?: readonly { media: string; cropAspectRatio: number }[]
+ fallbackWidth?: number
+ maximumWidth?: number
+ sizes?: string
+ style?: CSSProperties
+ widths?: readonly number[]
+ frame?: { ratio: number; variants: readonly { media: string; cropAspectRatio: number }[] }
+}
+
+function boxDimension(value: number | undefined, name: string): number {
+ if (value === undefined || !Number.isSafeInteger(value) || value < 1 || value > 8000) {
+ throw new RangeError(`${name} must be an integer from 1 through 8000`)
+ }
+ return value
+}
+
+function parseAspectRatio(value: string | number | undefined): number {
+ const pieces = typeof value === 'string' ? value.split('/').map(Number) : [value]
+ const [width, height = 1] = pieces
+ if (
+ pieces.length > 2 ||
+ width === undefined ||
+ !Number.isFinite(width) ||
+ width <= 0 ||
+ !Number.isFinite(height) ||
+ height <= 0 ||
+ !Number.isFinite(width / height)
+ ) {
+ throw new TypeError('Cover fill requires a positive aspectRatio, for example "9/16"')
+ }
+ return width / height
+}
+
+/** Snapshots intrinsic and box geometry before any caller-owned attribute getter can mutate it. */
+export function resolveImageLayout(
+ props: StorageImageLayoutProps & { widths?: readonly number[] },
+ images?: StorageImageCatalog,
+): ResolvedImageLayout {
+ const input = props.src
+ const src =
+ typeof input === 'string' && images !== undefined
+ ? Object.hasOwn(images, input)
+ ? images[input]
+ : undefined
+ : input
+ if (src === undefined)
+ throw new TypeError(
+ typeof input === 'string'
+ ? missingImageHint(input, Object.keys(images ?? {}))
+ : 'Storage image src is required',
+ )
+ const layout = props.layout ?? (typeof src === 'string' ? 'none' : 'constrained')
+ if ((layout === 'fixed' || layout === 'fill') && typeof src === 'string') {
+ throw new TypeError(
+ `${layout} layout requires a receipt source with intrinsic dimensions${layout === 'fixed' ? '; width and height describe the display box' : ''}`,
+ )
+ }
+ const source = snapshotImageSource(
+ typeof src === 'string' ? { src, width: props.width, height: props.height } : { src },
+ )
+ const presentationWidth = typeof src === 'string' ? undefined : props.width
+ const presentationHeight = typeof src === 'string' ? undefined : props.height
+ const width =
+ presentationWidth === undefined
+ ? presentationHeight === undefined
+ ? source.width
+ : Math.max(1, Math.round((presentationHeight * source.width) / source.height))
+ : boxDimension(presentationWidth, 'width')
+ const height =
+ presentationHeight === undefined
+ ? presentationWidth === undefined
+ ? source.height
+ : Math.max(1, Math.round((presentationWidth * source.height) / source.width))
+ : boxDimension(presentationHeight, 'height')
+ const widths = Array.isArray(props.widths) ? [...props.widths] : props.widths
+ const base = { source, width, height, widths }
+ if (layout === 'none') return base
+ if (layout === 'constrained') {
+ // Explicit display dimensions were validated above; an original may exceed the CDN output cap.
+ const maxWidth = Math.min(width, source.width)
+ return {
+ ...base,
+ width: maxWidth,
+ height: Math.max(1, Math.round((maxWidth * source.height) / source.width)),
+ maximumWidth: widths === undefined ? 2 * maxWidth : undefined,
+ sizes: `(min-width: ${maxWidth}px) ${maxWidth}px, 100vw`,
+ style: { display: 'block', maxWidth, width: '100%', height: 'auto' },
+ }
+ }
+ const fit = props.fit ?? 'contain'
+ if (fit !== 'contain' && fit !== 'cover') throw new TypeError('fit must be contain or cover')
+ if (layout === 'fixed') {
+ const width = boxDimension(presentationWidth, 'width')
+ const height = boxDimension(presentationHeight, 'height')
+ return {
+ ...base,
+ width,
+ height,
+ cropAspectRatio: fit === 'cover' ? width / height : undefined,
+ fallbackWidth: width,
+ sizes: `${width}px`,
+ style: { display: 'block', height, width, objectFit: fit },
+ widths: widths ?? [width, Math.min(8000, 2 * width)],
+ }
+ }
+ if (layout === 'fill') {
+ const aspectRatio = props.aspectRatio
+ const breakpoints =
+ typeof aspectRatio === 'object' && aspectRatio !== null ? aspectRatio : undefined
+ if (
+ breakpoints !== undefined &&
+ (fit !== 'cover' || Array.isArray(breakpoints) || Object.keys(breakpoints).length > 9)
+ ) {
+ throw new TypeError(
+ 'Art direction requires fill cover with default and up to eight width breakpoints',
+ )
+ }
+ const artDirection =
+ breakpoints === undefined
+ ? undefined
+ : Object.entries(breakpoints)
+ .filter(([media]) => media !== 'default')
+ .map(([media, ratio]) => {
+ if (!/^\((?:min|max)-width:\s*\d+(?:\.\d+)?(?:px|em|rem)\)$/.test(media))
+ throw new TypeError(
+ 'Art direction keys must be width breakpoints, for example (max-width: 639px)',
+ )
+ return { media, cropAspectRatio: parseAspectRatio(ratio) }
+ })
+ const ratio =
+ aspectRatio === undefined && fit !== 'cover'
+ ? undefined
+ : parseAspectRatio(typeof aspectRatio === 'object' ? breakpoints?.default : aspectRatio)
+ return {
+ ...base,
+ cropAspectRatio: fit === 'cover' ? ratio : undefined,
+ artDirection,
+ frame:
+ props.frame === false || ratio === undefined
+ ? undefined
+ : { ratio, variants: artDirection ?? [] },
+ style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: fit },
+ }
+ }
+ throw new TypeError('layout must be constrained, fixed, fill or none')
+}
diff --git a/packages/img/src/next/options.ts b/packages/img/src/next/options.ts
new file mode 100644
index 00000000..847b256e
--- /dev/null
+++ b/packages/img/src/next/options.ts
@@ -0,0 +1,14 @@
+import type { StorageImageDelivery } from './catalog.ts'
+
+/** Build-time overrides; application keys always stay in the server environment. */
+export interface StorageImageProjectOptions {
+ /** Development-only path for explaining an authorizer added after config evaluation. */
+ authorizePath?: string
+ basePath?: string
+ delivery?: StorageImageDelivery
+ /** Development-only identity for deduplicated policy-change notices across hot reloads. */
+ diagnosticsId?: string
+}
+
+const options: StorageImageProjectOptions = {}
+export default options
diff --git a/packages/img/src/next/pathHints.ts b/packages/img/src/next/pathHints.ts
new file mode 100644
index 00000000..df00283d
--- /dev/null
+++ b/packages/img/src/next/pathHints.ts
@@ -0,0 +1,61 @@
+import { validateStoragePath } from '@transloadit/utils'
+
+function quoteArgument(value: string): string {
+ // Copyable POSIX commands must not expand a path containing quotes or shell expressions.
+ return /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`
+}
+
+function editDistance(left: string, right: string, limit: number): number {
+ if (Math.abs(left.length - right.length) > limit) return limit + 1
+ let row = Array.from({ length: right.length + 1 }, (_, index) => index)
+ for (let i = 1; i <= left.length; i++) {
+ const next = [i]
+ for (let j = 1; j <= right.length; j++) {
+ next[j] = Math.min(
+ next[j - 1] + 1,
+ row[j] + 1,
+ row[j - 1] + Number(left[i - 1] !== right[j - 1]),
+ )
+ }
+ if (Math.min(...next) > limit) return limit + 1
+ row = next
+ }
+ return row[right.length]
+}
+
+/** Actionable catalog errors; suggestions are bounded to short paths and small spelling errors. */
+export function missingImageHint(path: string, paths: readonly string[]): string {
+ // Shell quoting cannot neutralize terminal control characters or bound an oversized log line.
+ try {
+ validateStoragePath(path)
+ } catch (error) {
+ // A formatting mistake can name an exact existing key without becoming upload advice.
+ const corrected = path.length <= 256 ? path.trim().replace(/^\/+/, '') : undefined
+ if (corrected !== undefined && paths.includes(corrected))
+ return `Storage image path ${JSON.stringify(path)} is invalid. Did you mean ${JSON.stringify(corrected)}? Use the exact catalog key; no upload is needed.`
+ throw error
+ }
+ let nearest: string | undefined
+ let distance = 4
+ if (path.length <= 256) {
+ for (const candidate of paths) {
+ if (candidate.length > 256) continue
+ const score = editDistance(path, candidate, distance - 1)
+ if (score >= distance) continue
+ nearest = candidate
+ distance = score
+ }
+ }
+ const suggestion = nearest === undefined ? '' : ` Did you mean ${JSON.stringify(nearest)}?`
+ return `Storage image path ${JSON.stringify(path)} is not in the configured catalog.${suggestion} For a custom catalog, add --receipts to the command. For an explicit factory, update its images configuration too. To upload a new image, run:\n npx transloadit storage store -- ./image.jpg ${quoteArgument(path)}`
+}
+
+/** Publication is an explicit choice, never an automatic remedy for a denied private image. */
+export function publishImageHint(
+ path: string,
+ prefix = path.slice(0, path.lastIndexOf('/') + 1),
+): string {
+ if (prefix === '')
+ return 'If it should be public, store it in a directory and publish that prefix; workspace-root publication is not supported.'
+ return `For a custom catalog, add --receipts to the command. Keep an explicit factory’s public list in sync too. If it should be public, run:\n npx transloadit storage publish -- ${quoteArgument(prefix)}`
+}
diff --git a/packages/img/src/next/project.ts b/packages/img/src/next/project.ts
new file mode 100644
index 00000000..2c6fa813
--- /dev/null
+++ b/packages/img/src/next/project.ts
@@ -0,0 +1,36 @@
+import 'server-only'
+
+import type { StorageImageCatalog } from './layout.ts'
+import type { TransloaditImageIntegration, TransloaditRedirectImageIntegration } from './server.tsx'
+
+import { authorize } from '@transloadit/img/next/authorize'
+import catalog from '@transloadit/img/next/catalog'
+import options from '@transloadit/img/next/options'
+
+import { diagnosePublicPolicy } from './diagnostics.ts'
+import { createStorageImages } from './server.tsx'
+
+type ProjectIntegration =
+ | TransloaditImageIntegration
+ | TransloaditRedirectImageIntegration
+let integration: ProjectIntegration | undefined
+
+/** One integration per bundled project; credentials retain the factory's lazy server-only lookup. */
+export function getProjectImages(): ProjectIntegration {
+ if (catalog === undefined)
+ throw new Error(
+ 'Add withTransloaditImages() from @transloadit/img/next/config to next.config.ts, or use createStorageImages with an explicit catalog.',
+ )
+ if (integration !== undefined) return integration
+ diagnosePublicPolicy(catalog, options.diagnosticsId)
+ integration = createStorageImages({
+ ...catalog,
+ ...catalog.delivery,
+ ...options.delivery,
+ // The catalog's delivery object is transport, not the factory's private direct-delivery mode.
+ delivery: undefined,
+ authorize,
+ ...(authorize === undefined ? {} : { basePath: options.basePath }),
+ })
+ return integration
+}
diff --git a/packages/img/src/next/react-server.tsx b/packages/img/src/next/react-server.tsx
new file mode 100644
index 00000000..362b55f6
--- /dev/null
+++ b/packages/img/src/next/react-server.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react'
+
+import type { StorageImageProps } from './index.tsx'
+
+import { statSync } from 'node:fs'
+
+import options from '@transloadit/img/next/options'
+
+import { getProjectImages } from './project.ts'
+
+export type {
+ RegisteredStorageImages,
+ StorageImageProps,
+ TransloaditImageLayoutProps,
+ TransloaditImagePresentationProps,
+ TransloaditPictureProps,
+} from './index.tsx'
+
+export { TransloaditPicture } from './index.tsx'
+
+/** Render a project catalog image directly from Smart CDN, or through an authorized redirect. */
+export function StorageImage(props: StorageImageProps): ReactNode {
+ try {
+ return getProjectImages().StorageImage(props)
+ } catch (error) {
+ // Config discovers aliases once. Diagnose a late file, but never load or trust it at runtime.
+ if (
+ process.env.NODE_ENV === 'development' &&
+ error instanceof TypeError &&
+ error.message === "Private images require authorize or delivery: 'direct'" &&
+ options.authorizePath !== undefined &&
+ statSync(options.authorizePath, { throwIfNoEntry: false })?.isFile()
+ )
+ throw new TypeError(
+ 'transloadit.authorize.ts exists but was added after next dev started. Restart next dev to bundle it.',
+ { cause: error },
+ )
+ throw error
+ }
+}
diff --git a/packages/img/src/next/route.ts b/packages/img/src/next/route.ts
new file mode 100644
index 00000000..6a881773
--- /dev/null
+++ b/packages/img/src/next/route.ts
@@ -0,0 +1,13 @@
+import 'server-only'
+
+import { getProjectImages } from './project.ts'
+
+/** Re-export at app/api/storage-images/route.ts; private requests always consult authorize. */
+export async function GET(request: Request): Promise {
+ const integration = getProjectImages()
+ if (!('storageRoute' in integration) || typeof integration.storageRoute !== 'function')
+ return new Response(null, { status: 404, headers: { 'Cache-Control': 'no-store' } })
+ return await integration.storageRoute(request)
+}
+
+export { GET as HEAD }
diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx
index 35b27329..092454be 100644
--- a/packages/img/src/next/server.tsx
+++ b/packages/img/src/next/server.tsx
@@ -8,22 +8,35 @@ import type {
StoragePreviewFormats,
TransloaditImageModel,
} from '../index.ts'
-import type { TransloaditImagePresentationProps } from './index.tsx'
+import type { DiagnoseStorageImage } from './diagnostics.ts'
+import type { TransloaditImageLayoutProps, TransloaditImagePresentationProps } from './index.tsx'
+import type { StorageImageCatalog, StorageImageLayoutProps } from './layout.ts'
-import { hkdfSync } from 'node:crypto'
+import { createHash, hkdfSync } from 'node:crypto'
import { gcmsiv } from '@noble/ciphers/aes.js'
-import { getSignedSmartCdnUrl } from '@transloadit/utils/node'
+import { validateStoragePath, validateStoragePathPrefix } from '@transloadit/utils'
+import { getSignedSmartCdnUrl, getSmartCdnUrl } from '@transloadit/utils/node'
import { connection } from 'next/server.js'
-import { Suspense } from 'react'
+import { Suspense, use } from 'react'
+import { thumbHashToDataURL } from 'thumbhash'
-import { createTransloaditImageModel, transloaditStoragePreviewTemplate } from '../index.ts'
-import { validateStoragePath, validateStoragePathPrefix } from '../storagePath.ts'
+import { isOpaqueImageBackground, transparentImageBackground } from '../imageBackground.ts'
+import { snapshotImageSource } from '../imageSource.ts'
+import {
+ createTransloaditImageModel,
+ transloaditPublicStoragePreviewTemplate,
+ transloaditStoragePreviewTemplate,
+} from '../index.ts'
+import { createImageDiagnostics } from './diagnostics.ts'
+import { ImageSizeDiagnostics } from './ImageSizeDiagnostics.tsx'
+import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts'
import { TransloaditPicture } from './index.tsx'
+import { resolveImageLayout } from './layout.ts'
+import { publishImageHint } from './pathHints.ts'
const defaultStorageExpiresInMs = 60 * 60 * 1000
-const defaultStorageRotationIntervalMs = 5 * 60 * 1000
-const imagePolicyParams = new Set(['auth_key', 'exp', 'f', 'h', 'q', 'r', 'sig', 'w'])
+const imagePolicyParams = new Set(['auth_key', 'bg', 'exp', 'f', 'h', 'q', 'r', 'sig', 'v', 'w'])
const maximumImageDimension = 8000
const maximumStorageLifetimeMs = 48 * 60 * 60 * 1000
const storageCapabilityAuthenticationBytes = 16
@@ -39,7 +52,7 @@ export interface TransloaditStorageAuthorizationContext {
request: Request
}
-/** Application authorization for one exact private Storage object. */
+/** Return true to authorize one private object; thrown application errors propagate, not deny. */
export type AuthorizeTransloaditStorageImage = (
context: TransloaditStorageAuthorizationContext,
) => boolean | Promise
@@ -47,85 +60,130 @@ export type AuthorizeTransloaditStorageImage = (
/** Request-authorized, byte-pass-through-free Storage delivery through a local route. */
export interface TransloaditStorageRedirectDelivery {
authorize: AuthorizeTransloaditStorageImage
+ /** Server-declared public directories: use unsigned, direct CDN delivery. */
+ public?: readonly string[]
/** Next.js `basePath` prepended only to browser-facing route URLs. */
basePath?: string
+ /** Opt-in browser caching; capped at the rotation interval. Delays reauthorization. */
+ cacheMaxAgeMs?: number
/** Internal App Router path that exports `storageRoute`, for example `/api/private-images`. */
route: string
}
-/** Bounded request-time policy for private Storage previews. */
-export interface TransloaditStorageImageConfiguration {
- /** Authorized directory prefixes. Defaults to deny-all; an empty prefix explicitly allows all. */
+/** A maximum grant age, in milliseconds or an explicit duration such as "1h" or "365d". */
+export type StorageImageLifetime = number | `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
+
+interface StorageImageOptions {
+ /** Trusted key override; otherwise resolved from the server environment on first use. */
+ authKey?: string
+ /** Trusted secret override; otherwise resolved from the server environment on first use. */
+ authSecret?: string
+ /** Catalog/explicit workspace fallback; TRANSLOADIT_WORKSPACE overrides it on first use. */
+ workspace?: string
+ /** Catalog transport overrides, or direct signing inside a request-authorized page. */
+ delivery?: 'direct' | { baseUrl?: string; urlParams?: SmartCdnUrlParams }
+ /** Catalog keys become typed src references; values provide intrinsic geometry. */
+ images?: Catalog
+ /** Defaults to public and catalog directories, plus exact root-level catalog paths. */
allowedPathPrefixes?: readonly string[]
- /** Direct signed CDN URLs are the default; an object opts into authorized redirect delivery. */
- delivery?: 'direct' | TransloaditStorageRedirectDelivery
- /** Minimum lifetime of each CDN signature. Defaults to one hour. */
- expiresInMs?: number
- /** Stable CDN-signature rotation bucket. Defaults to five minutes. */
+ /** Explicitly allow every object in the workspace, including root-level paths. */
+ allowWorkspaceRoot?: boolean
+ /** Server-declared public directories. These render unsigned URLs without keys or expiry. */
+ public?: readonly string[]
+ /** Opt into a redirect handler; private objects require this check for every uncached request. */
+ authorize?: AuthorizeTransloaditStorageImage
+ /** Defaults to /api/storage-images when authorize is provided. */
+ route?: string
+ basePath?: string
+ /** Opt-in private browser caching, capped at rotation and remaining grant age. */
+ cacheMaxAge?: StorageImageLifetime
+ /** @deprecated Use cacheMaxAge with a duration such as '1m'. */
+ cacheMaxAgeMs?: number
+ /** Maximum private CDN grant age, at most 48h; defaults to 1h. Public URLs never expire. */
+ lifetime?: StorageImageLifetime
+ /** Stable signature bucket, at most half the private lifetime. Defaults to min(lifetime / 2, one hour). */
+ rotationInterval?: StorageImageLifetime
+ /** @deprecated Use rotationInterval with a duration such as '30m'. */
rotationIntervalMs?: number
-}
-
-/** Server-only credentials and trusted Smart CDN configuration. */
-export interface TransloaditImageConfiguration {
- authKey: string
- authSecret: string
/** Trusted development endpoint override; never derive this from request data. */
baseUrl?: string
- storage: TransloaditStorageImageConfiguration
/** Trusted compatible signed Template override for Storage previews. */
template?: string
+ /** Trusted unsigned public Template override; independent of the private Template. */
+ publicTemplate?: string
/** Trusted transport parameters appended to every signed URL, such as `cdn=required`. */
urlParams?: SmartCdnUrlParams
- workspace: string
}
-/** Configuration that opts into a request-authorized Storage route. */
-export interface TransloaditRedirectImageConfiguration extends TransloaditImageConfiguration {
- storage: TransloaditStorageImageConfiguration & {
- delivery: TransloaditStorageRedirectDelivery
- }
-}
+/** One flat policy with either a catalog, explicit prefixes (including deny-all []), or root access. */
+export type StorageImagesConfiguration<
+ Catalog extends StorageImageCatalog | undefined = undefined,
+> = StorageImageOptions &
+ (
+ | { images: Catalog extends undefined ? never : Catalog }
+ | { allowedPathPrefixes: readonly string[] }
+ | { allowWorkspaceRoot: true }
+ )
-interface CommonTransloaditImageProps extends TransloaditImagePresentationProps {
- /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */
- widths?: readonly number[]
+/** Request-authorized private delivery with the same flat configuration as direct delivery. */
+export type PrivateStorageImagesConfiguration<
+ Catalog extends StorageImageCatalog | undefined = undefined,
+> = StorageImagesConfiguration & {
+ authorize: AuthorizeTransloaditStorageImage
}
-/** Props for a private Transloadit Storage preview. */
-export interface TransloaditImageProps
- extends Omit {
- /** Encoding quality for the signed JPEG fallback. Defaults to 75. */
- fallbackQuality?: number
- formats?: StoragePreviewFormats
- media?: never
- mediaPlaceholderSrc?: never
- /** Relative object path inside the configured Transloadit Storage workspace. */
- src: string
- /** Static shell used only while direct request-time signing is suspended. */
- suspenseFallback?: ReactNode
-}
+/** Props for a Transloadit Storage preview, optionally typed from a rendering catalog. */
+export type TransloaditImageProps =
+ TransloaditImageLayoutProps &
+ StorageImageLayoutProps & {
+ /** Opaque JPEG background as #rrggbb or #rrggbbff. Defaults to white. */
+ fallbackBackground?: string
+ /** Encoding quality for the signed JPEG fallback. Defaults to 75. */
+ fallbackQuality?: number
+ formats?: StoragePreviewFormats
+ /** Opt-in receipt blur; request-authorized private redirects do not expose placeholder pixels. */
+ placeholder?: 'blur' | 'empty'
+ /** Static shell used only while direct request-time signing is suspended. */
+ suspenseFallback?: ReactNode
+ /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */
+ widths?: readonly number[]
+ }
+
+/** Redirect images render synchronously and have no signing suspension to replace. */
+export type TransloaditRedirectImageProps<
+ Catalog extends StorageImageCatalog | undefined = undefined,
+> = TransloaditImageProps & { suspenseFallback?: never }
/** One configured Next.js Server Component for Transloadit Storage objects. */
-export type TransloaditImageComponent = (props: TransloaditImageProps) => ReactNode
+export type TransloaditImageComponent =
+ (props: TransloaditImageProps) => ReactNode
/** A Next.js route handler that authorizes and redirects one private image request. */
export type TransloaditStorageRoute = (request: Request) => Promise
/** Direct-delivery integration. Image bytes and requests bypass the Next.js server. */
-export interface TransloaditImageIntegration {
- Image: TransloaditImageComponent
+export interface TransloaditImageIntegration<
+ Catalog extends StorageImageCatalog | undefined = undefined,
+> {
+ StorageImage: TransloaditImageComponent
}
/** Redirect-delivery integration with a route handler for private Storage images. */
-export interface TransloaditRedirectImageIntegration extends TransloaditImageIntegration {
+export interface TransloaditRedirectImageIntegration<
+ Catalog extends StorageImageCatalog | undefined = undefined,
+> {
+ StorageImage: (props: TransloaditRedirectImageProps) => ReactNode
storageRoute: TransloaditStorageRoute
}
interface ResolvedStoragePolicy {
allowedPathPrefixes: readonly string[]
+ allowedPaths: ReadonlySet
delivery: 'direct' | TransloaditStorageRedirectDelivery
- expiresInMs: number
- rotationIntervalMs: number
+ images?: StorageImageCatalog
+ lifetime?: number
+ public: readonly string[]
+ rotationIntervalMs?: number
}
interface ResolvedStorageCapabilityPolicy {
@@ -135,18 +193,59 @@ interface ResolvedStorageCapabilityPolicy {
}
interface StorageImageTransform {
+ background?: string
format: 'avif' | 'jpg' | 'png' | 'webp'
height: number
path: string
quality: number
+ strategy?: 'fillcrop'
width: number
}
interface TransloaditStorageImageRequestProps {
- props: TransloaditImageProps
+ props: ResolvedStorageImageProps
+}
+
+type ResolvedStorageImageProps = TransloaditImagePresentationProps & {
+ source: ReturnType['source']
+ cropAspectRatio?: number
+ artDirection?: ReturnType['artDirection']
+ frame?: ReturnType['frame']
+ diagnoseSize?: boolean
+ fallbackWidth?: number
+ maximumWidth?: number
+ fallbackBackground?: string
+ fallbackQuality?: number
+ formats?: StoragePreviewFormats
+ placeholder?: 'blur' | 'empty'
+ suspenseFallback?: ReactNode
+ widths?: readonly number[]
+}
+
+function StorageImagePlaceholder({ props }: TransloaditStorageImageRequestProps): ReactNode {
+ const attributes = snapshotImageAttributes(props)
+ // Streaming briefly keeps both elements in the DOM; identity and accessibility belong to the
+ // resolved image, not to the decorative shell that React will remove.
+ const placeholderAttributes = Object.fromEntries(
+ Object.entries(attributes).filter(([name]) => name !== 'id' && !name.startsWith('aria-')),
+ )
+ return (
+
+
+
+
+
+ )
}
-function validateRequiredConfiguration(value: string, name: string): void {
+function validateRequiredConfiguration(value: unknown, name: string): asserts value is string {
if (typeof value !== 'string' || value === '' || value.trim() !== value) {
throw new TypeError(`${name} must be a non-empty string without surrounding whitespace`)
}
@@ -197,7 +296,7 @@ function validateGlobalUrlParams(urlParams: SmartCdnUrlParams | undefined): void
}
function validateStorageRoute(route: string): void {
- const error = new TypeError('storage.delivery.route must be one absolute application path')
+ const error = new TypeError('route must be one absolute application path')
if (
typeof route !== 'string' ||
!route.startsWith('/') ||
@@ -212,9 +311,7 @@ function validateStorageRoute(route: string): void {
function validateStorageBasePath(basePath: string | undefined): void {
if (basePath === undefined) return
- const error = new TypeError(
- 'storage.delivery.basePath must be one absolute path without a trailing slash',
- )
+ const error = new TypeError('basePath must be one absolute path without a trailing slash')
if (
typeof basePath !== 'string' ||
basePath === '' ||
@@ -246,59 +343,186 @@ function matchesStorageRoute(path: string, delivery: TransloaditStorageRedirectD
)
}
+function parseLifetime(
+ lifetime: StorageImageLifetime | undefined,
+ name = 'lifetime',
+): number | undefined {
+ if (lifetime === undefined) return undefined
+ const units = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
+ if (typeof lifetime === 'number') {
+ validateDuration(lifetime, name)
+ return lifetime
+ }
+ if (typeof lifetime !== 'string')
+ throw new TypeError(`${name} must be milliseconds or a duration such as "1h"`)
+ const parts = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(lifetime)
+ const unit = parts?.[2]
+ if (parts === null || unit === undefined || !(unit in units))
+ throw new TypeError(`${name} must be milliseconds or a duration such as "1h"`)
+ const multiplier = Object.entries(units).find(([name]) => name === unit)?.[1]
+ if (multiplier === undefined) throw new TypeError('Unsupported lifetime unit')
+ const duration = Number(parts[1]) * multiplier
+ validateDuration(duration, name)
+ return duration
+}
+
+function validatePrefixes(prefixes: readonly string[], name: string): readonly string[] {
+ if (!Array.isArray(prefixes)) throw new TypeError(`${name} must be an array of explicit prefixes`)
+ for (const [index, prefix] of prefixes.entries()) {
+ validateStoragePathPrefix(prefix, index, name)
+ if (prefix === '')
+ throw new TypeError(
+ `${name} cannot contain an empty prefix; use allowWorkspaceRoot: true for workspace-wide access`,
+ )
+ }
+ return [...new Set(prefixes)]
+}
+
function getStoragePolicy(
- configuration: TransloaditStorageImageConfiguration,
+ configuration: StorageImagesConfiguration,
): ResolvedStoragePolicy {
- const allowedPathPrefixes = configuration.allowedPathPrefixes ?? []
- const delivery = configuration.delivery ?? 'direct'
- const expiresInMs = configuration.expiresInMs ?? defaultStorageExpiresInMs
- const rotationIntervalMs = configuration.rotationIntervalMs ?? defaultStorageRotationIntervalMs
- if (!Array.isArray(allowedPathPrefixes)) {
- throw new TypeError('storage.allowedPathPrefixes must be an array')
- }
- const validatedPathPrefixes = new Set()
- for (const [index, prefix] of allowedPathPrefixes.entries()) {
- validateStoragePathPrefix(prefix, index)
- validatedPathPrefixes.add(prefix)
- }
- validateDuration(expiresInMs, 'storage.expiresInMs')
- validateDuration(rotationIntervalMs, 'storage.rotationIntervalMs')
- if (expiresInMs + rotationIntervalMs > maximumStorageLifetimeMs) {
- throw new RangeError('Storage image expiry plus its rotation interval must not exceed 48 hours')
- }
- if (delivery !== 'direct') {
- if (typeof delivery !== 'object' || delivery === null || Array.isArray(delivery)) {
- throw new TypeError('storage.delivery must be direct or a redirect configuration')
+ if (typeof configuration !== 'object' || configuration === null || Array.isArray(configuration)) {
+ throw new TypeError('Storage images require an explicit configuration object')
+ }
+ const catalog = configuration.images
+ let images: StorageImageCatalog | undefined
+ if (catalog !== undefined) {
+ if (!isRecord(catalog)) throw new TypeError('images must be a rendering catalog')
+ images = Object.fromEntries(
+ Object.entries(catalog).map(([path, source]) => {
+ const receipt = snapshotImageSource({ src: source })
+ if (path !== receipt.path)
+ throw new TypeError('Each catalog key must equal its receipt path')
+ return [path, receipt]
+ }),
+ )
+ }
+ if (
+ configuration.allowWorkspaceRoot !== undefined &&
+ typeof configuration.allowWorkspaceRoot !== 'boolean'
+ )
+ throw new TypeError('allowWorkspaceRoot must be a boolean')
+ if (
+ configuration.allowedPathPrefixes === undefined &&
+ images === undefined &&
+ configuration.allowWorkspaceRoot !== true
+ )
+ throw new TypeError('images, allowedPathPrefixes or allowWorkspaceRoot: true is required')
+ const inferredPrefixes = new Set()
+ const allowedPaths = new Set()
+ if (configuration.allowedPathPrefixes === undefined) {
+ for (const path of Object.keys(images ?? {})) {
+ const separator = path.lastIndexOf('/')
+ if (separator === -1) allowedPaths.add(path)
+ else inferredPrefixes.add(path.slice(0, separator + 1))
}
- validateStorageRoute(delivery.route)
- validateStorageBasePath(delivery.basePath)
- if (typeof delivery.authorize !== 'function') {
- throw new TypeError('storage.delivery.authorize must be a function')
+ }
+ const publicPrefixes = validatePrefixes(configuration.public ?? [], 'public')
+ const allowedPathPrefixes = validatePrefixes(
+ configuration.allowedPathPrefixes ?? [...inferredPrefixes, ...publicPrefixes],
+ 'allowedPathPrefixes',
+ )
+ const resolvedPrefixes = configuration.allowWorkspaceRoot === true ? [''] : allowedPathPrefixes
+ for (const prefix of publicPrefixes) {
+ if (new TextEncoder().encode(prefix).byteLength > 512)
+ throw new TypeError('public prefixes must not exceed 512 UTF-8 bytes')
+ if (!resolvedPrefixes.some((allowed) => prefix.startsWith(allowed)))
+ throw new TypeError('public prefixes must be within allowedPathPrefixes')
+ }
+ if (
+ configuration.authorize === undefined &&
+ publicPrefixes.length === 0 &&
+ configuration.delivery !== 'direct'
+ )
+ throw new TypeError(
+ "No public prefixes are configured. Publish a directory with storage publish only if it should be public; otherwise configure private authorization. Choose public, authorize, or delivery: 'direct' for Storage images.",
+ )
+ if (configuration.delivery !== undefined && configuration.delivery !== 'direct')
+ throw new TypeError("delivery must be 'direct'; provide authorize to enable redirects")
+ const lifetime = parseLifetime(configuration.lifetime)
+ if (lifetime !== undefined && lifetime > maximumStorageLifetimeMs)
+ throw new RangeError(
+ 'Private Storage image lifetime must not exceed 48 hours; public URLs do not use lifetime',
+ )
+ if (
+ configuration.rotationInterval !== undefined &&
+ configuration.rotationIntervalMs !== undefined
+ )
+ throw new TypeError('Use rotationInterval or rotationIntervalMs, not both')
+ if (configuration.cacheMaxAge !== undefined && configuration.cacheMaxAgeMs !== undefined)
+ throw new TypeError('Use cacheMaxAge or cacheMaxAgeMs, not both')
+ const rotationIntervalMs = parseLifetime(
+ configuration.rotationInterval ?? configuration.rotationIntervalMs,
+ 'rotationInterval',
+ )
+ const cacheMaxAgeMs = parseLifetime(
+ configuration.cacheMaxAge ?? configuration.cacheMaxAgeMs,
+ 'cacheMaxAge',
+ )
+ if (rotationIntervalMs !== undefined) {
+ validateDuration(rotationIntervalMs, 'rotationIntervalMs')
+ if (
+ rotationIntervalMs >
+ Math.min(lifetime ?? defaultStorageExpiresInMs, maximumStorageLifetimeMs) / 2
+ )
+ throw new RangeError(
+ 'rotationIntervalMs must not exceed half the private lifetime (capped at 48 hours)',
+ )
+ }
+ const basePath = configuration.authorize !== undefined ? configuration.basePath : undefined
+ let delivery: ResolvedStoragePolicy['delivery'] = 'direct'
+ if (configuration.authorize !== undefined) {
+ // An authorizer overrides a shared direct-delivery default; private access stays gated.
+ const route = configuration.route ?? '/api/storage-images'
+ validateStorageRoute(route)
+ validateStorageBasePath(basePath)
+ if (typeof configuration.authorize !== 'function') {
+ throw new TypeError('authorize must be a function')
+ }
+ delivery = {
+ authorize: configuration.authorize,
+ basePath,
+ cacheMaxAgeMs,
+ public: publicPrefixes,
+ route,
}
+ } else if (
+ configuration.route !== undefined ||
+ configuration.basePath !== undefined ||
+ cacheMaxAgeMs !== undefined
+ ) {
+ throw new TypeError('route, basePath and cacheMaxAgeMs require an authorize function')
}
return {
- allowedPathPrefixes: [...validatedPathPrefixes],
- delivery:
- delivery === 'direct'
- ? delivery
- : {
- authorize: delivery.authorize,
- basePath: delivery.basePath,
- route: delivery.route,
- },
- expiresInMs,
+ allowedPathPrefixes: resolvedPrefixes,
+ allowedPaths,
+ delivery,
+ images,
+ lifetime,
+ public: publicPrefixes,
rotationIntervalMs,
}
}
+function getGrantPolicy(policy: ResolvedStoragePolicy): { lifetime: number; rotation: number } {
+ const lifetime = policy.lifetime ?? defaultStorageExpiresInMs
+ const rotation =
+ policy.rotationIntervalMs ??
+ Math.max(1, Math.min(Math.floor(lifetime / 2), defaultStorageExpiresInMs))
+ return { lifetime, rotation }
+}
+
function getStorageExpiresAt(now: number, policy: ResolvedStoragePolicy): number {
- const nextRotation = (Math.floor(now / policy.rotationIntervalMs) + 1) * policy.rotationIntervalMs
- return nextRotation + policy.expiresInMs
+ const { lifetime, rotation } = getGrantPolicy(policy)
+ return Math.floor(now / rotation) * rotation + lifetime
}
function assertAllowedStoragePath(path: string, policy: ResolvedStoragePolicy): void {
validateStoragePath(path)
- if (!policy.allowedPathPrefixes.some((prefix) => path.startsWith(prefix))) {
+ if (
+ !policy.allowedPaths.has(path) &&
+ !policy.allowedPathPrefixes.some((prefix) => path.startsWith(prefix))
+ ) {
throw new TypeError('Storage image path is outside the configured allowed prefixes')
}
}
@@ -314,73 +538,193 @@ function snapshotUrlParams(
return snapshot
}
+function previewUrlParams(template: string, parameters: SmartCdnUrlParams): SmartCdnUrlParams {
+ // These exact versions share API2's defaults. Customer templates (and future Built-ins) may not.
+ if (template !== 'builtin/storage-preview@0.0.2' && template !== 'builtin/public-preview@0.0.1')
+ return parameters
+ const defaults: Readonly> = {
+ bg: '#ffffff',
+ f: 'jpg',
+ q: 75,
+ r: 'pad',
+ }
+ return Object.fromEntries(
+ Object.entries(parameters).filter(
+ ([name, value]) => !Object.hasOwn(defaults, name) || defaults[name] !== value,
+ ),
+ )
+}
+
function snapshotStorageImageProps(
- props: TransloaditImageProps,
- path: string,
-): TransloaditImageProps {
+ props: TransloaditImageProps,
+ layout: ReturnType,
+): ResolvedStorageImageProps {
+ const attributes = snapshotImageAttributes(props)
+ const loading = snapshotImageLoading(props)
+ const lazy = loading.loading !== 'eager' && loading.preload !== true
return {
- alt: props.alt,
- className: props.className,
- deferUntilHydrated: props.deferUntilHydrated,
+ ...attributes,
+ ...loading,
+ artDirection: layout.artDirection,
+ frame: layout.frame,
+ diagnoseSize: process.env.NODE_ENV === 'development' && props.sizes === undefined,
+ cropAspectRatio: layout.cropAspectRatio,
+ errorFallback: props.errorFallback,
+ retryKey: props.retryKey,
+ fallbackBackground: props.fallbackBackground,
fallbackQuality: props.fallbackQuality,
- fetchPriority: props.fetchPriority,
+ fallbackWidth: layout.fallbackWidth,
formats: props.formats === undefined ? undefined : { ...props.formats },
- height: props.height,
- loading: props.loading,
+ height: layout.height,
+ maximumWidth: layout.maximumWidth,
objectFit: props.objectFit,
- preload: props.preload,
- sizes: props.sizes,
- src: path,
- style: props.style === undefined ? undefined : { ...props.style },
+ placeholder: props.placeholder,
+ source: layout.source,
+ sizes:
+ attributes.sizes ??
+ ((props.layout === undefined || props.layout === 'constrained') &&
+ lazy &&
+ layout.sizes !== undefined
+ ? `auto, ${layout.sizes}`
+ : layout.sizes),
+ style: { ...layout.style, ...attributes.style },
suspenseFallback: props.suspenseFallback,
- width: props.width,
- widths: Array.isArray(props.widths) ? [...props.widths] : props.widths,
+ width: layout.width,
+ widths: layout.widths,
}
}
-function getStoragePath(src: unknown): string {
- if (typeof src !== 'string') {
- throw new TypeError('Storage image src must be one relative object path')
- }
- return src
+interface DevelopmentDeliveryResultProps {
+ result: Promise
+}
+
+function DevelopmentDeliveryResult({ result }: DevelopmentDeliveryResultProps): ReactNode {
+ return {use(result)}. See the terminal for details.
}
function renderPicture(
- props: CommonTransloaditImageProps,
+ props: ResolvedStorageImageProps,
model: Parameters[0]['model'],
+ diagnostic?: Promise,
+ inlinePixels = false,
): ReactNode {
+ let blurDataURL: string | undefined
+ if (props.placeholder === 'blur') {
+ const hash = props.source.thumbhash
+ // Receipt metadata can be hand-edited. Bound decoding and reject malformed base64/geometry.
+ const bytes =
+ typeof hash === 'string' && hash.length <= 48 && /^[A-Za-z0-9+/]+={0,2}$/.test(hash)
+ ? Buffer.from(hash, 'base64')
+ : undefined
+ // Preserve transparency even if a hand-edited receipt lost its original alpha metadata.
+ const hasAlpha = props.source.hasAlpha === true || ((bytes?.[2] ?? 0) & 0x80) !== 0
+ if (
+ inlinePixels &&
+ !hasAlpha &&
+ bytes !== undefined &&
+ bytes.length >= 17 &&
+ bytes.length <= 25 &&
+ bytes.toString('base64') === hash &&
+ ((bytes[3] ?? 0) & 7) > 0
+ ) {
+ blurDataURL = thumbHashToDataURL(bytes)
+ } else if (process.env.NODE_ENV === 'development') {
+ console.warn(
+ !inlinePixels
+ ? `[StorageImage] ${JSON.stringify(props.source.path)} uses request-authorized private delivery; placeholder="blur" is a no-op so its pixels are not exposed before authorization.`
+ : hasAlpha
+ ? `[StorageImage] ${JSON.stringify(props.source.path)}: transparent image: no blur placeholder.`
+ : `[StorageImage] ${JSON.stringify(props.source.path)} has no usable thumbhash; placeholder="blur" is a no-op. Use storage store with the original bytes to generate it.`,
+ )
+ }
+ }
+ const errorFallback =
+ props.errorFallback === undefined || process.env.NODE_ENV !== 'development' ? (
+ props.errorFallback
+ ) : (
+ <>
+ {props.errorFallback}
+ {diagnostic === undefined ? (
+ See the terminal for details.
+ ) : (
+ Checking delivery; see the terminal for details.}>
+
+
+ )}
+ >
+ )
+ const picture = (
+
+
+
+ )
+ return props.diagnoseSize ? {picture} : picture
+}
+
+interface StorageImageFrameProps {
+ props: ResolvedStorageImageProps
+ children: ReactNode
+}
+
+function StorageImageFrame({ props, children }: StorageImageFrameProps): ReactNode {
+ const frame = props.frame
+ if (frame === undefined) return children
+ // Values are validated numeric ratios/width queries, never arbitrary caller CSS. Reverse the
+ // rules so overlapping breakpoints follow picture's first-matching-source precedence.
+ const name = `tli-${createHash('sha256').update(JSON.stringify(frame)).digest('hex').slice(0, 16)}`
+ const selector = `.${name}`
+ const css = `${selector}{display:block;position:relative;width:100%;aspect-ratio:${frame.ratio}}${[
+ ...frame.variants,
+ ]
+ .reverse()
+ .map(
+ ({ media, cropAspectRatio }) =>
+ `@media ${media}{${selector}{aspect-ratio:${cropAspectRatio}}}`,
+ )
+ .join('')}`
return (
-
+ <>
+
+ {children}
+ >
)
}
-function getStorageTransform(request: SmartCdnImageSignRequest): StorageImageTransform {
- const { f: format, h: height, q: quality, r: strategy, w: width } = request.urlParams
+function getStorageTransform(
+ request: Omit,
+): StorageImageTransform {
+ const {
+ bg: background,
+ f: format,
+ h: height,
+ q: quality,
+ r: strategy,
+ w: width,
+ } = request.urlParams
if (
(format !== 'avif' && format !== 'jpg' && format !== 'png' && format !== 'webp') ||
+ typeof background !== 'string' ||
typeof height !== 'number' ||
typeof quality !== 'number' ||
- strategy !== 'pad' ||
+ (strategy !== 'pad' && strategy !== 'fillcrop') ||
typeof width !== 'number'
) {
throw new TypeError('Storage image model produced an unsupported transform')
}
- return { format, height, path: request.input, quality, width }
+ return {
+ background,
+ format,
+ height,
+ path: request.input,
+ quality,
+ width,
+ ...(strategy === 'fillcrop' ? { strategy } : {}),
+ }
}
function createStorageRouteKey(authSecret: string, workspace: string): Buffer {
@@ -389,13 +733,14 @@ function createStorageRouteKey(authSecret: string, workspace: string): Buffer {
function getStorageCapabilityContext(
delivery: TransloaditStorageRedirectDelivery,
- template: string,
+ customTemplate: string | undefined,
workspace: string,
): string {
return JSON.stringify([
storageRouteKeyDomain,
+ storageCapabilityVersion,
workspace,
- template,
+ customTemplate ?? null,
delivery.route,
getBrowserStorageRoute(delivery),
])
@@ -417,7 +762,7 @@ function getStorageRouteUrl(
context: string,
delivery: TransloaditStorageRedirectDelivery,
key: Buffer,
- request: SmartCdnImageSignRequest,
+ request: Omit,
): string {
const capability = encryptStorageCapability(context, key, getStorageTransform(request))
return `${getBrowserStorageRoute(delivery)}?${new URLSearchParams({ cap: capability })}`
@@ -433,9 +778,14 @@ function isStorageRouteFormat(value: unknown): value is StorageImageTransform['f
function getStorageTransformFromPayload(payload: unknown): StorageImageTransform | undefined {
if (!isRecord(payload) || payload.version !== storageCapabilityVersion) return undefined
- const { format, height, path, quality, width } = payload
+ const { background, format, height, path, quality, width, strategy } = payload
if (
!isStorageRouteFormat(format) ||
+ (background !== undefined &&
+ (format === 'jpg'
+ ? !isOpaqueImageBackground(background)
+ : background !== transparentImageBackground)) ||
+ (strategy !== undefined && strategy !== 'fillcrop') ||
typeof height !== 'number' ||
!Number.isInteger(height) ||
height < 1 ||
@@ -453,7 +803,15 @@ function getStorageTransformFromPayload(payload: unknown): StorageImageTransform
return undefined
}
validateStoragePath(path)
- return { format, height, path, quality, width }
+ return {
+ ...(typeof background === 'string' ? { background } : {}),
+ format,
+ height,
+ path,
+ quality,
+ width,
+ ...(strategy === 'fillcrop' ? { strategy } : {}),
+ }
}
function decryptStorageCapability(
@@ -495,13 +853,6 @@ function parseStorageRouteTransform(
return decryptStorageCapability(url.searchParams.get('cap'), context, key)
}
-function notFound(): Response {
- return new Response(null, {
- headers: { 'Cache-Control': 'private, no-store' },
- status: 404,
- })
-}
-
function createStorageRoute(
context: string,
delivery: TransloaditStorageRedirectDelivery,
@@ -509,7 +860,35 @@ function createStorageRoute(
policy: ResolvedStoragePolicy,
sign: (request: SmartCdnImageSignRequest) => string,
template: string,
+ diagnose: DiagnoseStorageImage | undefined,
+ buildPublicUrl: (
+ request: Omit,
+ md5hash?: string,
+ ) => string,
): TransloaditStorageRoute {
+ const reasons = {
+ route:
+ 'Redirect route/basePath differs from this handler. Check the route export and rebuild cached markup.',
+ capability:
+ 'Invalid or stale image capability. The signing secret, route/basePath, custom template or capability contract may have changed. Refresh cached markup.',
+ prefix:
+ 'The requested object is outside allowedPathPrefixes. Check the current signing policy.',
+ authorization:
+ 'Application authorization denied this image. Check the session and per-object access policy.',
+ }
+ const explained = new Set()
+ function notFound(reason: keyof typeof reasons, path?: string): Response {
+ const key = JSON.stringify([reason, path])
+ if (process.env.NODE_ENV === 'development' && !explained.has(key)) {
+ explained.add(key)
+ const publication =
+ reason === 'authorization' && path !== undefined
+ ? ` Storage path ${JSON.stringify(path)} is not under a public prefix in the current image configuration. ${publishImageHint(path)}`
+ : ''
+ console.warn(`[StorageImage] ${reasons[reason]}${publication}`)
+ }
+ return new Response(null, { headers: { 'Cache-Control': 'private, no-store' }, status: 404 })
+ }
return async function storageRoute(request: Request): Promise {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response(null, {
@@ -518,31 +897,66 @@ function createStorageRoute(
})
}
const url = new URL(request.url)
- if (!matchesStorageRoute(url.pathname, delivery)) return notFound()
+ if (!matchesStorageRoute(url.pathname, delivery)) return notFound('route')
const transform = parseStorageRouteTransform(url, context, key)
- if (transform === undefined) return notFound()
+ if (transform === undefined) return notFound('capability')
try {
assertAllowedStoragePath(transform.path, policy)
} catch {
- return notFound()
+ return notFound('prefix')
}
- if ((await delivery.authorize({ path: transform.path, request })) !== true) return notFound()
+ const path = transform.path
+ const isPublic = delivery.public?.some((prefix) => path.startsWith(prefix)) === true
+ if (!isPublic && (await delivery.authorize({ path: transform.path, request })) !== true)
+ return notFound('authorization', path)
- const location = sign({
- expiresAt: getStorageExpiresAt(Date.now(), policy),
+ const signRequest = {
input: transform.path,
template,
urlParams: {
+ ...(transform.background === undefined ? {} : { bg: transform.background }),
f: transform.format,
h: transform.height,
q: transform.quality,
- r: 'pad',
+ r: transform.strategy ?? 'pad',
w: transform.width,
},
- })
+ }
+ if (isPublic) {
+ const source =
+ policy.images !== undefined && Object.hasOwn(policy.images, path)
+ ? policy.images[path]
+ : undefined
+ const location = buildPublicUrl(signRequest, source?.md5hash)
+ diagnose?.(
+ path,
+ location,
+ policy.public.find((prefix) => path.startsWith(prefix)),
+ )
+ return new Response(null, {
+ status: 307,
+ headers: {
+ Location: location,
+ // Old private capabilities carry no receipt hash; bound stale Locations after overwrite.
+ // Newly rendered public images bypass this compatibility route with versioned CDN URLs.
+ 'Cache-Control': 'public, max-age=0, s-maxage=60',
+ 'Referrer-Policy': 'no-referrer',
+ },
+ })
+ }
+ const now = Date.now()
+ const expiresAt = getStorageExpiresAt(now, policy)
+ const { rotation } = getGrantPolicy(policy)
+ const cacheSeconds = Math.max(
+ 0,
+ Math.floor(Math.min(delivery.cacheMaxAgeMs ?? 0, rotation, expiresAt - now) / 1000),
+ )
+ const location = sign({ ...signRequest, expiresAt })
+ diagnose?.(path, location)
return new Response(null, {
headers: {
- 'Cache-Control': 'private, no-store',
+ 'Cache-Control':
+ cacheSeconds > 0 ? `private, max-age=${cacheSeconds}` : 'private, no-store',
Location: location,
'Referrer-Policy': 'no-referrer',
},
@@ -551,131 +965,265 @@ function createStorageRoute(
}
}
-/** Creates one credentialed Next.js image integration without reading application environment. */
-export function createTransloaditImage(
- configuration: TransloaditRedirectImageConfiguration,
-): TransloaditRedirectImageIntegration
-export function createTransloaditImage(
- configuration: TransloaditImageConfiguration,
-): TransloaditImageIntegration
-export function createTransloaditImage(
- configuration: TransloaditImageConfiguration,
-): TransloaditImageIntegration | TransloaditRedirectImageIntegration {
- const authKey = configuration.authKey
- const authSecret = configuration.authSecret
+function createImageIntegration(
+ configuration: StorageImagesConfiguration,
+ storagePolicy: ResolvedStoragePolicy,
+ getCredentials: () => { authKey: string; authSecret: string; workspace: string },
+ getWorkspace: () => string,
+): TransloaditImageIntegration | TransloaditRedirectImageIntegration {
const baseUrl = configuration.baseUrl
const storageTemplate = configuration.template ?? transloaditStoragePreviewTemplate
+ const customTemplate = configuration.template
const urlParams = snapshotUrlParams(configuration.urlParams)
- const workspace = configuration.workspace
- validateRequiredConfiguration(authKey, 'authKey')
- validateRequiredConfiguration(authSecret, 'authSecret')
- validateRequiredConfiguration(workspace, 'workspace')
validateBaseUrl(baseUrl)
validateTemplate(storageTemplate, 'template')
validateGlobalUrlParams(urlParams)
-
- const storagePolicy = getStoragePolicy(configuration.storage)
- // Redirect capabilities do not encode this value; keeping one factory snapshot makes their
- // prerendered markup deterministic while request-time CDN signatures rotate independently.
- const storageCapabilityModelExpiresAt = getStorageExpiresAt(Date.now(), storagePolicy)
+ const diagnose = createImageDiagnostics(storageTemplate)
+ const privateDirect = configuration.delivery === 'direct'
const sign = (request: SmartCdnImageSignRequest): string =>
getSignedSmartCdnUrl({
- authKey,
- authSecret,
+ ...getCredentials(),
baseUrl,
expiresAt: request.expiresAt,
input: request.input,
template: request.template,
- urlParams: { ...urlParams, ...request.urlParams },
- workspace,
+ urlParams: previewUrlParams(request.template, { ...urlParams, ...request.urlParams }),
})
- const storageCapability: ResolvedStorageCapabilityPolicy | undefined =
- storagePolicy.delivery === 'direct'
- ? undefined
- : {
- context: getStorageCapabilityContext(storagePolicy.delivery, storageTemplate, workspace),
- delivery: storagePolicy.delivery,
- key: createStorageRouteKey(authSecret, workspace),
- }
- const buildStorageUrl =
- storageCapability === undefined
- ? sign
- : (request: SmartCdnImageSignRequest): string =>
- getStorageRouteUrl(
- storageCapability.context,
- storageCapability.delivery,
- storageCapability.key,
- request,
- )
+ const publicTemplate = configuration.publicTemplate ?? transloaditPublicStoragePreviewTemplate
+ validateTemplate(publicTemplate, 'publicTemplate')
+ const buildPublicUrl = (
+ request: Omit,
+ md5hash?: string,
+ ): string =>
+ getSmartCdnUrl({
+ workspace: getWorkspace(),
+ baseUrl,
+ template: publicTemplate,
+ input: request.input,
+ urlParams: previewUrlParams(publicTemplate, {
+ ...urlParams,
+ ...request.urlParams,
+ ...(md5hash === undefined ? {} : { v: md5hash.slice(0, 16) }),
+ }),
+ })
+ const redirectDelivery = storagePolicy.delivery
+ let storageCapability: ResolvedStorageCapabilityPolicy | undefined
+ function getCapability(): ResolvedStorageCapabilityPolicy {
+ if (redirectDelivery === 'direct')
+ throw new Error('Direct images do not use route capabilities')
+ if (storageCapability === undefined) {
+ const { authSecret, workspace } = getCredentials()
+ storageCapability = {
+ context: getStorageCapabilityContext(redirectDelivery, customTemplate, workspace),
+ delivery: redirectDelivery,
+ key: createStorageRouteKey(authSecret, workspace),
+ }
+ }
+ return storageCapability
+ }
+ const buildStorageUrl = (request: Omit): string => {
+ const capability = getCapability()
+ return getStorageRouteUrl(capability.context, capability.delivery, capability.key, request)
+ }
- async function DirectStorageImage({
- props,
- }: TransloaditStorageImageRequestProps): Promise {
- await connection()
+ function createModel(
+ props: ResolvedStorageImageProps,
+ expiresAt: Expiry,
+ resolveUrl: (request: SmartCdnImageSignRequest) => string,
+ template = storageTemplate,
+ ): TransloaditImageModel {
const model = createTransloaditImageModel(
{
- expiresAt: getStorageExpiresAt(Date.now(), storagePolicy),
+ cropAspectRatio: props.cropAspectRatio,
+ expiresAt,
+ fallbackBackground: props.fallbackBackground,
fallbackQuality: props.fallbackQuality,
+ fallbackWidth: props.fallbackWidth,
formats: props.formats,
- height: props.height,
- src: props.src,
- template: storageTemplate,
- width: props.width,
+ maximumWidth: props.maximumWidth,
+ src: props.source,
+ template,
widths: props.widths,
},
- sign,
+ resolveUrl,
)
- return renderPicture(props, model)
+ if (props.artDirection === undefined || props.artDirection.length === 0) return model
+ return {
+ ...model,
+ artDirection: props.artDirection.map(({ media, cropAspectRatio }) => ({
+ media,
+ model: createModel(
+ { ...props, cropAspectRatio, artDirection: undefined },
+ expiresAt,
+ resolveUrl,
+ template,
+ ),
+ })),
+ }
}
- function Image(props: TransloaditImageProps): ReactNode {
- const storagePath = getStoragePath(props.src)
- if (props.media !== undefined) {
- throw new TypeError('Storage image previews do not support media conditions')
+ let explainedDirectDelivery = false
+ async function DirectStorageImage({
+ props,
+ }: TransloaditStorageImageRequestProps): Promise {
+ await connection()
+ if (process.env.NODE_ENV === 'development' && !explainedDirectDelivery) {
+ explainedDirectDelivery = true
+ console.info(
+ 'StorageImage (direct) makes this route dynamic; use redirect delivery for static pages',
+ )
}
- assertAllowedStoragePath(storagePath, storagePolicy)
- const storageProps = snapshotStorageImageProps(props, storagePath)
- if (storageCapability === undefined) {
+ const model = createModel(props, getStorageExpiresAt(Date.now(), storagePolicy), sign)
+ const diagnostic = diagnose?.(
+ props.source.path,
+ model.sources[0]?.candidates[0]?.url ?? model.fallbackUrl,
+ )
+ return renderPicture(props, model, diagnostic, true)
+ }
+
+ function StorageImage(props: TransloaditImageProps): ReactNode {
+ const layout = resolveImageLayout(props, storagePolicy.images)
+ assertAllowedStoragePath(layout.source.path, storagePolicy)
+ const storageProps = snapshotStorageImageProps(props, layout)
+ const publicPrefix = storagePolicy.public.find((prefix) =>
+ layout.source.path.startsWith(prefix),
+ )
+ if (publicPrefix !== undefined) {
+ const model = createModel(
+ storageProps,
+ undefined,
+ (request) => buildPublicUrl(request, layout.source.md5hash),
+ publicTemplate,
+ )
+ const diagnostic = diagnose?.(
+ layout.source.path,
+ model.sources[0]?.candidates[0]?.url ?? model.fallbackUrl,
+ publicPrefix,
+ )
+ return renderPicture(storageProps, model, diagnostic, true)
+ }
+ if (redirectDelivery === 'direct') {
+ if (!privateDirect)
+ throw new TypeError("Private images require authorize or delivery: 'direct'")
return (
-
+
+ ) : (
+ storageProps.suspenseFallback
+ )
+ }
+ >
)
}
- if (props.suspenseFallback !== undefined) {
+ if (storageProps.suspenseFallback !== undefined) {
throw new TypeError('suspenseFallback is only used by direct Storage delivery')
}
- const resolvedModel = createTransloaditImageModel(
- {
- expiresAt: storageCapabilityModelExpiresAt,
- fallbackQuality: props.fallbackQuality,
- formats: props.formats,
- height: props.height,
- src: storagePath,
- template: storageTemplate,
- width: props.width,
- widths: props.widths,
- },
- buildStorageUrl,
- )
+ const resolvedModel = createModel(storageProps, undefined, buildStorageUrl)
const model: TransloaditImageModel = {
+ artDirection: resolvedModel.artDirection,
fallbackUrl: resolvedModel.fallbackUrl,
sources: resolvedModel.sources,
}
return renderPicture(storageProps, model)
}
- const integration: TransloaditImageIntegration = { Image }
- if (storageCapability === undefined) return integration
+ const integration: TransloaditImageIntegration = { StorageImage }
+ if (redirectDelivery === 'direct') return integration
+ let route: TransloaditStorageRoute | undefined
return {
...integration,
- storageRoute: createStorageRoute(
- storageCapability.context,
- storageCapability.delivery,
- storageCapability.key,
- storagePolicy,
- sign,
- storageTemplate,
- ),
+ async storageRoute(request) {
+ if (route === undefined) {
+ const capability = getCapability()
+ route = createStorageRoute(
+ capability.context,
+ capability.delivery,
+ capability.key,
+ storagePolicy,
+ sign,
+ storageTemplate,
+ diagnose,
+ buildPublicUrl,
+ )
+ }
+ return await route(request)
+ },
}
}
+
+/** Reads rendering credentials once on first render/request, not while importing the factory. */
+export function createStorageImages(
+ configuration: PrivateStorageImagesConfiguration,
+): TransloaditRedirectImageIntegration
+export function createStorageImages(
+ configuration: StorageImagesConfiguration,
+): TransloaditImageIntegration
+export function createStorageImages(
+ input: StorageImagesConfiguration,
+): TransloaditImageIntegration | TransloaditRedirectImageIntegration {
+ const configuration =
+ typeof input?.delivery === 'object' && input.delivery !== null
+ ? {
+ ...input,
+ baseUrl: input.baseUrl ?? input.delivery.baseUrl,
+ urlParams: input.urlParams ?? input.delivery.urlParams,
+ delivery: undefined,
+ }
+ : input
+ const policy = getStoragePolicy(configuration)
+ const explicit = {
+ authKey: configuration.authKey,
+ authSecret: configuration.authSecret,
+ workspace: configuration.workspace,
+ }
+ if (configuration.authorize !== undefined || configuration.delivery === 'direct')
+ for (const [name, value] of Object.entries(explicit))
+ if (value !== undefined) validateRequiredConfiguration(value, name)
+ let workspace: string | undefined
+ function getWorkspace(): string {
+ if (workspace === undefined) {
+ const value = process.env.TRANSLOADIT_WORKSPACE || explicit.workspace
+ validateRequiredConfiguration(value, 'TRANSLOADIT_WORKSPACE')
+ workspace = value
+ }
+ return workspace
+ }
+ let credentials: { authKey: string; authSecret: string; workspace: string } | undefined
+ return createImageIntegration(
+ configuration,
+ policy,
+ () => {
+ if (credentials === undefined) {
+ const separate =
+ process.env.TRANSLOADIT_SMART_CDN_KEY !== undefined ||
+ process.env.TRANSLOADIT_SMART_CDN_SECRET !== undefined
+ const explicitPair = explicit.authKey !== undefined || explicit.authSecret !== undefined
+ const keyName = explicitPair
+ ? 'authKey'
+ : separate
+ ? 'TRANSLOADIT_SMART_CDN_KEY'
+ : 'TRANSLOADIT_KEY'
+ const secretName = explicitPair
+ ? 'authSecret'
+ : separate
+ ? 'TRANSLOADIT_SMART_CDN_SECRET'
+ : 'TRANSLOADIT_SECRET'
+ const authKey = explicitPair ? explicit.authKey : process.env[keyName]
+ const authSecret = explicitPair ? explicit.authSecret : process.env[secretName]
+ if (!explicitPair && authKey === undefined && authSecret === undefined)
+ throw new TypeError(
+ 'Private images need a signing key. Set TRANSLOADIT_SMART_CDN_KEY and TRANSLOADIT_SMART_CDN_SECRET (Console → Credentials → New Auth Key → “Private image delivery”). TRANSLOADIT_KEY/SECRET are also accepted.',
+ )
+ validateRequiredConfiguration(authKey, keyName)
+ validateRequiredConfiguration(authSecret, secretName)
+ credentials = { authKey, authSecret, workspace: getWorkspace() }
+ }
+ return credentials
+ },
+ getWorkspace,
+ )
+}
diff --git a/packages/img/test/conventional.test.tsx b/packages/img/test/conventional.test.tsx
new file mode 100644
index 00000000..105d0760
--- /dev/null
+++ b/packages/img/test/conventional.test.tsx
@@ -0,0 +1,178 @@
+import type { StorageProjectCatalog } from '../src/next/catalog.ts'
+import type { AuthorizeTransloaditStorageImage } from '../src/next/server.tsx'
+
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { parseSmartCdnUrl } from '@transloadit/utils/node'
+import { Window } from 'happy-dom'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, expect, test, vi } from 'vitest'
+
+const project = vi.hoisted<{
+ catalog: StorageProjectCatalog
+ authorize: AuthorizeTransloaditStorageImage | undefined
+ authorizePath?: string
+}>(() => ({
+ catalog: {
+ workspace: 'catalog-app',
+ public: ['website/'],
+ images: {
+ 'website/hero.jpg': { path: 'website/hero.jpg', width: 1200, height: 800 },
+ 'uploads/avatar.png': { path: 'uploads/avatar.png', width: 96, height: 96 },
+ },
+ delivery: undefined,
+ },
+ authorize: undefined,
+}))
+const window = new Window()
+vi.mock('server-only', () => ({}))
+vi.mock('@transloadit/img/next/catalog', () => ({
+ get default() {
+ return project.catalog
+ },
+}))
+vi.mock('@transloadit/img/next/authorize', () => ({
+ get authorize() {
+ return project.authorize
+ },
+}))
+vi.mock('@transloadit/img/next/options', () => ({
+ default: {
+ diagnosticsId: 'conventional-test',
+ get authorizePath() {
+ return project.authorizePath
+ },
+ },
+}))
+
+beforeEach(() => {
+ vi.resetModules()
+ vi.stubEnv('NODE_ENV', 'production')
+ for (const name of [
+ 'TRANSLOADIT_WORKSPACE',
+ 'TRANSLOADIT_KEY',
+ 'TRANSLOADIT_SECRET',
+ 'TRANSLOADIT_SMART_CDN_KEY',
+ 'TRANSLOADIT_SMART_CDN_SECRET',
+ ])
+ vi.stubEnv(name, undefined)
+ project.authorize = undefined
+ project.authorizePath = undefined
+ project.catalog.public = ['website/']
+ project.catalog.delivery = undefined
+})
+afterEach(() => {
+ vi.unstubAllEnvs()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+test('package StorageImage renders a catalog path with intrinsic dimensions and no secrets', async () => {
+ const { StorageImage } = await import('../src/next/react-server.tsx')
+ const html = renderToStaticMarkup(
+ ,
+ )
+ const document = new window.DOMParser().parseFromString(html, 'text/html')
+ expect(document.querySelector('img')?.getAttribute('height')).toBe('640')
+ const url = document.querySelector('img')?.getAttribute('src')
+ expect(url).toContain('catalog-app.tlcdn.com')
+ expect(url).not.toMatch(/auth_key|sig=|exp=/)
+})
+
+test('catalog delivery overrides are used without a generated factory', async () => {
+ project.catalog.delivery = {
+ baseUrl: 'http://127.0.0.1:32189/file/{workspace}',
+ urlParams: { cdn: 'required' },
+ }
+ const { StorageImage } = await import('../src/next/react-server.tsx')
+ const html = renderToStaticMarkup()
+ expect(html).toContain('http://127.0.0.1:32189/file/catalog-app/')
+ expect(html).toContain('cdn=required')
+})
+
+test.each([
+ 'development',
+ 'production',
+])('an authorizer added after bundling gets restart advice only in %s', async (environment) => {
+ vi.stubEnv('NODE_ENV', environment)
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 200 })),
+ )
+ const directory = await mkdtemp(join(tmpdir(), 'img-late-authorizer-'))
+ project.authorizePath = join(directory, 'transloadit.authorize.ts')
+ try {
+ const { StorageImage } = await import('../src/next/react-server.tsx')
+ const renderPrivate = () =>
+ renderToStaticMarkup()
+ expect(renderPrivate).toThrow("Private images require authorize or delivery: 'direct'")
+ await writeFile(project.authorizePath, 'export const authorize = () => false\n')
+ expect(renderPrivate).toThrow(
+ environment === 'development'
+ ? 'transloadit.authorize.ts exists but was added after next dev started. Restart next dev to bundle it.'
+ : "Private images require authorize or delivery: 'direct'",
+ )
+ // The late file never changes the bundled policy or blocks already-public delivery.
+ const html = renderToStaticMarkup()
+ expect(html).toContain('builtin%2Fpublic-preview')
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+})
+
+test('the conventional private handler checks each request and never returns image bytes', async () => {
+ vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'app-key')
+ vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'app-secret')
+ const authorize = vi.fn(
+ ({ path, request }: { path: string; request: Request }) =>
+ path === 'uploads/avatar.png' && request.headers.get('cookie') === 'session=allowed',
+ )
+ project.authorize = authorize
+ const { StorageImage } = await import('../src/next/react-server.tsx')
+ const { GET, HEAD } = await import('../src/next/route.ts')
+ const html = renderToStaticMarkup()
+ const document = new window.DOMParser().parseFromString(html, 'text/html')
+ const src = document.querySelector('img')?.getAttribute('src')
+ if (src === null || src === undefined) throw new Error('Expected private image route')
+ const request = new Request(new URL(src, 'https://app.example'))
+ expect((await GET(request)).status).toBe(404)
+ const allowed = await HEAD(new Request(request, { headers: { cookie: 'session=allowed' } }))
+ expect(authorize).toHaveBeenCalledTimes(2)
+ expect(allowed.status).toBe(307)
+ expect(await allowed.text()).toBe('')
+ expect(parseSmartCdnUrl(allowed.headers.get('location') ?? '').input).toBe('uploads/avatar.png')
+ expect(html).not.toMatch(/app-key|app-secret|auth_key/)
+})
+
+test('a catalog reload names newly private paths once, never in production', async () => {
+ vi.stubEnv('NODE_ENV', 'development')
+ vi.stubEnv('TRANSLOADIT_SMART_CDN_KEY', 'app-key')
+ vi.stubEnv('TRANSLOADIT_SMART_CDN_SECRET', 'app-secret')
+ project.authorize = () => false
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
+ const { getProjectImages } = await import('../src/next/project.ts')
+ getProjectImages()
+ expect(info).not.toHaveBeenCalled()
+ project.catalog.public = []
+ vi.resetModules()
+ const refreshed = await import('../src/next/project.ts')
+ refreshed.getProjectImages()
+ refreshed.getProjectImages()
+ expect(info).toHaveBeenCalledExactlyOnceWith(
+ '[StorageImage] Catalog public prefixes changed. These paths now require the private image route and authorization: "website/hero.jpg".',
+ )
+ const { StorageImage } = refreshed.getProjectImages()
+ const html = renderToStaticMarkup()
+ expect(html).toContain('/api/storage-images?cap=')
+ expect(html).not.toContain('builtin%2Fpublic-preview')
+ project.catalog.public = ['website/']
+ vi.resetModules()
+ ;(await import('../src/next/project.ts')).getProjectImages()
+ vi.stubEnv('NODE_ENV', 'production')
+ project.catalog.public = []
+ vi.resetModules()
+ ;(await import('../src/next/project.ts')).getProjectImages()
+ expect(info).toHaveBeenCalledOnce()
+})
diff --git a/packages/img/test/model.test.ts b/packages/img/test/model.test.ts
index 292769eb..8ab234a6 100644
--- a/packages/img/test/model.test.ts
+++ b/packages/img/test/model.test.ts
@@ -21,6 +21,180 @@ function collectSignedRequests(): {
}
describe('createTransloaditImageModel', () => {
+ test.each([
+ { widths: undefined },
+ { widths: [1, 100] },
+ ])('explains a subpixel source crop before signing (widths $widths)', ({ widths }) => {
+ const { requests, sign } = collectSignedRequests()
+ expect(() =>
+ createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/banner.jpg', width: 1000, height: 10 },
+ cropAspectRatio: 0.01,
+ widths,
+ },
+ sign,
+ ),
+ ).toThrow(/source dimensions.*cropAspectRatio.*one pixel/)
+ expect(requests).toEqual([])
+ })
+
+ test('accepts the one-pixel source crop boundary without upscaling', () => {
+ const { requests, sign } = collectSignedRequests()
+ createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/banner.jpg', width: 1000, height: 10 },
+ cropAspectRatio: 0.1,
+ widths: [1, 100],
+ },
+ sign,
+ )
+ expect(requests.map(({ urlParams }) => [urlParams.w, urlParams.h])).toEqual([
+ [1, 10],
+ [1, 10],
+ [1, 10],
+ ])
+ })
+
+ test.each([
+ undefined,
+ '#224466',
+ '#AABBCCFF',
+ ])('preserves alpha per candidate and gives JPEG an opaque background (%s)', (fallbackBackground) => {
+ const { requests, sign } = collectSignedRequests()
+ createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/logo.png', width: 64, height: 64 },
+ formats: { avif: 45, webp: 75, png: 75 },
+ fallbackBackground,
+ widths: [32, 64],
+ },
+ sign,
+ )
+ expect(requests).toHaveLength(7)
+ expect(requests.map(({ template }) => template)).toEqual(
+ Array(7).fill('builtin/storage-preview@0.0.2'),
+ )
+ expect(requests.slice(0, -1).map(({ urlParams }) => urlParams.bg)).toEqual(
+ Array(6).fill('#00000000'),
+ )
+ expect(requests.at(-1)?.urlParams).toMatchObject({
+ bg: fallbackBackground ?? '#ffffff',
+ f: 'jpg',
+ })
+ })
+
+ test.each([
+ 'transparent',
+ '#00000000',
+ '#22446680',
+ '#fff',
+ '#ffffff\n',
+ 'ffffff',
+ ])('rejects an invalid or nonopaque JPEG background before signing: %j', (fallbackBackground) => {
+ const { requests, sign } = collectSignedRequests()
+ expect(() =>
+ createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/logo.png', width: 64, height: 64 },
+ fallbackBackground,
+ },
+ sign,
+ ),
+ ).toThrow(/fallbackBackground.*opaque/)
+ expect(requests).toEqual([])
+ })
+
+ test('signs cover crops in the box ratio without upscaling source pixels', () => {
+ const { requests, sign } = collectSignedRequests()
+ const model = createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/hero.jpg', width: 2400, height: 1600 },
+ cropAspectRatio: 9 / 16,
+ widths: [390, 780, 2400],
+ fallbackWidth: 390,
+ },
+ sign,
+ )
+ expect(model.sources[0]?.candidates.map(({ width }) => width)).toEqual([390, 780, 900])
+ expect(requests[0]?.urlParams).toMatchObject({ r: 'fillcrop', w: 390, h: 693 })
+ expect(requests.at(-1)?.urlParams).toMatchObject({ r: 'fillcrop', w: 390, h: 693, f: 'jpg' })
+ })
+
+ test('caps the default ladder at an explicit maximum without changing source proportions', () => {
+ const { requests, sign } = collectSignedRequests()
+ const model = createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/hero.jpg', width: 2400, height: 1600 },
+ maximumWidth: 1920,
+ },
+ sign,
+ )
+ expect(model.sources[0]?.candidates.at(-1)?.width).toBe(1920)
+ expect(requests.at(-1)?.urlParams).toMatchObject({ r: 'pad', w: 1920, h: 1280 })
+ })
+
+ test.each([
+ 0,
+ -1,
+ Number.NaN,
+ Number.POSITIVE_INFINITY,
+ ])('rejects invalid crop aspect ratio %s before signing', (cropAspectRatio) => {
+ const { requests, sign } = collectSignedRequests()
+ expect(() =>
+ createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/hero.jpg', width: 2400, height: 1600 },
+ cropAspectRatio,
+ },
+ sign,
+ ),
+ ).toThrow('cropAspectRatio')
+ expect(requests).toEqual([])
+ })
+
+ test('caps the JPEG fallback at the largest requested candidate', () => {
+ const { requests, sign } = collectSignedRequests()
+ createTransloaditImageModel(
+ {
+ expiresAt,
+ src: { path: 'website/avatar.jpg', width: 400, height: 400 },
+ widths: [96, 48],
+ },
+ sign,
+ )
+ expect(requests.at(-1)?.urlParams).toMatchObject({ f: 'jpg', w: 96, h: 96 })
+ })
+
+ test('uses receipt geometry without forwarding ancillary receipt fields to signing', () => {
+ const src = {
+ path: 'documents/report.pdf',
+ width: 400,
+ height: 300,
+ asset_id: 'private-id',
+ authSecret: 'not-a-signing-option',
+ }
+ const fromReceipt = collectSignedRequests()
+ const fromString = collectSignedRequests()
+ const options = { expiresAt, widths: [200, 400] }
+ expect(createTransloaditImageModel({ ...options, src }, fromReceipt.sign)).toEqual(
+ createTransloaditImageModel(
+ { ...options, src: src.path, width: 400, height: 300 },
+ fromString.sign,
+ ),
+ )
+ expect(fromReceipt.requests).toEqual(fromString.requests)
+ expect(JSON.stringify(fromReceipt.requests)).not.toContain('private-id')
+ expect(JSON.stringify(fromReceipt.requests)).not.toContain('not-a-signing-option')
+ })
+
test('builds responsive Storage previews and a signed JPEG fallback', () => {
const { requests, sign } = collectSignedRequests()
const model = createTransloaditImageModel(
@@ -53,20 +227,20 @@ describe('createTransloaditImageModel', () => {
{
expiresAt,
input: 'documents/report.pdf',
- template: 'builtin/storage-preview@0.0.1',
- urlParams: { f: 'webp', h: 150, q: 61, r: 'pad', w: 200 },
+ template: 'builtin/storage-preview@0.0.2',
+ urlParams: { bg: '#00000000', f: 'webp', h: 150, q: 61, r: 'pad', w: 200 },
},
{
expiresAt,
input: 'documents/report.pdf',
- template: 'builtin/storage-preview@0.0.1',
- urlParams: { f: 'webp', h: 300, q: 61, r: 'pad', w: 400 },
+ template: 'builtin/storage-preview@0.0.2',
+ urlParams: { bg: '#00000000', f: 'webp', h: 300, q: 61, r: 'pad', w: 400 },
},
{
expiresAt,
input: 'documents/report.pdf',
- template: 'builtin/storage-preview@0.0.1',
- urlParams: { f: 'jpg', h: 300, q: 68, r: 'pad', w: 400 },
+ template: 'builtin/storage-preview@0.0.2',
+ urlParams: { bg: '#ffffff', f: 'jpg', h: 300, q: 68, r: 'pad', w: 400 },
},
])
})
@@ -122,7 +296,14 @@ describe('createTransloaditImageModel', () => {
)
expect(model.sources[0]?.candidates.map(({ width }) => width)).toEqual([200, 400])
- expect(requests.at(-1)?.urlParams).toEqual({ f: 'jpg', h: 300, q: 75, r: 'pad', w: 400 })
+ expect(requests.at(-1)?.urlParams).toEqual({
+ bg: '#ffffff',
+ f: 'jpg',
+ h: 300,
+ q: 75,
+ r: 'pad',
+ w: 400,
+ })
})
test('rejects an invalid fallback quality before signing any candidate', () => {
@@ -163,7 +344,14 @@ describe('createTransloaditImageModel', () => {
[400, 400],
)
expect(requests.slice(0, -1).every(({ urlParams }) => urlParams.h === 1200)).toBe(true)
- expect(requests.at(-1)?.urlParams).toEqual({ f: 'jpg', h: 1200, q: 75, r: 'pad', w: 400 })
+ expect(requests.at(-1)?.urlParams).toEqual({
+ bg: '#ffffff',
+ f: 'jpg',
+ h: 1200,
+ q: 75,
+ r: 'pad',
+ w: 400,
+ })
})
test.each([
diff --git a/packages/img/test/next-config.test.ts b/packages/img/test/next-config.test.ts
new file mode 100644
index 00000000..c06f2251
--- /dev/null
+++ b/packages/img/test/next-config.test.ts
@@ -0,0 +1,183 @@
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { promisify } from 'node:util'
+
+import {
+ PHASE_DEVELOPMENT_SERVER,
+ PHASE_PRODUCTION_BUILD,
+ PHASE_PRODUCTION_SERVER,
+} from 'next/constants.js'
+import { afterEach, beforeEach, expect, test, vi } from 'vitest'
+
+import { withTransloaditImages } from '../src/next/config.ts'
+
+let root: string
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'img-next-config-'))
+ await writeFile(
+ join(root, 'transloadit.images.json'),
+ JSON.stringify({ workspace: 'my-app', public: ['website/'], images: {} }),
+ )
+})
+afterEach(async () => {
+ await rm(root, { recursive: true, force: true })
+})
+
+test('the documented upload factory, route and receipt page typecheck together', async () => {
+ const repoRoot = resolve(import.meta.dirname, '../../..')
+ const reference = await readFile(resolve(repoRoot, 'packages/img/docs/reference.md'), 'utf8')
+ const uploads = reference.slice(
+ reference.indexOf('### Images uploaded by your users'),
+ reference.indexOf('### Credentials and framework adapters'),
+ )
+ // This package's test command builds its declarations; root script tests run before that build.
+ await symlink(resolve(repoRoot, 'node_modules'), join(root, 'node_modules'), 'dir')
+ const files: string[] = []
+ for (const block of uploads.split('```')) {
+ const match = block.match(/^tsx?\n\/\/ (app\/[^\n]+)\n([\s\S]*)$/)
+ if (match?.[1] === undefined || match[2] === undefined) continue
+ const file = join(root, match[1])
+ await mkdir(dirname(file), { recursive: true })
+ await writeFile(file, match[2])
+ files.push(file)
+ }
+ expect(files).toHaveLength(3)
+ await mkdir(join(root, 'lib'))
+ // Only application-owned helpers are declared; SDK/framework imports use real declarations.
+ await writeFile(
+ join(root, 'lib/authorization.ts'),
+ 'export declare function getSession(request: Request): Promise<{ canRead(path: string): boolean } | null>\n',
+ )
+ await writeFile(
+ join(root, 'lib/images.ts'),
+ "import type { TransloaditImageSource } from '@transloadit/img'\nexport declare function getAuthorizedImage(id: string): Promise\n",
+ )
+ const result = await promisify(execFile)(
+ process.execPath,
+ [
+ resolve(repoRoot, 'node_modules/typescript/bin/tsc'),
+ '--ignoreConfig',
+ '--noEmit',
+ '--strict',
+ // Check the recipe without rechecking dependency internals.
+ '--skipLibCheck',
+ '--target',
+ 'es2022',
+ '--module',
+ 'esnext',
+ '--moduleResolution',
+ 'bundler',
+ '--jsx',
+ 'react-jsx',
+ '--esModuleInterop',
+ '--types',
+ 'node,react',
+ ...files,
+ ],
+ { cwd: root, timeout: 25_000 },
+ )
+ expect(result.stdout).toBe('')
+}, 30_000)
+
+test('only development carries a stable catalog identity for hot-reload diagnostics', async () => {
+ const plugin = withTransloaditImages({}, { root })
+ const path = join(root, 'node_modules/.cache/transloadit-images/options.json')
+ plugin(PHASE_DEVELOPMENT_SERVER)
+ expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({
+ authorizePath: join(root, 'transloadit.authorize.ts'),
+ diagnosticsId: join(root, 'transloadit.images.json'),
+ })
+ plugin(PHASE_PRODUCTION_BUILD)
+ expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({})
+})
+
+test('binds the conventional catalog and retains unrelated Next configuration', async () => {
+ const config = withTransloaditImages(
+ {
+ basePath: '/site',
+ outputFileTracingIncludes: { '/*': ['./existing.json'], '/other': ['./other.json'] },
+ turbopack: { resolveAlias: { existing: './existing.ts' } },
+ },
+ { root },
+ )(PHASE_PRODUCTION_BUILD)
+ expect(config.basePath).toBe('/site')
+ expect(config.turbopack?.resolveAlias).toMatchObject({
+ existing: './existing.ts',
+ '@transloadit/img/next/catalog': './transloadit.images.json',
+ })
+ expect(config.outputFileTracingIncludes).toMatchObject({
+ '/*': expect.arrayContaining(['./existing.json', './transloadit.images.json']),
+ '/other': ['./other.json'],
+ })
+ expect(
+ JSON.parse(
+ await readFile(join(root, 'node_modules/.cache/transloadit-images/options.json'), 'utf8'),
+ ),
+ ).toEqual({ basePath: '/site' })
+})
+
+test('discovers authorization and allows a custom catalog and delivery without touching the source', async () => {
+ await mkdir(join(root, 'assets'))
+ await writeFile(join(root, 'assets/images.json'), '{}')
+ await writeFile(join(root, 'transloadit.authorize.ts'), 'export const authorize = () => false\n')
+ const delivery = {
+ baseUrl: 'http://127.0.0.1:32189/file/{workspace}',
+ urlParams: { cdn: 'required' },
+ }
+ const config = withTransloaditImages(
+ {},
+ { root, catalog: 'assets/images.json', delivery },
+ )(PHASE_PRODUCTION_BUILD)
+ expect(config.turbopack?.resolveAlias).toMatchObject({
+ '@transloadit/img/next/catalog': './assets/images.json',
+ '@transloadit/img/next/authorize': './transloadit.authorize.ts',
+ })
+ expect(
+ JSON.parse(
+ await readFile(join(root, 'node_modules/.cache/transloadit-images/options.json'), 'utf8'),
+ ),
+ ).toEqual({ delivery })
+ expect(await readFile(join(root, 'assets/images.json'), 'utf8')).toBe('{}')
+})
+
+test('missing catalog names the upload command, not image init', async () => {
+ await rm(join(root, 'transloadit.images.json'))
+ expect(() => withTransloaditImages({}, { root })(PHASE_PRODUCTION_BUILD)).toThrow(/storage store/)
+})
+
+test('production start requires neither a source catalog nor regenerating a pruned cache', async () => {
+ await rm(join(root, 'transloadit.images.json'))
+ const plugin = withTransloaditImages({ basePath: '/site' }, { root })
+ const config = plugin(PHASE_PRODUCTION_SERVER)
+ expect(config.basePath).toBe('/site')
+ await expect(
+ readFile(join(root, 'node_modules/.cache/transloadit-images/options.json')),
+ ).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+test('the webpack adapter applies exact aliases after preserving the application hook', () => {
+ const upstream = vi.fn(() => ({ resolve: { alias: { other: '/app/other.ts' } }, retained: true }))
+ const config = withTransloaditImages({ webpack: upstream }, { root })(PHASE_PRODUCTION_BUILD)
+ if (typeof config.webpack !== 'function') throw new Error('Expected the bundler hook')
+ const input = { name: 'server' }
+ const context = { isServer: true }
+ const output = Reflect.apply(config.webpack, undefined, [input, context])
+ expect(upstream).toHaveBeenCalledExactlyOnceWith(input, context)
+ expect(output).toMatchObject({
+ retained: true,
+ resolve: {
+ alias: {
+ other: '/app/other.ts',
+ '@transloadit/img/next/catalog$': join(root, 'transloadit.images.json'),
+ },
+ },
+ })
+})
+
+test('declines catalog paths outside the app root instead of silently depending on untraced files', () => {
+ expect(() =>
+ withTransloaditImages({}, { root, catalog: '../outside.json' })(PHASE_PRODUCTION_BUILD),
+ ).toThrow(/inside the Next.js app/)
+})
diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx
index 29c3686c..db5ba152 100644
--- a/packages/img/test/next-server.test.tsx
+++ b/packages/img/test/next-server.test.tsx
@@ -2,7 +2,11 @@
import type { ReactNode } from 'react'
+import type { StorageImagesConfiguration } from '../src/next/server.tsx'
+
import { parseSmartCdnUrl } from '@transloadit/utils/node'
+import { act } from 'react'
+import { createRoot } from 'react-dom/client'
import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
@@ -11,16 +15,18 @@ const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefin
vi.mock('next/server.js', () => ({ connection }))
vi.mock('server-only', () => ({}))
-import { createTransloaditImage } from '../src/next/server.tsx'
+import { createImageDiagnostics } from '../src/next/diagnostics.ts'
+import { createStorageImages } from '../src/next/server.tsx'
const authSecret = 'never-render-this-secret'
const baseConfiguration = {
+ delivery: 'direct',
authKey: 'auth-key',
authSecret,
baseUrl: 'https://cdn.example/file/{workspace}',
- storage: { allowedPathPrefixes: ['documents/'] },
+ allowedPathPrefixes: ['documents/'],
workspace: 'my-app',
-}
+} satisfies StorageImagesConfiguration
async function renderAsync(node: ReactNode): Promise {
const stream = await renderToReadableStream(node)
@@ -49,12 +55,11 @@ function getStorageRouteCandidate(): {
({ path, request }: { path: string; request: Request }): boolean =>
path === 'documents/report.pdf' && request.headers.get('authorization') === 'Bearer allowed',
)
- const { Image, storageRoute } = createTransloaditImage({
+ const { StorageImage: Image, storageRoute } = createStorageImages({
...baseConfiguration,
- storage: {
- allowedPathPrefixes: ['documents/'],
- delivery: { authorize, route: '/api/private-images' },
- },
+ allowedPathPrefixes: ['documents/'],
+ authorize,
+ route: '/api/private-images',
})
const markup = renderToStaticMarkup(
{
vi.useRealTimers()
})
-describe('createTransloaditImage', () => {
+describe('development delivery diagnostics', () => {
+ test.each([
+ 'HTTP failure',
+ 'network failure',
+ ])('gives actionable %s advice without requiring a development restart', async (failure) => {
+ if (failure === 'HTTP failure') {
+ vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 400 }))
+ } else {
+ vi.mocked(fetch).mockRejectedValue(new Error(`Could not fetch ${authSecret}`))
+ }
+ const { StorageImage } = createStorageImages({
+ ...baseConfiguration,
+ baseUrl: 'https://cdn.example:8443/file/{workspace}',
+ urlParams: { token: 'never-log-query-token' },
+ })
+ const markup = await renderAsync(
+ ,
+ )
+ const target = new URL(getFirstCandidate(parseMarkup(markup)))
+ expect(fetch).toHaveBeenCalledWith(target.href, expect.objectContaining({ method: 'HEAD' }))
+ expect(console.warn).toHaveBeenCalledOnce()
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining(`${target.origin}${target.pathname}`),
+ )
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('baseUrl/urlParams'))
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Check'))
+ expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toMatch(
+ /restart|never-render-this-secret|never-log-query-token|auth-key|\?|sig=|exp=/,
+ )
+ })
+
+ test('does not call a redirecting origin a failed image delivery', async () => {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(null, { status: 307, headers: { Location: 'https://cdn.example/image' } }),
+ )
+ const { StorageImage } = createStorageImages(baseConfiguration)
+ await renderAsync(
+ ,
+ )
+ expect(fetch).toHaveBeenCalledOnce()
+ expect(console.warn).not.toHaveBeenCalled()
+ })
+ test('explains denied route boundaries with static, deduplicated reasons only', async () => {
+ const { storageRoute, url } = getStorageRouteCandidate()
+ const wrongRoute = new URL(url)
+ wrongRoute.pathname = '/wrong-route'
+ expect((await storageRoute(new Request(wrongRoute))).status).toBe(404)
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('route/basePath'))
+ const malformed = new URL(url)
+ malformed.searchParams.set('cap', 'secret-sentinel')
+ expect((await storageRoute(new Request(malformed))).status).toBe(404)
+ expect((await storageRoute(new Request(malformed))).status).toBe(404)
+ expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/secret.*template/))
+ expect(console.warn).toHaveBeenCalledTimes(2)
+ expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('secret-sentinel')
+ expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(url.href)
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ test('explains direct rendering once per integration, without logging credentials or URLs', async () => {
+ const { StorageImage } = createStorageImages(baseConfiguration)
+ const props = { alt: 'Hero', src: { path: 'documents/hero.jpg', width: 400, height: 300 } }
+ await renderAsync()
+ await renderAsync()
+ expect(console.info).toHaveBeenCalledExactlyOnceWith(
+ 'StorageImage (direct) makes this route dynamic; use redirect delivery for static pages',
+ )
+ })
+
+ test.each([
+ 'GET',
+ 'HEAD',
+ ])('a denied private %s route names its path and the opt-in publication fix', async (method) => {
+ const { StorageImage, storageRoute } = createStorageImages({
+ ...baseConfiguration,
+ delivery: undefined,
+ public: [],
+ authorize: () => false,
+ })
+ const markup = renderToStaticMarkup(
+ ,
+ )
+ const url = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example')
+ const denied = await storageRoute(new Request(url, { method }))
+ expect(denied.status).toBe(404)
+ expect(await denied.text()).toBe('')
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('"documents/hero.jpg"'))
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('not under a public prefix'))
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('If it should be public'))
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining('current image configuration'),
+ )
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('--receipts '))
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining('explicit factory’s public list'),
+ )
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringMatching(/npx transloadit storage publish -- documents\/$/),
+ )
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining('storage publish -- documents/'),
+ )
+ await storageRoute(new Request(url))
+ expect(console.warn).toHaveBeenCalledOnce()
+ expect(fetch).not.toHaveBeenCalled()
+ expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toMatch(
+ /cap=|auth-key|never-render-this-secret/,
+ )
+ })
+
+ test('an inline image fallback keeps development advice valid inside a paragraph', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(false)
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const { StorageImage } = createStorageImages({ ...baseConfiguration, authorize: () => false })
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ try {
+ await act(() =>
+ root.render(
+